Files
Bohan Jiang 2b940046d7 fix(slack): build the binding link from the web app URL, matching Lark (MUL-3666) (#4703)
The Slack "link your account" prompt built its redeem link from
MULTICA_PUBLIC_URL, but /slack/bind is a web-app page — the link must use the
web app URL, not the backend/API URL. MULTICA_PUBLIC_URL is intentionally the
backend/API public URL (webhooks, daemon server_url, attachments); the Lark
replier already uses appURLFromEnv() (MULTICA_APP_URL ?? FRONTEND_ORIGIN).
Slack was never migrated, so on deployments that set FRONTEND_ORIGIN but not
MULTICA_PUBLIC_URL (e.g. dev) the binding prompt silently failed
("public url not configured") and @-mentions got no response.

Rename slack.OutboundReplierConfig.PublicURL -> AppURL and feed it
appURLFromEnv() in router.go, mirroring Lark. Backend/API-URL uses of
MULTICA_PUBLIC_URL (webhooks, attachments, daemon server_url) are unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 17:13:41 +08:00

191 lines
7.0 KiB
Go

package slack
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the Slack OutboundReplier — the engine seam that delivers a
// verdict-driven reply back to the user (MUL-3666, completing the stage-3
// Replier=nil tail). It posts through the same bot-token Send path as the
// EventChatDone outbound subscriber, so it needs no new transport.
//
// Outcomes handled:
// - NeedsBinding: the sender is unbound. Mint a single-use binding token and
// reply with a "link your account" prompt pointing at the in-product redeem
// page. After they bind, their next message reaches the agent.
// - AgentOffline / AgentArchived: a status notice so the user is not left
// wondering why nothing happened.
// - Ingested with an /issue created: a confirmation of the new issue.
const (
agentOfflineText = "⚠️ The agent is offline right now. Your message was received and will be handled once it's back online."
agentArchivedText = "⚠️ This agent has been archived and can't respond. Please contact your workspace admin."
)
// bindingMinter is the binding-token surface the replier needs.
// *BindingTokenService satisfies it.
type bindingMinter interface {
Mint(ctx context.Context, workspaceID, installationID pgtype.UUID, slackUserID string) (BindingToken, error)
}
// OutboundReplier implements engine.OutboundReplier for Slack.
type OutboundReplier struct {
binding bindingMinter
decrypt Decrypter
newSender func(creds credentials) replySender
appURL string
bindingPath string
logger *slog.Logger
}
// OutboundReplierConfig configures the replier. Binding + AppURL are required
// for the NeedsBinding prompt to work; without them the prompt is skipped (the
// offline/archived/issue notices still fire).
type OutboundReplierConfig struct {
Binding bindingMinter
Decrypt Decrypter
// AppURL is the Multica web app host the user clicks into to redeem the
// binding token (e.g. https://multica.example). It comes from MULTICA_APP_URL
// (falling back to FRONTEND_ORIGIN) and is intentionally separate from
// MULTICA_PUBLIC_URL, which is the backend/API public URL used for webhook and
// daemon-facing endpoints — the bind page (/slack/bind) is served by the web
// app, so the link must point at the app host, not the API host. Mirrors the
// Lark replier's AppURL.
AppURL string
BindingPath string // default "/slack/bind"
Logger *slog.Logger
}
var _ engine.OutboundReplier = (*OutboundReplier)(nil)
// NewOutboundReplier builds the replier. The sender factory mirrors the outbound
// subscriber: only the bot token is needed to post.
func NewOutboundReplier(cfg OutboundReplierConfig) *OutboundReplier {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
bindingPath := cfg.BindingPath
if bindingPath == "" {
bindingPath = "/slack/bind"
}
if !strings.HasPrefix(bindingPath, "/") {
bindingPath = "/" + bindingPath
}
r := &OutboundReplier{
binding: cfg.Binding,
decrypt: cfg.Decrypt,
appURL: strings.TrimRight(cfg.AppURL, "/"),
bindingPath: bindingPath,
logger: logger,
}
r.newSender = func(c credentials) replySender {
return newSlackSender(c, slack.New(c.BotToken), logger)
}
return r
}
// Reply routes each outcome to its user-visible message. Errors are logged, not
// propagated: the replier runs detached from the inbound ACK path.
func (r *OutboundReplier) Reply(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) {
switch res.Outcome {
case engine.OutcomeNeedsBinding:
if err := r.sendBindingPrompt(ctx, inst, msg, res); err != nil {
r.logger.WarnContext(ctx, "slack replier: binding prompt failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeAgentOffline:
if err := r.post(ctx, inst, msg, agentOfflineText); err != nil {
r.logger.WarnContext(ctx, "slack replier: offline notice failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeAgentArchived:
if err := r.post(ctx, inst, msg, agentArchivedText); err != nil {
r.logger.WarnContext(ctx, "slack replier: archived notice failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
case engine.OutcomeIngested:
// Only a /issue-created message warrants a confirmation; a plain chat
// message stays silent (the agent's own reply lands via EventChatDone).
if res.IssueID.Valid {
if err := r.post(ctx, inst, msg, issueCreatedText(res)); err != nil {
r.logger.WarnContext(ctx, "slack replier: issue-created confirmation failed",
"installation_id", util.UUIDToString(inst.ID), "error", err)
}
}
}
}
func (r *OutboundReplier) sendBindingPrompt(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, res engine.Result) error {
sender := res.Sender
if sender == "" {
sender = msg.Source.SenderID
}
if sender == "" {
return errors.New("missing sender id")
}
if r.binding == nil {
return errors.New("binding service not configured")
}
if r.appURL == "" {
return errors.New("app url not configured")
}
token, err := r.binding.Mint(ctx, inst.WorkspaceID, inst.ID, sender)
if err != nil {
return fmt.Errorf("mint binding token: %w", err)
}
bindURL := r.appURL + r.bindingPath + "?token=" + url.QueryEscape(token.Raw)
// Wrap the URL as an explicit Slack link <url|label>: formatMrkdwn protects
// these from its markdown passes, so the base64url token's `_`/`-` chars are
// not mangled into italics.
text := "👋 To start chatting with me, link your Slack account to Multica: <" +
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
return r.post(ctx, inst, msg, text)
}
// post resolves the installation's bot token from the carried platform row and
// sends text back into the originating channel / thread.
func (r *OutboundReplier) post(ctx context.Context, inst engine.ResolvedInstallation, msg channel.InboundMessage, text string) error {
row, ok := inst.Platform.(db.ChannelInstallation)
if !ok {
return errors.New("installation platform row unavailable")
}
creds, err := decodeCredentials(row.Config, r.decrypt)
if err != nil {
return fmt.Errorf("decode credentials: %w", err)
}
if _, err := r.newSender(creds).Send(ctx, channel.OutboundMessage{
ChatID: msg.Source.ChatID,
Text: text,
ThreadID: msg.Source.ThreadID,
}); err != nil {
return fmt.Errorf("post slack reply: %w", err)
}
return nil
}
func issueCreatedText(res engine.Result) string {
id := res.IssueIdentifier
if id == "" {
id = fmt.Sprintf("#%d", res.IssueNumber)
}
title := strings.TrimSpace(res.IssueTitle)
if title == "" {
return "✅ Created " + id
}
return "✅ Created " + id + " — " + title
}