Compare commits

..

6 Commits

Author SHA1 Message Date
Naiyuan Qing
cc286d85f6 fix(issues): make comment highlight background-only 2026-07-01 15:59:03 +08:00
Bohan Jiang
bbd900635e fix(slack): allow slack_chat issue origin so /issue can create issues (MUL-3908) (#4785)
Follow-up to #4780 (the /issue slash command), which merged the code + docs. The DB migration was pushed to that branch after it had already been squash-merged, so it never landed on main — the /issue command still fails at issue creation.

The issue.origin_type CHECK constraint (migration 111) only allowed autopilot / quick_create / lark_chat. Slack stamps origin_type='slack_chat' on every /issue create, so the INSERT trips SQLSTATE 23514 and IssueService.Create fails ("Something went wrong creating the issue"). This also silently broke the pre-existing message-based Slack /issue. Extends the constraint to include 'slack_chat', mirroring 111 (lark_chat). Numbered 131 because 129/130 were taken on main since #4780 branched.

Verified against a real Postgres: full chain up to 131 applies cleanly and an issue insert with origin_type='slack_chat' passes the CHECK after 131 (fails with the pre-131 constraint).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 15:20:22 +08:00
bhirstmedia
170750242b BHI-12314: add Claude Sonnet 5 catalog and pricing support (MUL-3910) (#4783)
Co-authored-by: Ember <ember@Embers-iMac.localdomain>
2026-07-01 14:59:00 +08:00
Bohan Jiang
240ec4efd0 feat(slack): native /issue slash command over Socket Mode (MUL-3908) (#4780)
* feat(slack): native /issue slash command over Socket Mode (MUL-3908)

A message beginning with `/issue` is intercepted by the Slack client as a slash command and never delivered to the app, so the message-prefix /issue never worked on Slack (no event, no 👀, no issue).

Register /issue as a real slash command in the app manifest and handle EventTypeSlashCommand over the existing per-installation Socket Mode connection. It is a one-shot issue creation (no chat session / agent run) that reuses the shared IssueService and the same installation-routing + identity/membership checks as the message path, replying privately via the command's response_url (ephemeral) since a slash command has no message to react to.

Docs: register the command in the manifest and describe the slash-command behavior across all four locales.
Co-authored-by: multica-agent <github@multica.ai>

* docs(slack): add commands scope to /issue manifest; fix chat-run wording (MUL-3908)

Review follow-up: the manifest examples registered features.slash_commands but omitted the commands bot scope, so updating + reinstalling could still fail to grant the /issue command. Add - commands to oauth_config.scopes.bot in all four locales and document it in the permissions table.

Also correct the misleading "no agent run" wording in the slash-command header and router comment: a todo issue assigned to the agent still triggers it via maybeEnqueueOnAssign (issue-assignment), like the message /issue — the slash command only skips the chat session / chat run.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 14:47:44 +08:00
Naiyuan Qing
fc88c7720f test(onboarding): cover official source reporting controls (#4782) 2026-07-01 14:35:57 +08:00
Naiyuan Qing
ad1afdd48d fix(self-host): restore official source report endpoint (#4781) 2026-07-01 14:11:34 +08:00
18 changed files with 957 additions and 132 deletions

View File

@@ -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 でも使え(@ メンション不要)、確認は本人にだけ返信されます。初回のユーザーには、まずアカウント連携の使い切りリンクが届きます。
## 管理と切断

View File

@@ -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에서 모두 쓸 수 있고(@ 멘션 불필요), 확인은 본인에게만 비공개로 답합니다. 처음 사용하는 사람은 먼저 일회용 계정 연결 링크를 받습니다.
## 관리 및 연결 해제

View File

@@ -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

View File

@@ -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会私下回一条确认。第一次使用的人会先收到一个一次性的账号绑定链接
## 管理与断开

View File

@@ -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}

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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 },

View File

@@ -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

View File

@@ -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
}

View 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
}

View 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)
}
}
}

View File

@@ -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"},

View File

@@ -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},

View 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'));

View 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'));

View File

@@ -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"},

View File

@@ -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 —