mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* 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>
145 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|