Compare commits

...

1 Commits

Author SHA1 Message Date
J
f5a353794e feat(slack): reuse account link across apps in one Slack workspace
A Slack user who linked their Multica identity to one bot was re-prompted
to link again when messaging a second bot (a different Slack app) in the
SAME Slack workspace, because bindings are keyed per-installation
(installation_id, channel_user_id).

On an unbound inbound, the identity resolver now looks up an existing
binding for the same (Multica workspace, Slack team, Slack user) via the
new FindReusableChannelUserBinding query and, if that user is still a
workspace member, materializes a binding for the new installation instead
of returning ErrSenderUnbound — so the second bot resolves the user
silently. Reuse is fenced to one Multica workspace AND one Slack team, so
it never crosses either boundary; legacy installs with no recorded team
never reuse.

Adds resolver unit tests for every decision path and updates the Slack
integration docs (en/zh/ja/ko).

MUL-3911

Co-authored-by: multica-agent <github@multica.ai>
2026-07-01 15:17:26 +08:00
8 changed files with 335 additions and 11 deletions

View File

@@ -134,6 +134,8 @@ app-level トークンは Socket Mode 接続を認可します。これはコン
初めて Bot を @ メンションするか DM すると、Bot は **アカウントを紐づける** プロンプトで返信します。リンクをタップして Multica にサインインすると、あなたの Slack アイデンティティがあなたの Multica メンバーシップに紐づきます——これによって、エージェントがあなたとして振る舞えるようになります(たとえば `/issue` はあなたの名義でイシューを起票します)。このリンクは使い切りで、約 15 分で失効します。新しいものが必要なら、もう一度 Bot にメッセージを送るだけです。
**紐づけは Slack ワークスペースごとに一度だけです。** 同じ Multica ワークスペースが 1 つの Slack ワークスペースで複数の Bot を運用している場合(エージェントごとに 1 つのアプリ)、最初の Bot で紐づけを済ませれば、残りはその紐づけを自動的に再利用し、再びプロンプトは出ません。(再度の紐づけが必要なのは、*別の* Slack ワークスペースにある Bot、または *別の* Multica ワークスペースに接続された Bot だけです。)
<Callout type="warning">
Bot を使えるのは **ワークスペースのメンバー** だけです。メンバーでない場合や、アイデンティティの紐づけをスキップした場合、Bot は実行されません——あなたのメッセージは破棄されます(内容は保存せず、監査のために記録されます)。
</Callout>

View File

@@ -134,6 +134,8 @@ app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성
봇을 처음 `@`로 멘션하거나 DM하면, **계정을 연결하라**는 안내로 답합니다. 링크를 탭하고 Multica에 로그인하면, 당신의 Slack 신원이 Multica 멤버십에 바인딩됩니다 — 바로 이 단계가 에이전트로 하여금 당신을 대신해 행동하게 합니다(예: `/issue`는 당신 이름으로 이슈를 생성합니다). 이 링크는 일회용이며 약 15분 후에 만료됩니다. 새 링크가 필요하면 봇에게 다시 메시지를 보내세요.
**연결은 Slack 워크스페이스당 한 번만 하면 됩니다.** 같은 Multica 워크스페이스가 하나의 Slack 워크스페이스에서 여러 봇을 운영하는 경우(에이전트마다 앱 하나), 첫 봇에서 연결을 마치면 나머지는 그 연결을 자동으로 재사용하므로 다시 안내가 뜨지 않습니다. (다시 연결해야 하는 경우는 *다른* Slack 워크스페이스에 있는 봇이거나, *다른* Multica 워크스페이스에 연결된 봇일 때뿐입니다.)
<Callout type="warning">
**워크스페이스 멤버**만 봇을 사용할 수 있습니다. 멤버가 아니거나 신원 연결을 건너뛰면 봇은 실행되지 않으며, 메시지는 폐기됩니다(감사 목적으로 기록되며, 내용은 저장하지 않습니다).
</Callout>

View File

@@ -134,6 +134,8 @@ Setting this up for **multiple agents**? Repeat the whole flow once per agent
The first time you @-mention or DM the bot, it replies with a **link your account** prompt. Tap the link, sign in to Multica, and your Slack identity is bound to your Multica membership — this is what lets the agent act as you (e.g. `/issue` files under your name). The link is single-use and expires in about 15 minutes; just message the bot again for a fresh one.
You only link **once per Slack workspace**. If the same Multica workspace runs several bots in one Slack workspace (one app per agent), the first bot you link teaches the rest: messaging a second bot reuses that link automatically, no re-prompt. (Linking again is only needed for a bot in a *different* Slack workspace, or a bot connected to a *different* Multica workspace.)
<Callout type="warning">
Only **members of the workspace** can use the bot. If you aren't a member, or you skip the identity link, the bot won't run — your message is dropped (recorded for audit, without its contents).
</Callout>

View File

@@ -134,6 +134,8 @@ App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建
第一次 @ 或私聊 Bot 时,它会回一条 **绑定你的账号** 提示。点开链接、登录 Multica你的 Slack 身份就会绑定到你的 Multica 成员身份——正是这一步让智能体能以你的身份行事(比如 `/issue` 会把 issue 记在你名下)。这个链接是一次性的,大约 15 分钟后过期;再给 Bot 发条消息就能拿到一个新的。
**每个 Slack workspace 只需绑定一次。** 如果同一个 Multica 工作区在同一个 Slack workspace 里跑了多个 Bot每个智能体一个 App你在第一个 Bot 上完成绑定后,其余的会自动复用这次绑定,不会再次弹出提示。(只有在*另一个* Slack workspace 里的 Bot、或连到*另一个* Multica 工作区的 Bot才需要重新绑定。
<Callout type="warning">
只有**工作区成员**才能使用 Bot。如果你不是成员或者跳过了身份绑定Bot 不会运行——你的消息会被丢弃(仅出于审计目的记录,不保存消息内容)。
</Callout>

View File

@@ -0,0 +1,159 @@
package slack
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// fakeIdentityQueries implements identityQueries so the cross-installation
// account-link reuse path (MUL-3911) is exercised without a database.
type fakeIdentityQueries struct {
binding db.ChannelUserBinding
bindErr error
reusable db.ChannelUserBinding
reusableErr error
memberErr error
createErr error
findCalls int
findWith db.FindReusableChannelUserBindingParams
createCalls int
createWith db.CreateChannelUserBindingParams
}
func (f *fakeIdentityQueries) GetChannelUserBindingByUserID(_ context.Context, _ db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error) {
return f.binding, f.bindErr
}
func (f *fakeIdentityQueries) FindReusableChannelUserBinding(_ context.Context, arg db.FindReusableChannelUserBindingParams) (db.ChannelUserBinding, error) {
f.findCalls++
f.findWith = arg
return f.reusable, f.reusableErr
}
func (f *fakeIdentityQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.GetMemberByUserAndWorkspaceParams) (db.Member, error) {
return db.Member{}, f.memberErr
}
func (f *fakeIdentityQueries) CreateChannelUserBinding(_ context.Context, arg db.CreateChannelUserBindingParams) (db.ChannelUserBinding, error) {
f.createCalls++
f.createWith = arg
return db.ChannelUserBinding{}, f.createErr
}
// TestResolveSenderReuse covers the identity resolver's decision to reuse an
// existing account link across installations of the same Slack team + Multica
// workspace, instead of re-prompting the user for every new Slack app.
func TestResolveSenderReuse(t *testing.T) {
const senderID = "U123"
wsID := slashTestUUID(0x11)
instB := slashTestUUID(0xBB) // the installation the message arrives on
instA := slashTestUUID(0xAA) // the installation the user already linked
userID := slashTestUUID(0x77)
// inst builds the ResolvedInstallation the message routes to; teamID is what
// its stored config carries (empty = a legacy install with no recorded team).
inst := func(teamID string) engine.ResolvedInstallation {
cfg, _ := json.Marshal(installConfig{AppID: "A_APPB", TeamID: teamID})
return engine.ResolvedInstallation{
ID: instB,
WorkspaceID: wsID,
Platform: db.ChannelInstallation{ID: instB, WorkspaceID: wsID, Config: cfg},
}
}
msg := inbound(channel.ChatTypeP2P, "D1", "", "1.0")
msg.Source.SenderID = senderID
t.Run("direct binding resolves without a reuse lookup or write", func(t *testing.T) {
f := &fakeIdentityQueries{binding: db.ChannelUserBinding{MulticaUserID: userID}}
got, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst("T1"), msg)
if err != nil {
t.Fatalf("ResolveSender err = %v", err)
}
if got.UserID != userID {
t.Errorf("UserID = %v, want %v", got.UserID, userID)
}
if f.findCalls != 0 || f.createCalls != 0 {
t.Errorf("directly-bound sender must not trigger reuse (find=%d create=%d)", f.findCalls, f.createCalls)
}
})
t.Run("unlinked sender reuses a same-team link and materializes it", func(t *testing.T) {
f := &fakeIdentityQueries{
bindErr: pgx.ErrNoRows,
reusable: db.ChannelUserBinding{MulticaUserID: userID, InstallationID: instA},
}
got, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst("T1"), msg)
if err != nil {
t.Fatalf("ResolveSender err = %v", err)
}
if got.UserID != userID {
t.Errorf("UserID = %v, want reused %v", got.UserID, userID)
}
if f.findCalls != 1 {
t.Fatalf("reuse lookup must run exactly once, ran %d", f.findCalls)
}
if f.findWith.TeamID != "T1" || f.findWith.ChannelUserID != senderID || f.findWith.ChannelType != string(TypeSlack) || f.findWith.WorkspaceID != wsID {
t.Errorf("reuse lookup args = %+v", f.findWith)
}
if f.createCalls != 1 {
t.Fatalf("reused link must be materialized on THIS installation, create ran %d", f.createCalls)
}
if f.createWith.InstallationID != instB || f.createWith.MulticaUserID != userID || f.createWith.ChannelUserID != senderID {
t.Errorf("materialized binding args = %+v (want install=%v user=%v sender=%q)", f.createWith, instB, userID, senderID)
}
})
t.Run("no direct binding and nothing to reuse prompts a link", func(t *testing.T) {
f := &fakeIdentityQueries{bindErr: pgx.ErrNoRows, reusableErr: pgx.ErrNoRows}
_, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst("T1"), msg)
if !errors.Is(err, engine.ErrSenderUnbound) {
t.Fatalf("err = %v, want ErrSenderUnbound", err)
}
if f.createCalls != 0 {
t.Errorf("nothing to reuse must not write a binding")
}
})
t.Run("reusable link whose user left the workspace prompts a fresh link", func(t *testing.T) {
f := &fakeIdentityQueries{
bindErr: pgx.ErrNoRows,
reusable: db.ChannelUserBinding{MulticaUserID: userID, InstallationID: instA},
memberErr: pgx.ErrNoRows,
}
_, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst("T1"), msg)
if !errors.Is(err, engine.ErrSenderUnbound) {
t.Fatalf("err = %v, want ErrSenderUnbound (fresh link, not not-member)", err)
}
if f.createCalls != 0 {
t.Errorf("must not materialize a binding for a non-member")
}
})
t.Run("legacy installation with no team never attempts reuse", func(t *testing.T) {
f := &fakeIdentityQueries{bindErr: pgx.ErrNoRows}
_, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst(""), msg)
if !errors.Is(err, engine.ErrSenderUnbound) {
t.Fatalf("err = %v, want ErrSenderUnbound", err)
}
if f.findCalls != 0 {
t.Errorf("an install with no recorded team must not attempt cross-app reuse")
}
})
t.Run("directly-bound non-member surfaces not-member", func(t *testing.T) {
f := &fakeIdentityQueries{binding: db.ChannelUserBinding{MulticaUserID: userID}, memberErr: pgx.ErrNoRows}
_, err := (&identityResolver{q: f}).ResolveSender(context.Background(), inst("T1"), msg)
if !errors.Is(err, engine.ErrSenderNotMember) {
t.Fatalf("err = %v, want ErrSenderNotMember", err)
}
})
}

View File

@@ -120,6 +120,16 @@ func nullText(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: true}
}
// installTeamID reads the real Slack team id from a stored installation config,
// or "" if absent/undecodable. Unlike decodeCredentials / DecodePublicConfig it
// does NOT fall back to app_id: team routing and identity reuse must match the
// actual Slack workspace, and app_id != team_id for BYO apps.
func installTeamID(installConfigJSON json.RawMessage) string {
var cfg installConfig
_ = json.Unmarshal(installConfigJSON, &cfg)
return cfg.TeamID
}
// installationServesTeam reports whether an installation (its stored config) may
// serve events from eventTeamID. Inbound routing keys on api_app_id, which
// identifies the Slack APP, not the Slack workspace: a BYO app distributed /
@@ -127,12 +137,8 @@ func nullText(s string) pgtype.Text {
// So we additionally require the event's team to match the team the installed
// bot belongs to. An installation with no recorded team (legacy) is permissive.
func installationServesTeam(installConfigJSON json.RawMessage, eventTeamID string) bool {
// Read team_id directly (NOT via DecodePublicConfig, which falls back to
// app_id when team_id is absent — a hosted-era convenience that would defeat
// this check for BYO where app_id != team_id).
var cfg installConfig
_ = json.Unmarshal(installConfigJSON, &cfg)
return cfg.TeamID == "" || cfg.TeamID == eventTeamID
teamID := installTeamID(installConfigJSON)
return teamID == "" || teamID == eventTeamID
}
// ---- installation routing ----
@@ -173,32 +179,106 @@ func (r *installationResolver) ResolveInstallation(ctx context.Context, msg chan
// ---- identity ----
type identityResolver struct{ q *db.Queries }
// identityQueries is the slice of generated queries the identityResolver needs.
// It is an interface (not *db.Queries) so the cross-installation reuse path is
// unit-tested with fakes, mirroring slashQueries. *db.Queries satisfies it.
type identityQueries interface {
GetChannelUserBindingByUserID(ctx context.Context, arg db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error)
FindReusableChannelUserBinding(ctx context.Context, arg db.FindReusableChannelUserBindingParams) (db.ChannelUserBinding, error)
GetMemberByUserAndWorkspace(ctx context.Context, arg db.GetMemberByUserAndWorkspaceParams) (db.Member, error)
CreateChannelUserBinding(ctx context.Context, arg db.CreateChannelUserBindingParams) (db.ChannelUserBinding, error)
}
type identityResolver struct{ q identityQueries }
func (r *identityResolver) ResolveSender(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage) (engine.ResolvedIdentity, error) {
senderID := msg.Source.SenderID
binding, err := r.q.GetChannelUserBindingByUserID(ctx, db.GetChannelUserBindingByUserIDParams{
InstallationID: inst.ID,
ChannelUserID: msg.Source.SenderID,
ChannelUserID: senderID,
})
reused := false
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if !errors.Is(err, pgx.ErrNoRows) {
return engine.ResolvedIdentity{}, err
}
// Not linked to THIS installation. Before prompting, reuse a link the same
// Slack user already made to another installation of the same team in this
// workspace (MUL-3911): one link per Slack workspace, not per app.
cand, ok, ferr := r.reusableBinding(ctx, inst, senderID)
if ferr != nil {
return engine.ResolvedIdentity{}, ferr
}
if !ok {
return engine.ResolvedIdentity{}, engine.ErrSenderUnbound
}
return engine.ResolvedIdentity{}, err
binding, reused = cand, true
}
// Binding existence no longer proves membership (no FK); re-check.
// Binding existence no longer proves membership (no FK); re-check. For a
// reused link this also gates materialization: we never persist a binding for
// a user who has since left the workspace.
if _, err := r.q.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: binding.MulticaUserID,
WorkspaceID: inst.WorkspaceID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if reused {
// Same human, no longer a member: prompt a fresh link rather than
// surface "not a member" for an app they never linked.
return engine.ResolvedIdentity{}, engine.ErrSenderUnbound
}
return engine.ResolvedIdentity{}, engine.ErrSenderNotMember
}
return engine.ResolvedIdentity{}, err
}
if reused {
// Materialize the reused link as a binding on THIS installation so later
// messages resolve on the fast per-installation path and are pruned with
// the member like any other. Idempotent via ON CONFLICT; a concurrent
// first message that already wrote it returns the same row.
if _, err := r.q.CreateChannelUserBinding(ctx, db.CreateChannelUserBindingParams{
WorkspaceID: inst.WorkspaceID,
MulticaUserID: binding.MulticaUserID,
InstallationID: inst.ID,
ChannelType: string(TypeSlack),
ChannelUserID: senderID,
Config: []byte(`{}`),
}); err != nil {
return engine.ResolvedIdentity{}, fmt.Errorf("materialize reused slack binding: %w", err)
}
}
return engine.ResolvedIdentity{UserID: binding.MulticaUserID}, nil
}
// reusableBinding looks for a link the same Slack user already made to ANOTHER
// installation of the SAME workspace + SAME Slack team, so a second app in one
// Slack workspace need not re-prompt (MUL-3911). ok=false (nil error) means "no
// reuse — prompt to link": the installation records no team (legacy), its
// Platform is not a ChannelInstallation, or no matching binding exists.
func (r *identityResolver) reusableBinding(ctx context.Context, inst engine.ResolvedInstallation, senderID string) (db.ChannelUserBinding, bool, error) {
ci, ok := inst.Platform.(db.ChannelInstallation)
if !ok {
return db.ChannelUserBinding{}, false, nil
}
teamID := installTeamID(ci.Config)
if teamID == "" {
return db.ChannelUserBinding{}, false, nil
}
cand, err := r.q.FindReusableChannelUserBinding(ctx, db.FindReusableChannelUserBindingParams{
WorkspaceID: inst.WorkspaceID,
ChannelType: string(TypeSlack),
ChannelUserID: senderID,
TeamID: teamID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.ChannelUserBinding{}, false, nil
}
return db.ChannelUserBinding{}, false, err
}
return cand, true, nil
}
// ---- dedup ----
type deduper struct{ q *db.Queries }

View File

@@ -402,6 +402,59 @@ func (q *Queries) DeleteChannelUserBindingsByWorkspaceMember(ctx context.Context
return err
}
const findReusableChannelUserBinding = `-- name: FindReusableChannelUserBinding :one
SELECT b.id, b.workspace_id, b.multica_user_id, b.installation_id, b.channel_type, b.channel_user_id, b.config, b.bound_at FROM channel_user_binding b
JOIN channel_installation ci ON ci.id = b.installation_id
WHERE b.workspace_id = $1
AND b.channel_type = $2
AND b.channel_user_id = $3
AND ci.config ->> 'team_id' = $4::text
ORDER BY b.bound_at DESC
LIMIT 1
`
type FindReusableChannelUserBindingParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ChannelType string `json:"channel_type"`
ChannelUserID string `json:"channel_user_id"`
TeamID string `json:"team_id"`
}
// Cross-installation account-link reuse (MUL-3911). When a platform user
// messages an installation they have NOT linked, but the SAME user id is already
// bound to ANOTHER installation in the SAME Multica workspace + SAME Slack team,
// the inbound identity step reuses that link instead of re-prompting. Slack user
// ids are stable within a team, so an identical channel_user_id denotes the same
// human across that team's apps. The match is fenced to one workspace AND one
// team (installation config->>'team_id'): a Slack team can be connected to two
// different Multica workspaces, and a user may hold different Multica accounts in
// each, so reuse must cross neither boundary. Most-recently-bound wins. The
// caller re-checks membership and materializes a fresh per-installation binding.
//
// team_id is pinned ::text so sqlc types the arg as a string instead of
// attributing the bare param to the JSONB config column (mirrors
// GetChannelInstallationByAppID's app_id cast).
func (q *Queries) FindReusableChannelUserBinding(ctx context.Context, arg FindReusableChannelUserBindingParams) (ChannelUserBinding, error) {
row := q.db.QueryRow(ctx, findReusableChannelUserBinding,
arg.WorkspaceID,
arg.ChannelType,
arg.ChannelUserID,
arg.TeamID,
)
var i ChannelUserBinding
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.MulticaUserID,
&i.InstallationID,
&i.ChannelType,
&i.ChannelUserID,
&i.Config,
&i.BoundAt,
)
return i, err
}
const getChannelChatSessionBinding = `-- name: GetChannelChatSessionBinding :one
SELECT id, chat_session_id, installation_id, channel_type, channel_chat_id, chat_type, last_message_id, last_thread_id, config, created_at FROM channel_chat_session_binding
WHERE installation_id = $1 AND channel_chat_id = $2

View File

@@ -227,6 +227,30 @@ RETURNING *;
SELECT * FROM channel_user_binding
WHERE installation_id = $1 AND channel_user_id = $2;
-- name: FindReusableChannelUserBinding :one
-- Cross-installation account-link reuse (MUL-3911). When a platform user
-- messages an installation they have NOT linked, but the SAME user id is already
-- bound to ANOTHER installation in the SAME Multica workspace + SAME Slack team,
-- the inbound identity step reuses that link instead of re-prompting. Slack user
-- ids are stable within a team, so an identical channel_user_id denotes the same
-- human across that team's apps. The match is fenced to one workspace AND one
-- team (installation config->>'team_id'): a Slack team can be connected to two
-- different Multica workspaces, and a user may hold different Multica accounts in
-- each, so reuse must cross neither boundary. Most-recently-bound wins. The
-- caller re-checks membership and materializes a fresh per-installation binding.
--
-- team_id is pinned ::text so sqlc types the arg as a string instead of
-- attributing the bare param to the JSONB config column (mirrors
-- GetChannelInstallationByAppID's app_id cast).
SELECT b.* FROM channel_user_binding b
JOIN channel_installation ci ON ci.id = b.installation_id
WHERE b.workspace_id = sqlc.arg('workspace_id')
AND b.channel_type = sqlc.arg('channel_type')
AND b.channel_user_id = sqlc.arg('channel_user_id')
AND ci.config ->> 'team_id' = sqlc.arg('team_id')::text
ORDER BY b.bound_at DESC
LIMIT 1;
-- name: DeleteChannelUserBindingsByWorkspaceMember :exec
-- Application-layer integrity (replaces the old member-FK ON DELETE
-- CASCADE): prune every binding for a user who has been removed from a