mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-01 11:29:28 +02:00
Compare commits
6 Commits
fix/self-h
...
codex/comm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc286d85f6 | ||
|
|
bbd900635e | ||
|
|
170750242b | ||
|
|
240ec4efd0 | ||
|
|
fc88c7720f | ||
|
|
ad1afdd48d |
@@ -34,6 +34,10 @@ features:
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /issue
|
||||
description: Create a Multica issue
|
||||
usage_hint: "[title]"
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -45,6 +49,7 @@ oauth_config:
|
||||
- chat:write
|
||||
- reactions:write
|
||||
- users:read
|
||||
- commands
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
@@ -72,8 +77,10 @@ settings:
|
||||
| `im:history` + `message.im` | Bot への **DM** を受け取ります(すべての DM メッセージが読み取られます)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 対応する `message.*` イベント | パブリックチャンネル、プライベートチャンネル、グループ DM のメッセージを受け取ります。これらの中では、Bot は自分を **@ メンション**したメッセージにのみ反応します。 |
|
||||
| `users:read` | Multica が(`bots.info` を介して)あなたの 2 つのトークンが同じアプリのものであることを検証するために必要です。 |
|
||||
| `commands` | `/issue` スラッシュコマンドを有効にする bot スコープです(`features.slash_commands` と対になります)。これがないと、マニフェストを更新して再インストールしてもコマンドが付与されません。 |
|
||||
| `socket_mode_enabled: true` | Bot は Socket Mode 経由で外向きに接続します——**公開 URL/リクエスト URL は不要**です。 |
|
||||
| `interactivity.is_enabled: false` | Multica のプロンプトはボタンではなくプレーンなリンクなので、インタラクティビティは不要です。 |
|
||||
| `slash_commands`(`/issue`) | `/issue` スラッシュコマンドを登録し、誰でもメッセージ入力欄から Multica イシューを起票できるようにします。Socket Mode 経由で配信され、リクエスト URL は不要です。 |
|
||||
|
||||
**OAuth リダイレクト URL はありません**。BYO は OAuth を使わないからです。
|
||||
|
||||
@@ -118,7 +125,7 @@ app-level トークンは Socket Mode 接続を認可します。これはコン
|
||||
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
|
||||
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
|
||||
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
|
||||
| **`/issue` コマンド** | `/issue <タイトル>`(続く行に本文を足してもよい)でメッセージを始めると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。 |
|
||||
| **`/issue` スラッシュコマンド** | `/issue <タイトル>`(チャンネルでも DM でも)と入力すると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。確認は本人にだけ返信されます——@ メンションは不要です。 |
|
||||
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
|
||||
|
||||
## Bot を使う(メンバー)
|
||||
@@ -135,7 +142,7 @@ Bot を使えるのは **ワークスペースのメンバー** だけです。
|
||||
|
||||
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてください(Bot は自分をメンションしたメッセージだけを読みます)。
|
||||
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
|
||||
- **イシューを起票する** —— `/issue Fix the login redirect` と送ります。タイトルの後ろに行を足せば、それが説明になります。
|
||||
- **イシューを起票する** —— `/issue` スラッシュコマンドを使います(例: `/issue Fix the login redirect`)。チャンネルでも DM でも使え(@ メンション不要)、確認は本人にだけ返信されます。初回のユーザーには、まずアカウント連携の使い切りリンクが届きます。
|
||||
|
||||
## 管理と切断
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ features:
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /issue
|
||||
description: Create a Multica issue
|
||||
usage_hint: "[title]"
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -45,6 +49,7 @@ oauth_config:
|
||||
- chat:write
|
||||
- reactions:write
|
||||
- users:read
|
||||
- commands
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
@@ -72,8 +77,10 @@ settings:
|
||||
| `im:history` + `message.im` | 봇에게 보내는 **DM**을 받습니다(모든 DM 메시지를 읽습니다). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 대응하는 `message.*` 이벤트 | 공개 채널, 비공개 채널, 그룹 DM의 메시지를 받습니다. 이런 곳에서 봇은 자신을 **@로 멘션한** 메시지에만 반응합니다. |
|
||||
| `users:read` | Multica가 두 토큰이 같은 앱에 속하는지 (`bots.info`를 통해) 확인하는 데 필요합니다. |
|
||||
| `commands` | `/issue` 슬래시 명령을 활성화하는 bot 스코프입니다(`features.slash_commands`와 짝을 이룹니다). 이것이 없으면 매니페스트를 업데이트하고 재설치해도 명령이 부여되지 않습니다. |
|
||||
| `socket_mode_enabled: true` | 봇이 Socket Mode로 밖으로 연결합니다 — **공개 URL / request URL이 필요 없습니다**. |
|
||||
| `interactivity.is_enabled: false` | Multica의 안내는 버튼이 아니라 일반 링크라서, interactivity가 필요 없습니다. |
|
||||
| `slash_commands`(`/issue`) | `/issue` 슬래시 명령을 등록해, 누구나 메시지 입력창에서 Multica 이슈를 생성할 수 있게 합니다. Socket Mode로 전달되며 요청 URL이 필요 없습니다. |
|
||||
|
||||
**OAuth redirect URL은 없습니다.** BYO는 OAuth를 사용하지 않기 때문입니다.
|
||||
|
||||
@@ -118,7 +125,7 @@ app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성
|
||||
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
|
||||
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
|
||||
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
|
||||
| **`/issue` 명령** | `/issue <제목>`(다음 줄에 본문 추가 가능)으로 메시지를 시작하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. |
|
||||
| **`/issue` 슬래시 명령** | `/issue <제목>`을(채널이나 DM에서) 입력하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. 확인은 본인에게만 비공개로 답하며 — @ 멘션이 필요 없습니다. |
|
||||
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
|
||||
|
||||
## 봇 사용하기 (멤버)
|
||||
@@ -135,7 +142,7 @@ app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성
|
||||
|
||||
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
|
||||
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
|
||||
- **이슈 생성** — `/issue Fix the login redirect`를 보내세요. 제목 뒤에 줄을 더 추가하면 설명이 됩니다.
|
||||
- **이슈 생성** — `/issue` 슬래시 명령을 사용하세요(예: `/issue Fix the login redirect`). 채널이나 DM에서 모두 쓸 수 있고(@ 멘션 불필요), 확인은 본인에게만 비공개로 답합니다. 처음 사용하는 사람은 먼저 일회용 계정 연결 링크를 받습니다.
|
||||
|
||||
## 관리 및 연결 해제
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ features:
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /issue
|
||||
description: Create a Multica issue
|
||||
usage_hint: "[title]"
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -45,6 +49,7 @@ oauth_config:
|
||||
- chat:write
|
||||
- reactions:write
|
||||
- users:read
|
||||
- commands
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
@@ -72,8 +77,10 @@ This manifest configures everything Multica needs, so you don't set anything by
|
||||
| `im:history` + `message.im` | Receive **DMs** to the bot (every DM message is read). |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + the matching `message.*` events | Receive messages in public channels, private channels, and group DMs. In these, the bot only acts on messages that **@-mention** it. |
|
||||
| `users:read` | Required so Multica can verify (via `bots.info`) that your two tokens belong to the same app. |
|
||||
| `commands` | The bot scope that enables the `/issue` slash command (pairs with `features.slash_commands`). Without it, updating the manifest and reinstalling won't grant the command. |
|
||||
| `socket_mode_enabled: true` | The bot connects out over Socket Mode — **no public URL / request URL needed**. |
|
||||
| `interactivity.is_enabled: false` | Multica's prompts are plain links, not buttons, so interactivity isn't needed. |
|
||||
| `slash_commands` (`/issue`) | Registers the `/issue` slash command so anyone can file a Multica issue from the message box. Delivered over Socket Mode — no request URL. |
|
||||
|
||||
There is **no OAuth redirect URL**, because BYO doesn't use OAuth.
|
||||
|
||||
@@ -118,7 +125,7 @@ Setting this up for **multiple agents**? Repeat the whole flow once per agent
|
||||
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
|
||||
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
|
||||
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
|
||||
| **`/issue` command** | Starting a message with `/issue <title>` (optionally with a body on the next lines) creates a new Multica issue in the workspace, attributed to you. |
|
||||
| **`/issue` slash command** | Type `/issue <title>` (in a channel or a DM) to create a Multica issue in the workspace, attributed to you. It replies privately with a confirmation — no @-mention needed. |
|
||||
| **Reply** | The agent's answer is posted back into the same DM or thread. |
|
||||
|
||||
## Use the bot (members)
|
||||
@@ -135,7 +142,7 @@ Only **members of the workspace** can use the bot. If you aren't a member, or yo
|
||||
|
||||
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
|
||||
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
|
||||
- **File an issue** — send `/issue Fix the login redirect`; add more lines after the title for a description.
|
||||
- **File an issue** — use the `/issue` slash command, e.g. `/issue Fix the login redirect`. It works in a channel or a DM (no @-mention needed) and replies with a private confirmation. First-time users get a one-time link to connect their account.
|
||||
|
||||
## Manage and disconnect
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ features:
|
||||
bot_user:
|
||||
display_name: Multica
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /issue
|
||||
description: Create a Multica issue
|
||||
usage_hint: "[title]"
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -45,6 +49,7 @@ oauth_config:
|
||||
- chat:write
|
||||
- reactions:write
|
||||
- users:read
|
||||
- commands
|
||||
settings:
|
||||
event_subscriptions:
|
||||
bot_events:
|
||||
@@ -72,8 +77,10 @@ settings:
|
||||
| `im:history` + `message.im` | 接收发给 Bot 的**私聊**(每一条私聊消息都会被读取)。 |
|
||||
| `channels:history` / `groups:history` / `mpim:history` + 对应的 `message.*` 事件 | 接收公开频道、私有频道和群组私聊里的消息。在这些场景里,Bot 只对 **@ 了**它的消息做出响应。 |
|
||||
| `users:read` | 必需,这样 Multica 才能(通过 `bots.info`)核实你的两个 token 属于同一个 app。 |
|
||||
| `commands` | 启用 `/issue` 斜杠命令所需的 bot 权限(和 `features.slash_commands` 搭配)。少了它,即使更新 manifest 并重装,也拿不到这个命令。 |
|
||||
| `socket_mode_enabled: true` | Bot 通过 Socket Mode 向外连接——**无需任何公网 URL / request URL**。 |
|
||||
| `interactivity.is_enabled: false` | Multica 的提示是纯链接,不是按钮,所以不需要交互性。 |
|
||||
| `slash_commands`(`/issue`) | 注册 `/issue` 斜杠命令,让任何人都能从消息框直接创建一个 Multica issue。通过 Socket Mode 下发——无需 request URL。 |
|
||||
|
||||
这里**没有 OAuth 重定向 URL**,因为 BYO 不使用 OAuth。
|
||||
|
||||
@@ -118,7 +125,7 @@ App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建
|
||||
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
|
||||
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
|
||||
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
|
||||
| **`/issue` 命令** | 以 `/issue <标题>` 开头的消息(可在后面几行附上正文)会在工作区创建一个新的 Multica issue,记在你名下。 |
|
||||
| **`/issue` 斜杠命令** | 输入 `/issue <标题>`(在频道或私聊里)会在工作区创建一个新的 Multica issue,记在你名下。它会私下回一条确认——不需要 @ Bot。 |
|
||||
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
|
||||
|
||||
## 使用 Bot(成员)
|
||||
@@ -135,7 +142,7 @@ App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建
|
||||
|
||||
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下(Bot 只读取 @ 了它的消息)。
|
||||
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
|
||||
- **创建 issue** —— 发送 `/issue Fix the login redirect`;在标题后面再加几行就是描述。
|
||||
- **创建 issue** —— 使用 `/issue` 斜杠命令,比如 `/issue Fix the login redirect`。在频道或私聊里都能用(不用 @ Bot),会私下回一条确认。第一次使用的人会先收到一个一次性的账号绑定链接。
|
||||
|
||||
## 管理与断开
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ import { deriveThreadResolution } from "./thread-utils";
|
||||
|
||||
const highlightedCommentBackgroundClass =
|
||||
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
|
||||
const stickyHeaderFadeClass =
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]";
|
||||
|
||||
function StickyHeaderShell({
|
||||
className,
|
||||
@@ -59,19 +61,23 @@ function StickyHeaderShell({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!sticky) {
|
||||
return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<div className={cn(highlighted && highlightedCommentBackgroundClass, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-10 transition-colors duration-700 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]",
|
||||
"sticky top-0 z-10 transition-colors duration-700",
|
||||
!highlighted && stickyHeaderFadeClass,
|
||||
highlighted ? highlightedCommentBackgroundClass : "bg-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -510,7 +516,8 @@ function CommentRow({
|
||||
{/* Header pins to the timeline's scroll parent within this reply's own
|
||||
row box, so a LONG reply keeps its
|
||||
author + actions visible while you scroll its body, then releases once
|
||||
this reply ends. bg-card occludes the body scrolling underneath. */}
|
||||
this reply ends. The header stays opaque and matches this comment's
|
||||
highlight state while it occludes the body scrolling underneath. */}
|
||||
<StickyHeaderShell
|
||||
highlighted={isHighlighted}
|
||||
className="flex items-center gap-2.5 px-4 pt-1 pb-1.5"
|
||||
@@ -760,7 +767,7 @@ function CommentCardImpl({
|
||||
// overflow-clip (not -hidden) clips the rounded corners WITHOUT creating a
|
||||
// scroll container, so the sticky collapse affordances below resolve to the
|
||||
// timeline's scroll parent instead of this card. See PR #3623.
|
||||
<Card className={cn("!py-0 !gap-0 overflow-clip transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50", isHighlighted && highlightedCommentBackgroundClass)}>
|
||||
<Card className="!py-0 !gap-0 overflow-clip transition-colors duration-700">
|
||||
{onCollapseResolved && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -779,112 +786,112 @@ function CommentCardImpl({
|
||||
That is what keeps exactly one header pinned at a time: without this
|
||||
wrapper the header's containing block is the whole thread and it
|
||||
stays stuck behind every reply. */}
|
||||
<div>
|
||||
{/* Header — always visible, acts as toggle */}
|
||||
<StickyHeaderShell
|
||||
sticky={stickyHeader}
|
||||
highlighted={isHighlighted}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
||||
</CollapsibleTrigger>
|
||||
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
||||
<span className="shrink-0 cursor-pointer text-sm font-medium">
|
||||
{getActorName(entry.actor_type, entry.actor_id)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!open && contentPreview && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{contentPreview}
|
||||
<div className={cn("transition-colors duration-700", isHighlighted && highlightedCommentBackgroundClass)}>
|
||||
{/* Header — always visible, acts as toggle */}
|
||||
<StickyHeaderShell
|
||||
sticky={stickyHeader}
|
||||
highlighted={isHighlighted}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
||||
</CollapsibleTrigger>
|
||||
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
||||
<span className="shrink-0 cursor-pointer text-sm font-medium">
|
||||
{getActorName(entry.actor_type, entry.actor_id)}
|
||||
</span>
|
||||
)}
|
||||
{!open && replyCount > 0 && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{t(($) => $.comment.reply_count, { count: replyCount })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
void copyText(entry.content ?? "").then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
||||
});
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.copy_action)}
|
||||
</DropdownMenuItem>
|
||||
{onResolveToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, !entry.resolved_at)}>
|
||||
{entry.resolved_at ? (
|
||||
<>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.unresolve_thread_action)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.resolve_thread_action)}
|
||||
</>
|
||||
)}
|
||||
<TooltipContent side="top">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!open && contentPreview && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{contentPreview}
|
||||
</span>
|
||||
)}
|
||||
{!open && replyCount > 0 && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{t(($) => $.comment.reply_count, { count: replyCount })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
void copyText(entry.content ?? "").then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
||||
});
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.copy_action)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{(canEditEntry || canDeleteEntry) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={edit.startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.edit_action)}
|
||||
</DropdownMenuItem>
|
||||
{onResolveToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, !entry.resolved_at)}>
|
||||
{entry.resolved_at ? (
|
||||
<>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.unresolve_thread_action)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.resolve.resolve_thread_action)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
||||
{canDeleteEntry && (
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.delete_action)}
|
||||
</DropdownMenuItem>
|
||||
{(canEditEntry || canDeleteEntry) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={edit.startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.edit_action)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
||||
{canDeleteEntry && (
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.delete_action)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
hasReplies
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StickyHeaderShell>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
hasReplies
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StickyHeaderShell>
|
||||
|
||||
{/* Collapsible body */}
|
||||
<CollapsibleContent>
|
||||
@@ -992,7 +999,13 @@ function CommentCardImpl({
|
||||
</div>
|
||||
)}
|
||||
{resolutionReply && (
|
||||
<div id={`comment-${resolutionReply.id}`} className={cn("border-t border-border/50 transition-colors duration-700", highlightedCommentId === resolutionReply.id && highlightedCommentBackgroundClass)}>
|
||||
<div
|
||||
id={`comment-${resolutionReply.id}`}
|
||||
className={cn(
|
||||
"border-t border-border/50 transition-colors duration-700",
|
||||
highlightedCommentId === resolutionReply.id && highlightedCommentBackgroundClass,
|
||||
)}
|
||||
>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={resolutionReply}
|
||||
@@ -1024,7 +1037,14 @@ function CommentCardImpl({
|
||||
)}
|
||||
{/* Replies — chronological; the resolution keeps its place with a badge */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 transition-colors duration-700", highlightedCommentId === reply.id && highlightedCommentBackgroundClass)}>
|
||||
<div
|
||||
key={reply.id}
|
||||
id={`comment-${reply.id}`}
|
||||
className={cn(
|
||||
"border-t border-border/50 transition-colors duration-700",
|
||||
highlightedCommentId === reply.id && highlightedCommentBackgroundClass,
|
||||
)}
|
||||
>
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
|
||||
@@ -311,8 +311,9 @@ vi.mock("@multica/core/issues/stores", () => ({
|
||||
// scrollIntoView (it drives the timeline container's scrollTop directly to
|
||||
// avoid scrolling ancestor overflow:hidden boxes — see issue-detail.tsx). We
|
||||
// keep a no-op stub on the prototype so any stray scrollIntoView call from
|
||||
// other components doesn't throw; deep-link tests assert the highlight ring
|
||||
// instead, which is mechanism-independent and observable without layout.
|
||||
// other components doesn't throw; deep-link tests assert the highlight
|
||||
// background instead, which is mechanism-independent and observable without
|
||||
// layout.
|
||||
const scrollIntoViewSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("react-virtuoso", () => ({
|
||||
@@ -491,6 +492,21 @@ function renderIssueDetailWithHighlight(
|
||||
return { ...result, queryClient };
|
||||
}
|
||||
|
||||
const highlightedCommentBackgroundClass =
|
||||
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
|
||||
|
||||
function hasHighlightedCommentBackground(root: ParentNode | null): boolean {
|
||||
if (!root) return false;
|
||||
|
||||
const elements = root instanceof Element
|
||||
? [root, ...Array.from(root.querySelectorAll("[class]"))]
|
||||
: Array.from(root.querySelectorAll("[class]"));
|
||||
|
||||
return elements.some(
|
||||
(el) => typeof el.className === "string" && el.className.includes(highlightedCommentBackgroundClass),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1112,14 +1128,56 @@ describe("IssueDetail (shared)", () => {
|
||||
// The deep-link effect lands on AND highlights the target comment: it
|
||||
// drives the timeline container's scrollTop directly (jsdom has no
|
||||
// layout, so the scroll itself isn't observable here) and applies the
|
||||
// brand highlight ring. Assert the user-facing highlight.
|
||||
// brand highlight background. Assert the user-facing highlight.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.getElementById("comment-comment-2")?.querySelector(".ring-2"),
|
||||
).not.toBeNull();
|
||||
hasHighlightedCommentBackground(document.getElementById("comment-comment-2")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights only the target root comment, not the whole thread", async () => {
|
||||
mockApiObj.listTimeline.mockResolvedValue([
|
||||
{
|
||||
type: "comment",
|
||||
id: "comment-root",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
content: "Root target",
|
||||
parent_id: null,
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
comment_type: "comment",
|
||||
} as TimelineEntry,
|
||||
{
|
||||
type: "comment",
|
||||
id: "reply-under-root",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
content: "Reply should stay neutral",
|
||||
parent_id: "comment-root",
|
||||
created_at: "2026-01-18T01:00:00Z",
|
||||
updated_at: "2026-01-18T01:00:00Z",
|
||||
comment_type: "comment",
|
||||
} as TimelineEntry,
|
||||
]);
|
||||
|
||||
renderIssueDetailWithHighlight("comment-root");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById("comment-comment-root")).not.toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
hasHighlightedCommentBackground(document.getElementById("comment-comment-root")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const reply = document.getElementById("comment-reply-under-root");
|
||||
expect(reply).not.toBeNull();
|
||||
expect(hasHighlightedCommentBackground(reply)).toBe(false);
|
||||
});
|
||||
|
||||
it("still scrolls when the timeline is ready before the issue (regression for inbox click)", async () => {
|
||||
// Reproduces the inbox-click race: timeline data is in the cache
|
||||
// before the issue resolves. While loading is true, IssueDetail
|
||||
@@ -1138,7 +1196,7 @@ describe("IssueDetail (shared)", () => {
|
||||
document.getElementById("comment-comment-2"),
|
||||
).toBeNull();
|
||||
// Nothing highlighted while the loading skeleton is up.
|
||||
expect(document.querySelector(".ring-2")).toBeNull();
|
||||
expect(hasHighlightedCommentBackground(document)).toBe(false);
|
||||
|
||||
resolveIssue(mockIssue);
|
||||
|
||||
@@ -1149,8 +1207,8 @@ describe("IssueDetail (shared)", () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.getElementById("comment-comment-2")?.querySelector(".ring-2"),
|
||||
).not.toBeNull();
|
||||
hasHighlightedCommentBackground(document.getElementById("comment-comment-2")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -141,6 +141,18 @@ describe("estimateCost", () => {
|
||||
expect(cost).toBeCloseTo(10 + 50 + 1 + 12.5, 5);
|
||||
});
|
||||
|
||||
it("prices Claude Sonnet 5 at Anthropic's intro $2 / $10 tier", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "claude-sonnet-5",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
cache_read_tokens: 1_000_000,
|
||||
cache_write_tokens: 1_000_000,
|
||||
});
|
||||
expect(cost).toBeCloseTo(2 + 10 + 0.2 + 2.5, 5);
|
||||
});
|
||||
|
||||
it("prices the provider-prefixed Anthropic form (anthropic/claude-sonnet-4.6)", () => {
|
||||
// openclaw / opencode emit `<provider>/<model>`. Same SKU as the
|
||||
// bare form, must hit the same rate.
|
||||
@@ -419,6 +431,7 @@ describe("estimateCost", () => {
|
||||
|
||||
describe("isModelPriced", () => {
|
||||
it("recognises both Claude and Codex/GPT families", () => {
|
||||
expect(isModelPriced("claude-sonnet-5")).toBe(true);
|
||||
expect(isModelPriced("claude-fable-5")).toBe(true);
|
||||
expect(isModelPriced("claude-sonnet-4-6")).toBe(true);
|
||||
expect(isModelPriced("gpt-5-codex")).toBe(true);
|
||||
@@ -432,6 +445,7 @@ describe("isModelPriced", () => {
|
||||
// while Anthropic's own CLIs use dashes (`claude-opus-4-7`). Both must
|
||||
// hit the same catalog row, otherwise Copilot-routed usage gets bucketed
|
||||
// as "unmapped" and the user has to type the price in by hand.
|
||||
expect(isModelPriced("claude-sonnet-5")).toBe(true);
|
||||
expect(isModelPriced("claude-haiku-4.5")).toBe(true);
|
||||
expect(isModelPriced("claude-sonnet-4.5")).toBe(true);
|
||||
expect(isModelPriced("claude-sonnet-4.6")).toBe(true);
|
||||
@@ -443,6 +457,7 @@ describe("isModelPriced", () => {
|
||||
it("recognises provider-prefixed Anthropic IDs (openclaw / opencode form)", () => {
|
||||
// openclaw / opencode emit `<provider>/<model>` in `meta.agentMeta.model`.
|
||||
// The provider prefix is routing metadata, not part of the SKU.
|
||||
expect(isModelPriced("anthropic/claude-sonnet-5")).toBe(true);
|
||||
expect(isModelPriced("anthropic/claude-fable-5")).toBe(true);
|
||||
expect(isModelPriced("anthropic/claude-opus-4.7")).toBe(true);
|
||||
expect(isModelPriced("anthropic/claude-sonnet-4-6")).toBe(true);
|
||||
|
||||
@@ -159,8 +159,12 @@ const MODEL_PRICING: Record<
|
||||
string,
|
||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
> = {
|
||||
// -- Anthropic: current generation. Fable 5 is a Mythos-class SKU at 10/50;
|
||||
// Opus 4.5+ stays on the lower 5/25 Opus tier. --
|
||||
// -- Anthropic: current generation. Sonnet 5 uses Anthropic's published
|
||||
// intro launch rate ($2 / $10 through 2026-08-31). This static map has
|
||||
// no future-dated pricing support yet, so update the row when the
|
||||
// post-intro $3 / $15 rate takes effect. Fable 5 is a Mythos-class SKU
|
||||
// at 10/50; Opus 4.5+ stays on the lower 5/25 Opus tier. --
|
||||
"claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.20, cacheWrite: 2.50 },
|
||||
"claude-fable-5": { input: 10, output: 50, cacheRead: 1.00, cacheWrite: 12.50 },
|
||||
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
|
||||
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
|
||||
@@ -478,11 +478,24 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
// agent asks, instead of force-assembling it on every inbound.
|
||||
h.SlackHistory = slack.NewHistory(queries, box.Open, slog.Default())
|
||||
|
||||
// `/issue` slash command (MUL-3908): a real Slack slash command,
|
||||
// delivered over the same Socket Mode connection. It is a one-shot
|
||||
// issue creation (no chat session or chat run; a todo issue assigned to
|
||||
// the agent still triggers it via normal issue-assignment) with a private
|
||||
// ephemeral confirmation, reusing the shared IssueService + binding service.
|
||||
slackSlash := slack.NewSlashCommandProcessor(slack.SlashCommandConfig{
|
||||
Queries: queries,
|
||||
Issues: h.IssueService,
|
||||
Binding: slackBindingSvc,
|
||||
AppURL: appURLFromEnv(),
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
|
||||
// Per-installation inbound: the Supervisor builds + supervises one
|
||||
// Socket Mode connection per active Slack installation, authenticated
|
||||
// with that installation's OWN app-level token (xapp-, pasted at BYO
|
||||
// install) — no deployment-level app token, no single connection.
|
||||
slack.RegisterSlack(channelRegistry, slack.ChannelDeps{Decrypt: box.Open, Logger: slog.Default()})
|
||||
slack.RegisterSlack(channelRegistry, slack.ChannelDeps{Decrypt: box.Open, Logger: slog.Default(), Slash: slackSlash})
|
||||
|
||||
// BYO self-serve install (paste bot token + app-level token). The
|
||||
// InstallService needs only the at-rest encryption key — there is no
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
@@ -35,9 +36,16 @@ type slackChannel struct {
|
||||
appToken string // decrypted xapp- — authorizes the Socket Mode connection
|
||||
botAPI *slack.Client // bot-token client for outbound Send
|
||||
handler channel.InboundHandler
|
||||
slash *SlashCommandProcessor // nil disables /issue slash-command handling
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// slashCommandTimeout bounds the detached processing of one `/issue` slash
|
||||
// command (installation + identity resolution, issue creation, response_url
|
||||
// reply). It runs off the socket receive loop on its own context, so a slow DB
|
||||
// or Slack HTTP call cannot wedge event delivery.
|
||||
const slashCommandTimeout = 10 * time.Second
|
||||
|
||||
func (c *slackChannel) Type() channel.Type { return TypeSlack }
|
||||
|
||||
func (c *slackChannel) Capabilities() channel.Capability {
|
||||
@@ -134,6 +142,22 @@ func (c *slackChannel) handleSocketEvent(ctx context.Context, sm *socketmode.Cli
|
||||
}
|
||||
}
|
||||
return c.dispatchEventsAPI(ctx, eventsAPI, mentionRe)
|
||||
case socketmode.EventTypeSlashCommand:
|
||||
// ACK first: like Events API envelopes, Slack expires an un-ACKed slash
|
||||
// command in ~3s, well under the DB + Slack HTTP work below. The reply is
|
||||
// delivered out-of-band via the command's response_url, so an empty ACK
|
||||
// is correct. Handling never fails the connection (product outcomes are
|
||||
// ephemeral replies, not infra errors).
|
||||
if evt.Request != nil {
|
||||
if err := sm.Ack(*evt.Request); err != nil {
|
||||
c.logger.WarnContext(ctx, "slack: ack slash command failed", "error", err)
|
||||
}
|
||||
}
|
||||
cmd, ok := evt.Data.(slack.SlashCommand)
|
||||
if ok {
|
||||
c.dispatchSlashCommand(cmd)
|
||||
}
|
||||
return nil
|
||||
case socketmode.EventTypeConnecting, socketmode.EventTypeConnected, socketmode.EventTypeHello:
|
||||
c.logger.DebugContext(ctx, "slack: socket mode", "event", evt.Type, "app_id", c.appID)
|
||||
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
|
||||
@@ -169,12 +193,33 @@ func (c *slackChannel) dispatchEventsAPI(ctx context.Context, e slackevents.Even
|
||||
return c.handler(ctx, msg)
|
||||
}
|
||||
|
||||
// dispatchSlashCommand processes an already-ACKed `/issue` slash command on a
|
||||
// detached goroutine with its own bounded context, so the issue creation and
|
||||
// response_url reply never block the socket receive loop (mirrors the router's
|
||||
// detached outbound path). A nil processor (slash handling not wired) drops it.
|
||||
func (c *slackChannel) dispatchSlashCommand(cmd slack.SlashCommand) {
|
||||
if c.slash == nil {
|
||||
c.logger.Warn("slack: slash command received but no processor configured",
|
||||
"command", cmd.Command, "app_id", c.appID)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), slashCommandTimeout)
|
||||
defer cancel()
|
||||
c.slash.Handle(ctx, cmd)
|
||||
}()
|
||||
}
|
||||
|
||||
// ChannelDeps are the shared dependencies the Slack Factory closes over. The
|
||||
// engine inbound handler is supplied per-build via channel.Config.Handler; the
|
||||
// Decrypter turns the installation's stored ciphertext tokens into plaintext.
|
||||
type ChannelDeps struct {
|
||||
Decrypt Decrypter
|
||||
Logger *slog.Logger
|
||||
// Slash handles the `/issue` slash command delivered over Socket Mode. Nil
|
||||
// leaves slash-command handling off (the connection still serves messages
|
||||
// and @-mentions); tests that only exercise inbound messages pass nil.
|
||||
Slash *SlashCommandProcessor
|
||||
}
|
||||
|
||||
// RegisterSlack registers the per-installation Slack Factory so the
|
||||
@@ -212,6 +257,7 @@ func newSlackFactory(deps ChannelDeps) channel.Factory {
|
||||
appToken: appToken,
|
||||
botAPI: slack.New(botToken),
|
||||
handler: cfg.Handler,
|
||||
slash: deps.Slash,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
298
server/internal/integrations/slack/slash_command.go
Normal file
298
server/internal/integrations/slack/slash_command.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// This file implements the Slack `/issue` SLASH COMMAND. It is deliberately
|
||||
// separate from the message-based `/issue` (engine ParseIssueCommand): on Slack
|
||||
// a message whose first character is `/` is intercepted by the client as a
|
||||
// slash command and never delivered to the app, so the message-prefix form of
|
||||
// `/issue` cannot work here at all (MUL-3908). Registering `/issue` as a real
|
||||
// slash command in the app manifest is what makes it reach us — as an
|
||||
// `EventTypeSlashCommand` over the same Socket Mode connection.
|
||||
//
|
||||
// Unlike the message path (chat session + dedup + debounced chat run), a slash
|
||||
// command creates no channel message and starts no chat session / chat run: it
|
||||
// is a one-shot issue creation with a PRIVATE (ephemeral) confirmation back to
|
||||
// the invoker via the command's response_url. It reuses the same installation
|
||||
// routing, identity + membership checks, and the shared IssueService, so a
|
||||
// slash-command issue shares the counter, dup guard, project boundary,
|
||||
// broadcast, analytics and agent-enqueue with every other create path — i.e. a
|
||||
// `todo` issue assigned to the agent still triggers the agent through the normal
|
||||
// issue-assignment path (maybeEnqueueOnAssign), exactly like the message /issue.
|
||||
|
||||
const issueSlashCommand = "/issue"
|
||||
|
||||
// User-facing ephemeral replies. Kept terse; only the invoker sees them.
|
||||
const (
|
||||
slashUsageText = "Please give the issue a title, e.g. `/issue Fix the login redirect`."
|
||||
slashNotMemberText = "You're not a member of this Multica workspace, so I can't file an issue for you."
|
||||
slashLinkAccountFallback = "Link your Slack account to Multica first, then try `/issue` again."
|
||||
slashInternalErrorText = "⚠️ Something went wrong creating the issue. Please try again."
|
||||
slashDisabledText = "This Slack app isn't connected to Multica (or was disconnected). Ask a workspace admin to reconnect it."
|
||||
)
|
||||
|
||||
// slashQueries is the narrow slice of generated queries the slash-command
|
||||
// processor needs. *db.Queries satisfies it; tests supply a fake. The
|
||||
// installation / member resolution mirrors the message-path resolvers
|
||||
// (resolvers.go) but is kept local so the proven inbound pipeline is untouched.
|
||||
type slashQueries interface {
|
||||
GetChannelInstallationByAppID(ctx context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error)
|
||||
GetChannelUserBindingByUserID(ctx context.Context, arg db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error)
|
||||
GetMemberByUserAndWorkspace(ctx context.Context, arg db.GetMemberByUserAndWorkspaceParams) (db.Member, error)
|
||||
GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Workspace, error)
|
||||
}
|
||||
|
||||
// SlashCommandProcessor handles the Slack `/issue` slash command end to end.
|
||||
type SlashCommandProcessor struct {
|
||||
q slashQueries
|
||||
issues engine.IssueCreator
|
||||
binding bindingMinter
|
||||
appURL string
|
||||
bindingPath string
|
||||
logger *slog.Logger
|
||||
// respond posts an ephemeral reply to the command's response_url. Injected
|
||||
// so tests can capture the reply without hitting Slack.
|
||||
respond func(ctx context.Context, responseURL, text string) error
|
||||
}
|
||||
|
||||
// SlashCommandConfig configures the processor. Binding + AppURL are required for
|
||||
// the unbound-user "link your account" reply; without them that case falls back
|
||||
// to a plain instruction. Issues + Queries are required for the command to do
|
||||
// anything.
|
||||
type SlashCommandConfig struct {
|
||||
Queries *db.Queries
|
||||
Issues engine.IssueCreator
|
||||
Binding bindingMinter
|
||||
AppURL string
|
||||
BindingPath string // default "/slack/bind"
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSlashCommandProcessor builds the processor. The default responder POSTs an
|
||||
// ephemeral message to the command's response_url (a signed webhook — no bot
|
||||
// token required).
|
||||
func NewSlashCommandProcessor(cfg SlashCommandConfig) *SlashCommandProcessor {
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
bindingPath := cfg.BindingPath
|
||||
if bindingPath == "" {
|
||||
bindingPath = "/slack/bind"
|
||||
}
|
||||
if !strings.HasPrefix(bindingPath, "/") {
|
||||
bindingPath = "/" + bindingPath
|
||||
}
|
||||
p := &SlashCommandProcessor{
|
||||
q: cfg.Queries,
|
||||
issues: cfg.Issues,
|
||||
binding: cfg.Binding,
|
||||
appURL: strings.TrimRight(cfg.AppURL, "/"),
|
||||
bindingPath: bindingPath,
|
||||
logger: logger,
|
||||
}
|
||||
p.respond = func(ctx context.Context, responseURL, text string) error {
|
||||
return slack.PostWebhookContext(ctx, responseURL, &slack.WebhookMessage{
|
||||
ResponseType: slack.ResponseTypeEphemeral,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Handle processes one slash command and delivers the ephemeral reply. It is
|
||||
// called from a detached goroutine (the socket receive loop has already ACKed),
|
||||
// so it never returns an error — every outcome is a user-facing message.
|
||||
func (p *SlashCommandProcessor) Handle(ctx context.Context, cmd slack.SlashCommand) {
|
||||
// Only /issue is registered in the manifest; ignore anything else defensively.
|
||||
if !strings.EqualFold(strings.TrimSpace(cmd.Command), issueSlashCommand) {
|
||||
return
|
||||
}
|
||||
text := p.process(ctx, cmd)
|
||||
if text == "" || cmd.ResponseURL == "" {
|
||||
return
|
||||
}
|
||||
if err := p.respond(ctx, cmd.ResponseURL, text); err != nil {
|
||||
p.logger.WarnContext(ctx, "slack slash command: response_url reply failed",
|
||||
"app_id", cmd.APIAppID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// process runs the command and returns the ephemeral text to reply with.
|
||||
func (p *SlashCommandProcessor) process(ctx context.Context, cmd slack.SlashCommand) string {
|
||||
title, description := splitIssueText(cmd.Text)
|
||||
if title == "" {
|
||||
return slashUsageText
|
||||
}
|
||||
|
||||
inst, err := p.resolveInstallation(ctx, cmd.APIAppID, cmd.TeamID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, engine.ErrInstallationNotFound) {
|
||||
p.logger.WarnContext(ctx, "slack slash command: resolve installation failed",
|
||||
"app_id", cmd.APIAppID, "error", err)
|
||||
return slashInternalErrorText
|
||||
}
|
||||
return slashDisabledText
|
||||
}
|
||||
if !inst.Active {
|
||||
return slashDisabledText
|
||||
}
|
||||
|
||||
userID, err := p.resolveUser(ctx, inst, cmd.UserID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrSenderUnbound):
|
||||
return p.bindingText(ctx, inst, cmd.UserID)
|
||||
case errors.Is(err, engine.ErrSenderNotMember):
|
||||
return slashNotMemberText
|
||||
default:
|
||||
p.logger.WarnContext(ctx, "slack slash command: resolve user failed",
|
||||
"app_id", cmd.APIAppID, "error", err)
|
||||
return slashInternalErrorText
|
||||
}
|
||||
}
|
||||
|
||||
res, err := p.issues.Create(ctx, service.IssueCreateParams{
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
Title: title,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Status: "todo",
|
||||
Priority: "none",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: inst.AgentID,
|
||||
CreatorType: "member",
|
||||
CreatorID: userID,
|
||||
OriginType: pgtype.Text{String: originSlackChat, Valid: true},
|
||||
// No chat session backs a slash command, so OriginID stays NULL.
|
||||
}, service.IssueCreateOpts{})
|
||||
if err != nil {
|
||||
p.logger.WarnContext(ctx, "slack slash command: create issue failed",
|
||||
"app_id", cmd.APIAppID, "error", err)
|
||||
return slashInternalErrorText
|
||||
}
|
||||
return p.issueCreatedText(ctx, inst.WorkspaceID, res.Issue)
|
||||
}
|
||||
|
||||
// resolveInstallation maps the command's api_app_id (+ event team) to its
|
||||
// installation, applying the same team-scoping guard as inbound routing.
|
||||
func (p *SlashCommandProcessor) resolveInstallation(ctx context.Context, appID, teamID string) (engine.ResolvedInstallation, error) {
|
||||
inst, err := p.q.GetChannelInstallationByAppID(ctx, db.GetChannelInstallationByAppIDParams{
|
||||
ChannelType: string(TypeSlack),
|
||||
AppID: appID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
|
||||
}
|
||||
return engine.ResolvedInstallation{}, err
|
||||
}
|
||||
if !installationServesTeam(inst.Config, teamID) {
|
||||
return engine.ResolvedInstallation{}, engine.ErrInstallationNotFound
|
||||
}
|
||||
return engine.ResolvedInstallation{
|
||||
ID: inst.ID,
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
AgentID: inst.AgentID,
|
||||
InstallerUserID: inst.InstallerUserID,
|
||||
Active: inst.Status == "active",
|
||||
Platform: inst,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveUser maps the Slack user id to the bound Multica user, re-checking
|
||||
// workspace membership (no binding→member FK). Returns engine.ErrSenderUnbound
|
||||
// or engine.ErrSenderNotMember for the product cases.
|
||||
func (p *SlashCommandProcessor) resolveUser(ctx context.Context, inst engine.ResolvedInstallation, slackUserID string) (pgtype.UUID, error) {
|
||||
binding, err := p.q.GetChannelUserBindingByUserID(ctx, db.GetChannelUserBindingByUserIDParams{
|
||||
InstallationID: inst.ID,
|
||||
ChannelUserID: slackUserID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgtype.UUID{}, engine.ErrSenderUnbound
|
||||
}
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
if _, err := p.q.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: binding.MulticaUserID,
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgtype.UUID{}, engine.ErrSenderNotMember
|
||||
}
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
return binding.MulticaUserID, nil
|
||||
}
|
||||
|
||||
// bindingText mints a single-use binding token and returns a "link your account"
|
||||
// prompt, mirroring the outbound replier's NeedsBinding message. Falls back to a
|
||||
// plain instruction when the binding service / app URL are not configured.
|
||||
func (p *SlashCommandProcessor) bindingText(ctx context.Context, inst engine.ResolvedInstallation, slackUserID string) string {
|
||||
if p.binding == nil || p.appURL == "" {
|
||||
return slashLinkAccountFallback
|
||||
}
|
||||
token, err := p.binding.Mint(ctx, inst.WorkspaceID, inst.ID, slackUserID)
|
||||
if err != nil {
|
||||
p.logger.WarnContext(ctx, "slack slash command: mint binding token failed",
|
||||
"installation_id", inst.ID, "error", err)
|
||||
return slashLinkAccountFallback
|
||||
}
|
||||
bindURL := p.appURL + p.bindingPath + "?token=" + url.QueryEscape(token.Raw)
|
||||
// Wrap the URL as an explicit Slack link so the base64url token's `_`/`-`
|
||||
// are not mangled by mrkdwn (same reasoning as the replier).
|
||||
return "👋 To file issues, link your Slack account to Multica: <" +
|
||||
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
|
||||
}
|
||||
|
||||
// issueCreatedText renders the success confirmation with the workspace-prefixed
|
||||
// identifier (falling back to #<number> when no prefix is set).
|
||||
func (p *SlashCommandProcessor) issueCreatedText(ctx context.Context, workspaceID pgtype.UUID, issue db.Issue) string {
|
||||
identifier := fmt.Sprintf("#%d", issue.Number)
|
||||
if ws, err := p.q.GetWorkspace(ctx, workspaceID); err == nil && ws.IssuePrefix != "" {
|
||||
identifier = fmt.Sprintf("%s-%d", ws.IssuePrefix, issue.Number)
|
||||
}
|
||||
title := strings.TrimSpace(issue.Title)
|
||||
if title == "" {
|
||||
return "✅ Created " + identifier
|
||||
}
|
||||
return "✅ Created " + identifier + " — " + title
|
||||
}
|
||||
|
||||
// splitIssueText splits the slash-command argument string into a title (first
|
||||
// non-empty line) and an optional description (the remaining lines). Slack
|
||||
// slash-command input is normally single-line, so the description is usually
|
||||
// empty.
|
||||
func splitIssueText(text string) (title, description string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
first := -1
|
||||
for i, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
first = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if first == -1 {
|
||||
return "", ""
|
||||
}
|
||||
title = strings.TrimSpace(lines[first])
|
||||
if first+1 < len(lines) {
|
||||
description = strings.TrimRight(strings.Join(lines[first+1:], "\n"), " \t\n")
|
||||
}
|
||||
return title, description
|
||||
}
|
||||
284
server/internal/integrations/slack/slash_command_test.go
Normal file
284
server/internal/integrations/slack/slash_command_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/slack-go/slack"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
type fakeSlashQueries struct {
|
||||
inst db.ChannelInstallation
|
||||
instErr error
|
||||
binding db.ChannelUserBinding
|
||||
bindErr error
|
||||
memberErr error
|
||||
ws db.Workspace
|
||||
wsErr error
|
||||
gotAppID string
|
||||
}
|
||||
|
||||
func (f *fakeSlashQueries) GetChannelInstallationByAppID(_ context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error) {
|
||||
f.gotAppID = arg.AppID
|
||||
return f.inst, f.instErr
|
||||
}
|
||||
|
||||
func (f *fakeSlashQueries) GetChannelUserBindingByUserID(_ context.Context, _ db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error) {
|
||||
return f.binding, f.bindErr
|
||||
}
|
||||
|
||||
func (f *fakeSlashQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.GetMemberByUserAndWorkspaceParams) (db.Member, error) {
|
||||
return db.Member{}, f.memberErr
|
||||
}
|
||||
|
||||
func (f *fakeSlashQueries) GetWorkspace(_ context.Context, _ pgtype.UUID) (db.Workspace, error) {
|
||||
return f.ws, f.wsErr
|
||||
}
|
||||
|
||||
type fakeIssueCreator struct {
|
||||
result service.IssueCreateResult
|
||||
err error
|
||||
calls int
|
||||
params service.IssueCreateParams
|
||||
}
|
||||
|
||||
func (f *fakeIssueCreator) Create(_ context.Context, p service.IssueCreateParams, _ service.IssueCreateOpts) (service.IssueCreateResult, error) {
|
||||
f.calls++
|
||||
f.params = p
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func slashTestUUID(b byte) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
for i := range u.Bytes {
|
||||
u.Bytes[i] = b
|
||||
}
|
||||
u.Valid = true
|
||||
return u
|
||||
}
|
||||
|
||||
// newTestSlashProcessor builds a processor over fakes and returns it plus a
|
||||
// pointer to the last ephemeral reply text and the reply count.
|
||||
func newTestSlashProcessor(q slashQueries, issues engine.IssueCreator, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
|
||||
captured := new(string)
|
||||
count := new(int)
|
||||
p := &SlashCommandProcessor{
|
||||
q: q,
|
||||
issues: issues,
|
||||
binding: binding,
|
||||
appURL: "https://app.example",
|
||||
bindingPath: "/slack/bind",
|
||||
logger: slog.Default(),
|
||||
}
|
||||
p.respond = func(_ context.Context, _ string, text string) error {
|
||||
*count++
|
||||
*captured = text
|
||||
return nil
|
||||
}
|
||||
return p, captured, count
|
||||
}
|
||||
|
||||
func activeSlashInstallation() db.ChannelInstallation {
|
||||
return db.ChannelInstallation{
|
||||
ID: slashTestUUID(1),
|
||||
WorkspaceID: slashTestUUID(2),
|
||||
AgentID: slashTestUUID(3),
|
||||
InstallerUserID: slashTestUUID(4),
|
||||
Status: "active",
|
||||
Config: []byte(`{"app_id":"A1","team_id":"T1"}`),
|
||||
}
|
||||
}
|
||||
|
||||
func issueSlashCmd() slack.SlashCommand {
|
||||
return slack.SlashCommand{
|
||||
Command: "/issue",
|
||||
Text: "Fix login",
|
||||
APIAppID: "A1",
|
||||
TeamID: "T1",
|
||||
UserID: "U1",
|
||||
ChannelID: "C1",
|
||||
ResponseURL: "https://hooks.slack.test/response",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
func TestSlashHandle_CreatesIssueAndConfirms(t *testing.T) {
|
||||
q := &fakeSlashQueries{
|
||||
inst: activeSlashInstallation(),
|
||||
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
||||
ws: db.Workspace{IssuePrefix: "MUL"},
|
||||
}
|
||||
issues := &fakeIssueCreator{result: service.IssueCreateResult{Issue: db.Issue{Number: 7, Title: "Fix login"}}}
|
||||
p, captured, count := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
|
||||
|
||||
p.Handle(context.Background(), issueSlashCmd())
|
||||
|
||||
if issues.calls != 1 {
|
||||
t.Fatalf("expected 1 issue create, got %d", issues.calls)
|
||||
}
|
||||
if *count != 1 {
|
||||
t.Fatalf("expected 1 ephemeral reply, got %d", *count)
|
||||
}
|
||||
if !strings.Contains(*captured, "MUL-7") || !strings.Contains(*captured, "Fix login") {
|
||||
t.Fatalf("confirmation missing identifier/title: %q", *captured)
|
||||
}
|
||||
if q.gotAppID != "A1" {
|
||||
t.Errorf("installation lookup used app id %q, want A1", q.gotAppID)
|
||||
}
|
||||
if issues.params.Title != "Fix login" {
|
||||
t.Errorf("issue title = %q, want Fix login", issues.params.Title)
|
||||
}
|
||||
if issues.params.AssigneeID != slashTestUUID(3) {
|
||||
t.Errorf("issue not assigned to the installation agent")
|
||||
}
|
||||
if issues.params.CreatorID != slashTestUUID(9) {
|
||||
t.Errorf("issue creator is not the bound member")
|
||||
}
|
||||
if issues.params.OriginID.Valid {
|
||||
t.Errorf("slash-command issue must have no origin session id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_TitleAndDescription(t *testing.T) {
|
||||
q := &fakeSlashQueries{
|
||||
inst: activeSlashInstallation(),
|
||||
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
||||
ws: db.Workspace{IssuePrefix: "MUL"},
|
||||
}
|
||||
issues := &fakeIssueCreator{result: service.IssueCreateResult{Issue: db.Issue{Number: 1, Title: "Title"}}}
|
||||
p, _, _ := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
|
||||
|
||||
cmd := issueSlashCmd()
|
||||
cmd.Text = "Title\nline one\nline two"
|
||||
p.Handle(context.Background(), cmd)
|
||||
|
||||
if issues.params.Title != "Title" {
|
||||
t.Errorf("title = %q, want Title", issues.params.Title)
|
||||
}
|
||||
if got := issues.params.Description.String; got != "line one\nline two" {
|
||||
t.Errorf("description = %q, want two body lines", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_EmptyTitleIsUsage(t *testing.T) {
|
||||
issues := &fakeIssueCreator{}
|
||||
p, captured, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
|
||||
|
||||
cmd := issueSlashCmd()
|
||||
cmd.Text = " "
|
||||
p.Handle(context.Background(), cmd)
|
||||
|
||||
if issues.calls != 0 {
|
||||
t.Fatalf("empty title must not create an issue")
|
||||
}
|
||||
if *count != 1 || *captured != slashUsageText {
|
||||
t.Fatalf("expected usage reply, got %q", *captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_UnboundUserGetsLink(t *testing.T) {
|
||||
q := &fakeSlashQueries{inst: activeSlashInstallation(), bindErr: pgx.ErrNoRows}
|
||||
issues := &fakeIssueCreator{}
|
||||
bind := &fakeBindingMinter{raw: "TOKEN123"}
|
||||
p, captured, _ := newTestSlashProcessor(q, issues, bind)
|
||||
|
||||
p.Handle(context.Background(), issueSlashCmd())
|
||||
|
||||
if issues.calls != 0 {
|
||||
t.Fatalf("unbound user must not create an issue")
|
||||
}
|
||||
if bind.calls != 1 {
|
||||
t.Fatalf("expected a binding token to be minted, got %d", bind.calls)
|
||||
}
|
||||
if !strings.Contains(*captured, "link your account") || !strings.Contains(*captured, "TOKEN123") {
|
||||
t.Fatalf("reply missing bind link: %q", *captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_NonMemberDropped(t *testing.T) {
|
||||
q := &fakeSlashQueries{
|
||||
inst: activeSlashInstallation(),
|
||||
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
||||
memberErr: pgx.ErrNoRows,
|
||||
}
|
||||
issues := &fakeIssueCreator{}
|
||||
p, captured, _ := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
|
||||
|
||||
p.Handle(context.Background(), issueSlashCmd())
|
||||
|
||||
if issues.calls != 0 {
|
||||
t.Fatalf("non-member must not create an issue")
|
||||
}
|
||||
if *captured != slashNotMemberText {
|
||||
t.Fatalf("expected not-member reply, got %q", *captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_InactiveInstallation(t *testing.T) {
|
||||
inst := activeSlashInstallation()
|
||||
inst.Status = "revoked"
|
||||
issues := &fakeIssueCreator{}
|
||||
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, issues, &fakeBindingMinter{})
|
||||
|
||||
p.Handle(context.Background(), issueSlashCmd())
|
||||
|
||||
if issues.calls != 0 || *captured != slashDisabledText {
|
||||
t.Fatalf("inactive install: calls=%d reply=%q", issues.calls, *captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_TeamMismatchTreatedAsDisconnected(t *testing.T) {
|
||||
issues := &fakeIssueCreator{}
|
||||
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
|
||||
|
||||
cmd := issueSlashCmd()
|
||||
cmd.TeamID = "T2" // config team is T1
|
||||
p.Handle(context.Background(), cmd)
|
||||
|
||||
if issues.calls != 0 || *captured != slashDisabledText {
|
||||
t.Fatalf("team mismatch: calls=%d reply=%q", issues.calls, *captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashHandle_IgnoresOtherCommands(t *testing.T) {
|
||||
issues := &fakeIssueCreator{}
|
||||
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
|
||||
|
||||
cmd := issueSlashCmd()
|
||||
cmd.Command = "/other"
|
||||
p.Handle(context.Background(), cmd)
|
||||
|
||||
if issues.calls != 0 || *count != 0 {
|
||||
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", issues.calls, *count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitIssueText(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, title, desc string
|
||||
}{
|
||||
{"Fix login", "Fix login", ""},
|
||||
{" Fix login ", "Fix login", ""},
|
||||
{"Title\nbody one\nbody two", "Title", "body one\nbody two"},
|
||||
{"", "", ""},
|
||||
{" \n ", "", ""},
|
||||
{"\n\nTitle\nbody", "Title", "body"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
gotTitle, gotDesc := splitIssueText(c.in)
|
||||
if gotTitle != c.title || gotDesc != c.desc {
|
||||
t.Errorf("splitIssueText(%q) = (%q,%q), want (%q,%q)", c.in, gotTitle, gotDesc, c.title, c.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,15 @@ type ModelPrice struct {
|
||||
}
|
||||
|
||||
var modelPrices = map[string]ModelPrice{
|
||||
"openai:gpt-5.5": {Provider: "openai", Model: "gpt-5.5", InputPerM: 5.00, CacheReadPerM: 0.50, CacheWritePerM: 0.50, OutputPerM: 30.00},
|
||||
"openai:gpt-5.4": {Provider: "openai", Model: "gpt-5.4", InputPerM: 2.50, CacheReadPerM: 0.25, CacheWritePerM: 0.25, OutputPerM: 15.00},
|
||||
"openai:gpt-5.4-mini": {Provider: "openai", Model: "gpt-5.4-mini", InputPerM: 0.75, CacheReadPerM: 0.075, CacheWritePerM: 0.075, OutputPerM: 4.50},
|
||||
"openai:gpt-5.3-codex": {Provider: "openai", Model: "gpt-5.3-codex", InputPerM: 1.75, CacheReadPerM: 0.175, CacheWritePerM: 0.175, OutputPerM: 14.00},
|
||||
"openai:gpt-5.2-codex": {Provider: "openai", Model: "gpt-5.2-codex", InputPerM: 1.75, CacheReadPerM: 0.175, CacheWritePerM: 0.175, OutputPerM: 14.00},
|
||||
"openai:gpt-5.5": {Provider: "openai", Model: "gpt-5.5", InputPerM: 5.00, CacheReadPerM: 0.50, CacheWritePerM: 0.50, OutputPerM: 30.00},
|
||||
"openai:gpt-5.4": {Provider: "openai", Model: "gpt-5.4", InputPerM: 2.50, CacheReadPerM: 0.25, CacheWritePerM: 0.25, OutputPerM: 15.00},
|
||||
"openai:gpt-5.4-mini": {Provider: "openai", Model: "gpt-5.4-mini", InputPerM: 0.75, CacheReadPerM: 0.075, CacheWritePerM: 0.075, OutputPerM: 4.50},
|
||||
"openai:gpt-5.3-codex": {Provider: "openai", Model: "gpt-5.3-codex", InputPerM: 1.75, CacheReadPerM: 0.175, CacheWritePerM: 0.175, OutputPerM: 14.00},
|
||||
"openai:gpt-5.2-codex": {Provider: "openai", Model: "gpt-5.2-codex", InputPerM: 1.75, CacheReadPerM: 0.175, CacheWritePerM: 0.175, OutputPerM: 14.00},
|
||||
// Anthropic's Sonnet 5 launch price is $2 / $10 through 2026-08-31. This
|
||||
// static table cannot schedule the published post-intro $3 / $15 change yet,
|
||||
// so keep the intro rate here and update the row when catalog support exists.
|
||||
"anthropic:claude-sonnet-5": {Provider: "anthropic", Model: "claude-sonnet-5", InputPerM: 2.00, CacheReadPerM: 0.20, CacheWritePerM: 2.50, OutputPerM: 10.00},
|
||||
"anthropic:claude-fable-5": {Provider: "anthropic", Model: "claude-fable-5", InputPerM: 10.00, CacheReadPerM: 1.00, CacheWritePerM: 12.50, OutputPerM: 50.00},
|
||||
"anthropic:claude-opus-4.8": {Provider: "anthropic", Model: "claude-opus-4.8", InputPerM: 5.00, CacheReadPerM: 0.50, CacheWritePerM: 6.25, OutputPerM: 25.00},
|
||||
"anthropic:claude-opus-4.7": {Provider: "anthropic", Model: "claude-opus-4.7", InputPerM: 5.00, CacheReadPerM: 0.50, CacheWritePerM: 6.25, OutputPerM: 25.00},
|
||||
@@ -47,6 +51,7 @@ var modelAliasRules = []struct {
|
||||
{regexp.MustCompile(`(^|/|:)gpt-5[.-]4-mini($|[^a-z0-9])`), "openai:gpt-5.4-mini"},
|
||||
{regexp.MustCompile(`(^|/|:)gpt-5[.-]3-codex$`), "openai:gpt-5.3-codex"},
|
||||
{regexp.MustCompile(`(^|/|:)gpt-5[.-]2-codex$`), "openai:gpt-5.2-codex"},
|
||||
{regexp.MustCompile(`claude-sonnet-5|claude-5-sonnet`), "anthropic:claude-sonnet-5"},
|
||||
{regexp.MustCompile(`claude-fable-5`), "anthropic:claude-fable-5"},
|
||||
{regexp.MustCompile(`claude-opus-4[-.]8`), "anthropic:claude-opus-4.8"},
|
||||
{regexp.MustCompile(`claude-opus-4[-.]7`), "anthropic:claude-opus-4.7"},
|
||||
|
||||
@@ -7,6 +7,18 @@ func TestPriceForModelAliasAnthropicFableAndOpus48(t *testing.T) {
|
||||
model string
|
||||
want ModelPrice
|
||||
}{
|
||||
{
|
||||
model: "claude-sonnet-5",
|
||||
want: ModelPrice{Provider: "anthropic", Model: "claude-sonnet-5", InputPerM: 2, CacheReadPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10},
|
||||
},
|
||||
{
|
||||
model: "anthropic:claude-sonnet-5",
|
||||
want: ModelPrice{Provider: "anthropic", Model: "claude-sonnet-5", InputPerM: 2, CacheReadPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10},
|
||||
},
|
||||
{
|
||||
model: "claude-5-sonnet",
|
||||
want: ModelPrice{Provider: "anthropic", Model: "claude-sonnet-5", InputPerM: 2, CacheReadPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10},
|
||||
},
|
||||
{
|
||||
model: "claude-fable-5",
|
||||
want: ModelPrice{Provider: "anthropic", Model: "claude-fable-5", InputPerM: 10, CacheReadPerM: 1, CacheWritePerM: 12.5, OutputPerM: 50},
|
||||
|
||||
8
server/migrations/131_issue_origin_slack_chat.down.sql
Normal file
8
server/migrations/131_issue_origin_slack_chat.down.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Revert to the pre-slack_chat issue_origin_type_check list. Any existing rows
|
||||
-- with origin_type='slack_chat' would violate the rolled-back constraint; the
|
||||
-- down migration assumes the operator has already deleted or relabeled those
|
||||
-- rows. Kept strict (no DROP NOT VALID dance) to preserve the schema invariant
|
||||
-- downstream code relies on. Mirrors 111.
|
||||
ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_origin_type_check;
|
||||
ALTER TABLE issue ADD CONSTRAINT issue_origin_type_check
|
||||
CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat'));
|
||||
10
server/migrations/131_issue_origin_slack_chat.up.sql
Normal file
10
server/migrations/131_issue_origin_slack_chat.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Extend issue.origin_type to allow the Slack `/issue` command paths (both the
|
||||
-- message-prefix form and the slash command) to stamp issues with
|
||||
-- origin_type='slack_chat'. The Slack integration shipped this origin label
|
||||
-- (originSlackChat) but no migration ever added it to the CHECK list, so every
|
||||
-- Slack /issue create tripped SQLSTATE 23514 and IssueService.Create failed —
|
||||
-- surfaced end-to-end by the /issue slash command (MUL-3908). Mirrors 111
|
||||
-- (lark_chat), which fixed the identical gap for Lark.
|
||||
ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_origin_type_check;
|
||||
ALTER TABLE issue ADD CONSTRAINT issue_origin_type_check
|
||||
CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat', 'slack_chat'));
|
||||
@@ -287,6 +287,7 @@ func discoveryCacheKey(providerType, executablePath string) string {
|
||||
// the everyday workhorse (Opus is reserved for advisor-style flows).
|
||||
func claudeStaticModels() []Model {
|
||||
return []Model{
|
||||
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", Provider: "anthropic"},
|
||||
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5", Provider: "anthropic"},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", Provider: "anthropic"},
|
||||
|
||||
@@ -80,6 +80,29 @@ func TestClaudeStaticModelsExposesFable5(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeStaticModelsExposesSonnet5(t *testing.T) {
|
||||
models := claudeStaticModels()
|
||||
ids := map[string]Model{}
|
||||
defaults := 0
|
||||
for _, m := range models {
|
||||
ids[m.ID] = m
|
||||
if m.Default {
|
||||
defaults++
|
||||
}
|
||||
}
|
||||
|
||||
sonnet, ok := ids["claude-sonnet-5"]
|
||||
if !ok {
|
||||
t.Fatalf("missing Claude Sonnet 5 in: %+v", models)
|
||||
}
|
||||
if sonnet.Label != "Claude Sonnet 5" || sonnet.Provider != "anthropic" || sonnet.Default {
|
||||
t.Errorf("unexpected Sonnet 5 entry: %+v", sonnet)
|
||||
}
|
||||
if defaults != 1 || !ids["claude-sonnet-4-6"].Default {
|
||||
t.Errorf("expected Sonnet 4.6 to remain the sole default, got defaults=%d models=%+v", defaults, models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexStaticModelsExposesGPT55(t *testing.T) {
|
||||
// Codex CLI has no `models list` subcommand so the catalog is
|
||||
// hand-maintained. Regression guard for multica-ai/multica#2009 —
|
||||
|
||||
Reference in New Issue
Block a user