Compare commits

...

3 Commits

Author SHA1 Message Date
J
2f1d666ec5 fix(slack): allow slack_chat issue origin so /issue can create issues (MUL-3908)
The issue.origin_type CHECK constraint (migration 111) only allowed autopilot / quick_create / lark_chat. The Slack integration stamps origin_type='slack_chat' (originSlackChat) on every /issue create, so the INSERT tripped SQLSTATE 23514 and IssueService.Create failed — the /issue slash command surfaced it as "Something went wrong creating the issue". This also silently broke the pre-existing message-based Slack /issue.

Add migration 129 extending the constraint to include 'slack_chat', mirroring 111 (lark_chat). Verified against a scratch DB: full chain up to 129 applies cleanly and an issue insert with origin_type='slack_chat' now passes the CHECK (it failed with the pre-129 constraint).

Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 15:09:31 +08:00
J
ff6d2b3c4b 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>
2026-07-01 14:17:02 +08:00
J
5c0e0bae9d 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>
2026-07-01 13:50:17 +08:00
10 changed files with 696 additions and 9 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

@@ -469,11 +469,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

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