mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* fix(channels): auto-reclaim orphaned bot installations + accurate rebind conflict copy channel_installation has no FK to workspace/agent (MUL-3515 §4), so deleting a workspace or hard-deleting an agent left the row behind, occupying the (channel_type, app_id) routing slot forever — the bot could never be rebound and the UI had no way to clear it (#4810). The 409 also always blamed "a different Multica workspace" even when the real owner sat in the same workspace. Auto-reclaim on delete: - DeleteWorkspace and the runtime-teardown paths now sweep the workspace's / archived agents' channel installations and every dependent row in-tx. - The shared install path (Feishu + Slack) reclaims a DEAD prior owner — a revoked placeholder or an orphan whose workspace/agent is gone — before the upsert, healing installations stranded before this fix. A live owner (active agent, including an archived one) is left in place, not stolen. Accurate conflict copy: - A rebind refused by a LIVE owner now distinguishes same-workspace / another agent, an archived agent, and a genuinely different workspace, for both Slack (typed sentinels) and Feishu (registration message). MUL-3937 Co-authored-by: multica-agent <github@multica.ai> * fix(channels): reclaim cross-workspace revoked bots + sweep card/dedup/audit (#4810) Address the #5103 review (yyclaw + Steve): - Reclaim: a REVOKED installation in ANY workspace is now dead (except the caller's own row), not just same-workspace. Disconnect never hard-deletes the row and there is no release UI, so a cross-workspace revoked row would pin a bot's app_id slot forever, with the misleading "connected to another workspace" copy resurfacing. A new binder proves control by holding the app credentials, so reclaiming is safe. Live ACTIVE owners (incl. archived) are still refused. - Sweep the two dependent tables the cleanups missed, in all three paths (reclaim / DeleteWorkspace / runtime teardown): channel_outbound_card_message (no reaper, so a permanent orphan otherwise) and channel_inbound_message_dedup (PurgeChannelInboundDedup has no caller). - Audit rows: PURGE on the hard-delete paths instead of detaching them into permanently unattributable NULL rows; keep DETACH on reclaim, where the workspace survives and the row stays useful for triage. - Tests: flip cross-ws revoked to reclaimed + add cross-ws active preserved; extend the reclaim and both delete-path cleanup tests for card/dedup and the audit purge/detach split; assert the channel sweep on the DeleteRuntimeProfile entry point. MUL-3937 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
191 lines
8.0 KiB
Go
191 lines
8.0 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/slack-go/slack"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// ErrInvalidBotToken / ErrInvalidAppToken are returned by RegisterBYO when a
|
|
// pasted token is malformed (wrong prefix, or an app token whose app id cannot
|
|
// be parsed). The handler maps them to 400 so the dialog can show a precise hint
|
|
// instead of a generic failure.
|
|
var (
|
|
ErrInvalidBotToken = errors.New("slack: bot token must start with xoxb-")
|
|
ErrInvalidAppToken = errors.New("slack: app-level token must start with xapp- and embed an app id")
|
|
// ErrTokenAppMismatch is returned when the pasted bot token and app-level
|
|
// token belong to DIFFERENT Slack apps. Persisting that pair would "connect"
|
|
// but be broken: inbound arrives on the app token's socket (routed by its
|
|
// app id) while mention detection + outbound use the bot token's identity.
|
|
ErrTokenAppMismatch = errors.New("slack: the bot token and app-level token are from different Slack apps")
|
|
)
|
|
|
|
// RegisterBYOParams are the inputs for a bring-your-own-app install: the agent
|
|
// this bot represents, who is installing, and the two tokens the user pasted
|
|
// from their own Slack app.
|
|
type RegisterBYOParams struct {
|
|
WorkspaceID pgtype.UUID
|
|
AgentID pgtype.UUID
|
|
InitiatorID pgtype.UUID
|
|
BotToken string // xoxb-… — outbound Web API (chat.postMessage)
|
|
AppToken string // xapp-… — this app's OWN Socket Mode connection (inbound)
|
|
}
|
|
|
|
// RegisterBYO installs a user-supplied ("bring your own") Slack app for an agent.
|
|
// The user creates their own Slack app, installs it to their workspace, and
|
|
// pastes its bot token (xoxb-) + app-level token (xapp-). There is NO OAuth code
|
|
// exchange: we validate the bot token live via auth.test (which also yields the
|
|
// team id + bot user id), prove the bot + app tokens belong to the SAME app,
|
|
// parse the real Slack app id out of the app-level token, encrypt BOTH tokens at
|
|
// rest, and persist the installation.
|
|
//
|
|
// Because each BYO app is a distinct Slack app — a distinct bot identity — the
|
|
// SAME Slack workspace can host several of them, one per agent. The stored
|
|
// config carries the real app id for inbound routing; persistInstall keys the
|
|
// row by (workspace, agent) and refuses the pair if that app id is already
|
|
// connected to another agent/workspace. The dedicated Socket Mode connection
|
|
// that consumes the stored app token lives in slack_channel.go; this method
|
|
// only persists the installation.
|
|
func (s *InstallService) RegisterBYO(ctx context.Context, p RegisterBYOParams) (db.ChannelInstallation, error) {
|
|
botToken := strings.TrimSpace(p.BotToken)
|
|
appToken := strings.TrimSpace(p.AppToken)
|
|
if !strings.HasPrefix(botToken, "xoxb-") {
|
|
return db.ChannelInstallation{}, ErrInvalidBotToken
|
|
}
|
|
appID, err := parseSlackAppID(appToken)
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, err
|
|
}
|
|
|
|
// Validate the bot token live and learn the team + bot user id. auth.test
|
|
// authenticates with the bot token and returns the bot's OWN user id, which
|
|
// is the @-mention identity inbound translation strips.
|
|
auth, err := s.authTest(ctx, botToken)
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("slack auth.test: %w", err)
|
|
}
|
|
if auth.TeamID == "" || auth.UserID == "" || auth.BotID == "" {
|
|
return db.ChannelInstallation{}, errors.New("slack auth.test: response missing team_id / user_id / bot_id")
|
|
}
|
|
|
|
// Prove the two tokens belong to the SAME Slack app: resolve the bot's
|
|
// OWNING app id (bots.info on the bot id auth.test returned) and require it to
|
|
// equal the app id embedded in the app-level token. Without this, pasting app
|
|
// A's bot token with app B's app token would "connect" but be broken —
|
|
// inbound arrives on app B's socket (routed by api_app_id=B) while mention
|
|
// detection + outbound use app A's bot identity / token (Niko review).
|
|
botAppID, err := s.botAppID(ctx, botToken, auth.BotID)
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("slack bots.info: %w", err)
|
|
}
|
|
if botAppID != appID {
|
|
return db.ChannelInstallation{}, ErrTokenAppMismatch
|
|
}
|
|
|
|
// Validate the app-level token is live (Socket Mode can actually open) so we
|
|
// never persist a token that will silently never receive events.
|
|
if err := s.validateAppToken(ctx, appToken); err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("slack apps.connections.open: %w", err)
|
|
}
|
|
|
|
sealedBot, err := s.box.Seal([]byte(botToken))
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack bot token: %w", err)
|
|
}
|
|
sealedApp, err := s.box.Seal([]byte(appToken))
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("encrypt slack app token: %w", err)
|
|
}
|
|
cfgJSON, err := json.Marshal(installConfig{
|
|
AppID: appID,
|
|
TeamID: auth.TeamID,
|
|
BotUserID: auth.UserID,
|
|
BotTokenEncrypted: base64.StdEncoding.EncodeToString(sealedBot),
|
|
AppTokenEncrypted: base64.StdEncoding.EncodeToString(sealedApp),
|
|
})
|
|
if err != nil {
|
|
return db.ChannelInstallation{}, fmt.Errorf("encode slack installation config: %w", err)
|
|
}
|
|
|
|
// Persist one bot per agent (the row is keyed by workspace + agent). The
|
|
// stored config carries the real app id for inbound routing; persistInstall
|
|
// refuses the pair if that app is already connected to another agent/workspace.
|
|
return s.persistInstall(ctx, installPersist{
|
|
wsID: p.WorkspaceID,
|
|
agentID: p.AgentID,
|
|
installerID: p.InitiatorID,
|
|
appIDKey: appID,
|
|
configJSON: cfgJSON,
|
|
})
|
|
}
|
|
|
|
// slackOpts builds the slack.Client options shared by the install-time Web API
|
|
// calls, honoring the apiURL override so tests can point them at an httptest
|
|
// server. The Slack SDK appends the method name to the endpoint, so the base
|
|
// must end in a slash. A fresh slice is returned each call (safe to append to).
|
|
func (s *InstallService) slackOpts() []slack.Option {
|
|
httpClient := s.httpClient
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
opts := []slack.Option{slack.OptionHTTPClient(httpClient)}
|
|
if s.apiURL != "" {
|
|
base := s.apiURL
|
|
if !strings.HasSuffix(base, "/") {
|
|
base += "/"
|
|
}
|
|
opts = append(opts, slack.OptionAPIURL(base))
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// authTest calls Slack auth.test with the bot token: validates it and returns
|
|
// the team id, the bot's own user id, and the bot id (for the bots.info lookup).
|
|
func (s *InstallService) authTest(ctx context.Context, botToken string) (*slack.AuthTestResponse, error) {
|
|
return slack.New(botToken, s.slackOpts()...).AuthTestContext(ctx)
|
|
}
|
|
|
|
// botAppID resolves the Slack app that OWNS the bot, via bots.info on the bot id
|
|
// from auth.test. It is the only token→app_id path for a bot token, so it is how
|
|
// we prove the pasted bot + app tokens belong to the same app.
|
|
func (s *InstallService) botAppID(ctx context.Context, botToken, botID string) (string, error) {
|
|
bot, err := slack.New(botToken, s.slackOpts()...).GetBotInfoContext(ctx, slack.GetBotInfoParameters{Bot: botID})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return bot.AppID, nil
|
|
}
|
|
|
|
// validateAppToken confirms the app-level token can open a Socket Mode
|
|
// connection (apps.connections.open) — a live check that the xapp is valid for
|
|
// THIS app, so we never store a token that will silently receive nothing.
|
|
func (s *InstallService) validateAppToken(ctx context.Context, appToken string) error {
|
|
api := slack.New("", append(s.slackOpts(), slack.OptionAppLevelToken(appToken))...)
|
|
_, _, err := api.StartSocketModeContext(ctx)
|
|
return err
|
|
}
|
|
|
|
// parseSlackAppID extracts the real Slack app id from an app-level token. The
|
|
// token format is `xapp-1-<APP_ID>-<gen>-<secret>` (e.g. xapp-1-A0BCXGVCS7R-…),
|
|
// so the app id is the third dash-segment. It is the per-app storage / routing
|
|
// key that lets multiple BYO apps coexist in one Slack workspace.
|
|
func parseSlackAppID(appToken string) (string, error) {
|
|
if !strings.HasPrefix(appToken, "xapp-") {
|
|
return "", ErrInvalidAppToken
|
|
}
|
|
parts := strings.SplitN(appToken, "-", 5)
|
|
if len(parts) < 4 || parts[2] == "" || !strings.HasPrefix(parts[2], "A") {
|
|
return "", ErrInvalidAppToken
|
|
}
|
|
return parts[2], nil
|
|
}
|