diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json
index a3445d026..5ec3f45f1 100644
--- a/packages/views/locales/en/settings.json
+++ b/packages/views/locales/en/settings.json
@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
- "page_description": "Connect each Multica Agent to a Slack bot. A workspace admin installs it with one click via OAuth; members can then DM the bot, @mention it in channels, and type /issue to spin up a new Multica issue.",
+ "page_description": "Connect each Multica Agent to a Slack bot. A workspace admin installs it with one click via OAuth; members can then DM the bot or @mention it in a channel, and start a message with /issue (e.g. \"@bot /issue Fix the login bug\") to spin up a new Multica issue.",
"not_enabled_title": "Slack integration not enabled",
"not_enabled_description_prefix": "Set",
"not_enabled_description_suffix": "on the server to enable Slack bot installations.",
diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json
index cedaeb75f..1be73a2a1 100644
--- a/packages/views/locales/zh-Hans/settings.json
+++ b/packages/views/locales/zh-Hans/settings.json
@@ -302,7 +302,7 @@
},
"slack": {
"section_title": "Slack",
- "page_description": "把每个 Multica Agent 连接到一个 Slack 机器人。工作区管理员通过 OAuth 一键安装;成员之后即可私聊机器人、在频道中 @ 它,并输入 /issue 创建新的 Multica issue。",
+ "page_description": "把每个 Multica Agent 连接到一个 Slack 机器人。工作区管理员通过 OAuth 一键安装;成员之后即可私聊机器人,或在频道中 @ 它,并以 /issue 开头发消息(例如「@机器人 /issue 修复登录问题」)来创建新的 Multica issue。",
"not_enabled_title": "Slack 集成未启用",
"not_enabled_description_prefix": "在服务器上设置",
"not_enabled_description_suffix": "以启用 Slack 机器人安装。",
diff --git a/server/internal/integrations/slack/app_connector.go b/server/internal/integrations/slack/app_connector.go
index b4b428623..f9ca66a3a 100644
--- a/server/internal/integrations/slack/app_connector.go
+++ b/server/internal/integrations/slack/app_connector.go
@@ -162,8 +162,24 @@ func (c *AppConnector) connectOnce(ctx context.Context) error {
api := slack.New("", slack.OptionAppLevelToken(c.appToken))
sm := socketmode.New(api)
- runErr := make(chan error, 1)
- go func() { runErr <- sm.RunContext(ctx) }()
+ // Each connection runs under its OWN cancellable context, not the parent
+ // ctx directly. Every exit path (handler error, event-stream close, ctx
+ // cancellation) cancels runCtx and waits for the run goroutine to observe it
+ // and exit — so a transient handler error tears the live connection down
+ // before Run reconnects. Without this, the old Socket Mode goroutine would
+ // keep running on the long-lived ctx, leaking the connection/goroutine and
+ // consuming events into an unread channel while a second connection opens.
+ runCtx, runCancel := context.WithCancel(ctx)
+ runErr := make(chan error, 1) // buffered: the goroutine sends once and exits even if nobody reads
+ done := make(chan struct{})
+ go func() {
+ runErr <- sm.RunContext(runCtx)
+ close(done)
+ }()
+ defer func() {
+ runCancel()
+ <-done
+ }()
for {
select {
@@ -216,8 +232,16 @@ func (c *AppConnector) handleSocketEvent(ctx context.Context, sm *socketmode.Cli
case socketmode.EventTypeIncomingError, socketmode.EventTypeErrorBadMessage:
c.logger.WarnContext(ctx, "slack: socket mode error", "event", evt.Type)
default:
- // Interactive / slash-command / other events are out of scope; ACK so
- // Slack does not retry, then ignore.
+ // Interactive / slash-command / other envelopes are intentionally
+ // ignored (ACK so Slack does not retry). In particular, /issue is NOT a
+ // registered Slack slash command — the hosted app requests no `commands`
+ // scope, so Slack never routes a slash-command envelope here. Issue
+ // creation runs through the normal message path instead: `@bot /issue
+ //
` in a channel (the mention is stripped, leaving "/issue …") or
+ // `/issue ` in a DM with the bot, which the engine's
+ // ParseIssueCommand picks up. Adding native slash-command support
+ // (scope + registration + response_url handling) is a possible later
+ // enhancement, not required for the message-driven /issue flow.
if evt.Request != nil {
_ = sm.Ack(*evt.Request)
}
diff --git a/server/internal/integrations/slack/install.go b/server/internal/integrations/slack/install.go
index 1fe4e72f0..f8ad4ade7 100644
--- a/server/internal/integrations/slack/install.go
+++ b/server/internal/integrations/slack/install.go
@@ -76,7 +76,7 @@ func (c OAuthConfig) supported() bool {
// installQueries is the slice of generated queries InstallService needs.
// *db.Queries satisfies it.
type installQueries interface {
- UpsertChannelInstallation(ctx context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error)
+ UpsertChannelInstallationByAppID(ctx context.Context, arg db.UpsertChannelInstallationByAppIDParams) (db.ChannelInstallation, error)
CreateChannelUserBinding(ctx context.Context, arg db.CreateChannelUserBindingParams) (db.ChannelUserBinding, error)
ListChannelInstallationsByWorkspace(ctx context.Context, arg db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error)
GetChannelInstallationInWorkspace(ctx context.Context, arg db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error)
@@ -217,7 +217,11 @@ func (s *InstallService) Complete(ctx context.Context, code, rawState string) (C
if err != nil {
return CompletedInstall{}, fmt.Errorf("encode slack installation config: %w", err)
}
- inst, err := s.q.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
+ // Team-keyed upsert: a Slack workspace (team_id) is one installation. Re-
+ // connecting the same team — including to represent a different agent —
+ // updates the existing row rather than colliding with the (channel_type,
+ // app_id) unique index (Niko review must-fix #3).
+ inst, err := s.q.UpsertChannelInstallationByAppID(ctx, db.UpsertChannelInstallationByAppIDParams{
WorkspaceID: wsID,
AgentID: agentID,
ChannelType: string(TypeSlack),
diff --git a/server/internal/integrations/slack/install_test.go b/server/internal/integrations/slack/install_test.go
index 8d773ae6c..671a0f688 100644
--- a/server/internal/integrations/slack/install_test.go
+++ b/server/internal/integrations/slack/install_test.go
@@ -40,13 +40,13 @@ func mustUUID(t *testing.T, s string) pgtype.UUID {
}
type fakeInstallQueries struct {
- upsertParams db.UpsertChannelInstallationParams
+ upsertParams db.UpsertChannelInstallationByAppIDParams
bindParams db.CreateChannelUserBindingParams
bindCalled bool
rowID pgtype.UUID
}
-func (f *fakeInstallQueries) UpsertChannelInstallation(_ context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error) {
+func (f *fakeInstallQueries) UpsertChannelInstallationByAppID(_ context.Context, arg db.UpsertChannelInstallationByAppIDParams) (db.ChannelInstallation, error) {
f.upsertParams = arg
return db.ChannelInstallation{
ID: f.rowID,
diff --git a/server/pkg/db/generated/channel.sql.go b/server/pkg/db/generated/channel.sql.go
index 7133c7ad7..62d5907fe 100644
--- a/server/pkg/db/generated/channel.sql.go
+++ b/server/pkg/db/generated/channel.sql.go
@@ -1085,3 +1085,66 @@ func (q *Queries) UpsertChannelInstallation(ctx context.Context, arg UpsertChann
)
return i, err
}
+
+const upsertChannelInstallationByAppID = `-- name: UpsertChannelInstallationByAppID :one
+INSERT INTO channel_installation (
+ workspace_id, agent_id, channel_type, config, installer_user_id
+) VALUES (
+ $1, $2, $3, $4, $5
+)
+ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
+ workspace_id = EXCLUDED.workspace_id,
+ agent_id = EXCLUDED.agent_id,
+ config = EXCLUDED.config,
+ installer_user_id = EXCLUDED.installer_user_id,
+ status = 'active',
+ installed_at = now(),
+ updated_at = now()
+RETURNING id, workspace_id, agent_id, channel_type, config, status, ws_lease_token, ws_lease_expires_at, installer_user_id, installed_at, created_at, updated_at
+`
+
+type UpsertChannelInstallationByAppIDParams struct {
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ AgentID pgtype.UUID `json:"agent_id"`
+ ChannelType string `json:"channel_type"`
+ Config []byte `json:"config"`
+ InstallerUserID pgtype.UUID `json:"installer_user_id"`
+}
+
+// Team-keyed install / re-install for channels whose natural identity is the
+// platform workspace, not the (agent) pairing. Slack: one Slack workspace
+// (team_id, stored as config->>'app_id') maps to exactly one installation, so
+// re-connecting it — even to represent a DIFFERENT agent — UPDATES the existing
+// row (moving agent_id) instead of colliding with the (channel_type, app_id)
+// unique index. Contrast UpsertChannelInstallation, whose conflict key is
+// (workspace_id, agent_id, channel_type): right for Feishu (one app per agent),
+// wrong for Slack (a second agent connecting the same team would hit the
+// (channel_type, app_id) index). NOTE: a re-connect that would move the team to
+// an agent that already holds a different Slack install still trips the
+// (workspace_id, agent_id, channel_type) unique constraint — that genuine
+// conflict surfaces as an error the OAuth callback turns into a redirect.
+func (q *Queries) UpsertChannelInstallationByAppID(ctx context.Context, arg UpsertChannelInstallationByAppIDParams) (ChannelInstallation, error) {
+ row := q.db.QueryRow(ctx, upsertChannelInstallationByAppID,
+ arg.WorkspaceID,
+ arg.AgentID,
+ arg.ChannelType,
+ arg.Config,
+ arg.InstallerUserID,
+ )
+ var i ChannelInstallation
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.AgentID,
+ &i.ChannelType,
+ &i.Config,
+ &i.Status,
+ &i.WsLeaseToken,
+ &i.WsLeaseExpiresAt,
+ &i.InstallerUserID,
+ &i.InstalledAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/server/pkg/db/queries/channel.sql b/server/pkg/db/queries/channel.sql
index fa9f0f716..7f474bfd6 100644
--- a/server/pkg/db/queries/channel.sql
+++ b/server/pkg/db/queries/channel.sql
@@ -38,6 +38,34 @@ ON CONFLICT (workspace_id, agent_id, channel_type) DO UPDATE SET
updated_at = now()
RETURNING *;
+-- name: UpsertChannelInstallationByAppID :one
+-- Team-keyed install / re-install for channels whose natural identity is the
+-- platform workspace, not the (agent) pairing. Slack: one Slack workspace
+-- (team_id, stored as config->>'app_id') maps to exactly one installation, so
+-- re-connecting it — even to represent a DIFFERENT agent — UPDATES the existing
+-- row (moving agent_id) instead of colliding with the (channel_type, app_id)
+-- unique index. Contrast UpsertChannelInstallation, whose conflict key is
+-- (workspace_id, agent_id, channel_type): right for Feishu (one app per agent),
+-- wrong for Slack (a second agent connecting the same team would hit the
+-- (channel_type, app_id) index). NOTE: a re-connect that would move the team to
+-- an agent that already holds a different Slack install still trips the
+-- (workspace_id, agent_id, channel_type) unique constraint — that genuine
+-- conflict surfaces as an error the OAuth callback turns into a redirect.
+INSERT INTO channel_installation (
+ workspace_id, agent_id, channel_type, config, installer_user_id
+) VALUES (
+ $1, $2, $3, $4, $5
+)
+ON CONFLICT (channel_type, (config ->> 'app_id')) DO UPDATE SET
+ workspace_id = EXCLUDED.workspace_id,
+ agent_id = EXCLUDED.agent_id,
+ config = EXCLUDED.config,
+ installer_user_id = EXCLUDED.installer_user_id,
+ status = 'active',
+ installed_at = now(),
+ updated_at = now()
+RETURNING *;
+
-- name: GetChannelInstallation :one
-- Scoped by channel_type: a per-channel caller (e.g. the Feishu store)
-- must never resolve another channel's installation by guessing its UUID.