Files
multica/server/internal/integrations/slack/resolvers_test.go
Bohan Jiang e5995c423f feat(slack): typing reaction on inbound message (MUL-3874) (#4737)
* feat(slack): add typing reaction on inbound message (MUL-3874)

Mirror the Feishu typing indicator on Slack: react with 👀 on the user's
message when it is ingested, then remove the reaction when the agent's run
finishes (EventChatDone) or fails (EventTaskFailed).

- New slack.TypingIndicatorManager: Add on ingest, Clear on terminal run
  events; state keyed by chat_session_id, bot token re-resolved from the DB on
  clear (never held in memory), all failures logged and swallowed (best-effort).
- Wire via the channel-agnostic engine.TypingNotifier seam (slackTypingNotifier
  in the ResolverSet) — the Router already calls OnIngested off the ACK path.
- Clear subscribes to the event bus directly so a failed run also drops the
  reaction (the outbound replier only handles EventChatDone).
- Skip messages older than 2m so Socket Mode reconnect replays don't restamp.

Requires the installed Slack app to hold the reactions:write scope.

Co-authored-by: multica-agent <github@multica.ai>

* fix(slack): clear typing reaction when no task runs; document reactions:write (MUL-3874)

Addresses review feedback on the typing-indicator PR.

1. Stuck reaction on offline/archived agent. The debounced flush
   (flushChatRun) enqueues no task when the agent has no runtime or is
   archived (or on any enqueue/reload error), so no task lifecycle event is
   ever published and the bus-driven clear never fires — leaving the 👀 (and
   Feishu's Typing) reaction stuck on the user's message. Fix at the shared
   engine seam: add TypingNotifier.OnSettled(ctx, sessionID), which the Router
   calls from the flush on every no-task exit (before any offline/archived
   notice). Both the Slack and Feishu notifiers route it to manager.Clear, so
   the latent Feishu case is fixed too. Adds engine coverage (offline/archived
   clear, success does not) and a Slack OnSettled test.

2. Missing reactions:write scope in docs. reactions.add/remove silently fail
   without the scope, but the BYO app manifest/docs never listed it. Add
   reactions:write to the manifest + scope table and a reinstall note across
   all four locales (en/zh/ja/ko).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 14:21:08 +08:00

145 lines
4.8 KiB
Go

package slack
import (
"encoding/json"
"testing"
"github.com/multica-ai/multica/server/internal/integrations/channel"
)
func inbound(chatType channel.ChatType, chatID, threadID, msgID string) channel.InboundMessage {
return channel.InboundMessage{
MessageID: msgID,
Source: channel.Source{
ChannelType: TypeSlack,
ChatID: chatID,
ChatType: chatType,
ThreadID: threadID,
},
}
}
func TestSlackSessionRouting(t *testing.T) {
cases := []struct {
name string
msg channel.InboundMessage
wantKey string
wantReplyThr string
wantChannelCfg string
}{
{
name: "DM top-level: one session per channel",
msg: inbound(channel.ChatTypeP2P, "D1", "", "111.0"),
wantKey: "D1",
wantReplyThr: "",
wantChannelCfg: "D1",
},
{
name: "DM in a thread: still one session per channel, reply into the thread",
msg: inbound(channel.ChatTypeP2P, "D1", "100.0", "111.0"),
wantKey: "D1",
wantReplyThr: "100.0",
wantChannelCfg: "D1",
},
{
name: "channel top-level @mention: new thread root = message ts",
msg: inbound(channel.ChatTypeGroup, "C1", "", "111.0"),
wantKey: "C1:111.0",
wantReplyThr: "111.0",
wantChannelCfg: "C1",
},
{
name: "channel thread reply: isolated by thread root",
msg: inbound(channel.ChatTypeGroup, "C1", "100.0", "222.0"),
wantKey: "C1:100.0",
wantReplyThr: "100.0",
wantChannelCfg: "C1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key, cfg, reply := slackSessionRouting(tc.msg)
if key != tc.wantKey {
t.Errorf("bindingKey = %q, want %q", key, tc.wantKey)
}
if reply != tc.wantReplyThr {
t.Errorf("replyThread = %q, want %q", reply, tc.wantReplyThr)
}
var got slackBindingConfig
if err := json.Unmarshal(cfg, &got); err != nil {
t.Fatalf("config not valid json: %v", err)
}
if got.ChannelID != tc.wantChannelCfg {
t.Errorf("config.channel_id = %q, want %q (real channel for outbound)", got.ChannelID, tc.wantChannelCfg)
}
})
}
}
// TestSlackThreadIsolation is the resolver-level guard Niko/Elon asked for: two
// @bot threads in the SAME channel must derive DISTINCT session binding keys,
// while a follow-up in the same thread derives the same key.
func TestSlackThreadIsolation(t *testing.T) {
thread1Root, _, _ := slackSessionRouting(inbound(channel.ChatTypeGroup, "C1", "", "1111.0")) // @mention starts thread 1
thread2Root, _, _ := slackSessionRouting(inbound(channel.ChatTypeGroup, "C1", "", "2222.0")) // @mention starts thread 2
thread1Reply, _, _ := slackSessionRouting(inbound(channel.ChatTypeGroup, "C1", "1111.0", "3333.0")) // reply in thread 1
if thread1Root == thread2Root {
t.Errorf("two @bot threads in one channel must isolate: %q == %q", thread1Root, thread2Root)
}
if thread1Reply != thread1Root {
t.Errorf("a follow-up in thread 1 must reuse thread 1's key: %q != %q", thread1Reply, thread1Root)
}
}
func TestNewSlackResolverSet(t *testing.T) {
set := NewSlackResolverSet(nil, nil, nil, nil)
if set.Installation == nil || set.Identity == nil || set.Dedup == nil || set.Session == nil || set.Audit == nil {
t.Error("resolver set must populate all required resolvers")
}
if set.OriginType != "slack_chat" {
t.Errorf("OriginType = %q, want slack_chat", set.OriginType)
}
if set.Replier != nil {
t.Error("a nil replier arg must leave Replier nil (not a typed-nil interface)")
}
if set.Typing != nil {
t.Error("a nil typing arg must leave Typing nil (not a typed-nil interface)")
}
// A real replier + typing manager thread through.
set = NewSlackResolverSet(nil, nil, NewOutboundReplier(OutboundReplierConfig{}), NewTypingIndicatorManager(nil, nil, nil))
if set.Replier == nil {
t.Error("a non-nil replier must populate ResolverSet.Replier")
}
if set.Typing == nil {
t.Error("a non-nil typing manager must populate ResolverSet.Typing")
}
}
func TestInstallationServesTeam(t *testing.T) {
cfg := func(team string) json.RawMessage {
b, _ := json.Marshal(installConfig{AppID: "A0BCXGVCS7R", TeamID: team})
return b
}
cases := []struct {
name string
cfgTeam string
eventTeam string
want bool
}{
{"matching team", "T999", "T999", true},
// api_app_id alone is not enough: the same app installed into another Slack
// workspace emits the same app id but a different team — must not route here.
{"different team", "T999", "TOTHER", false},
{"empty event team", "T999", "", false},
{"legacy row without a team is permissive", "", "TANY", true},
}
for _, c := range cases {
if got := installationServesTeam(cfg(c.cfgTeam), c.eventTeam); got != c.want {
t.Errorf("%s: installationServesTeam(cfg=%q, event=%q) = %v, want %v",
c.name, c.cfgTeam, c.eventTeam, got, c.want)
}
}
}