mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
Classify outbound replies by task origin (chat_input_task_id) before consulting a channel binding, so web/mobile direct-chat completions and failures stay inside Multica even when the session was originally created from Lark or Slack. Channel-created tasks continue to deliver. Closes #5644.
177 lines
5.3 KiB
Go
177 lines
5.3 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
func uid(b byte) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
u.Bytes[0] = b
|
|
u.Valid = true
|
|
return u
|
|
}
|
|
|
|
type fakeOutboundQueries struct {
|
|
binding db.ChannelChatSessionBinding
|
|
bindingErr error
|
|
inst db.ChannelInstallation
|
|
instErr error
|
|
task db.AgentTaskQueue
|
|
taskErr error
|
|
}
|
|
|
|
func (f *fakeOutboundQueries) GetAgentTask(context.Context, pgtype.UUID) (db.AgentTaskQueue, error) {
|
|
return f.task, f.taskErr
|
|
}
|
|
|
|
func (f *fakeOutboundQueries) GetChannelChatSessionBindingBySession(context.Context, db.GetChannelChatSessionBindingBySessionParams) (db.ChannelChatSessionBinding, error) {
|
|
return f.binding, f.bindingErr
|
|
}
|
|
|
|
func (f *fakeOutboundQueries) GetChannelInstallation(context.Context, db.GetChannelInstallationParams) (db.ChannelInstallation, error) {
|
|
return f.inst, f.instErr
|
|
}
|
|
|
|
type fakeSender struct {
|
|
called int
|
|
got channel.OutboundMessage
|
|
}
|
|
|
|
func (f *fakeSender) Send(_ context.Context, out channel.OutboundMessage) (channel.SendResult, error) {
|
|
f.called++
|
|
f.got = out
|
|
return channel.SendResult{MessageID: "1.1"}, nil
|
|
}
|
|
|
|
// slackInstallConfigJSON builds an installation config blob with base64 tokens
|
|
// (a nil Decrypter treats the decoded bytes as plaintext).
|
|
func slackInstallConfigJSON() []byte {
|
|
b, _ := json.Marshal(map[string]string{
|
|
"app_id": "T1",
|
|
"bot_user_id": "UBOT",
|
|
"bot_token_encrypted": base64.StdEncoding.EncodeToString([]byte("xoxb-test")),
|
|
})
|
|
return b
|
|
}
|
|
|
|
func newTestOutbound(q outboundQueries, fs *fakeSender) *Outbound {
|
|
o := NewOutbound(q, nil, nil)
|
|
o.newSender = func(credentials) replySender { return fs }
|
|
return o
|
|
}
|
|
|
|
func chatDoneEvent(sessionID string, content string) events.Event {
|
|
return events.Event{
|
|
Type: protocol.EventChatDone,
|
|
ChatSessionID: sessionID,
|
|
Payload: protocol.ChatDonePayload{
|
|
TaskID: "00000000-0000-0000-0000-000000000002",
|
|
ChatSessionID: sessionID,
|
|
Content: content,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestOutbound_SkipsDirectChatTaskOnBoundSlackSession(t *testing.T) {
|
|
q := &fakeOutboundQueries{
|
|
task: db.AgentTaskQueue{ChatInputTaskID: uid(2)},
|
|
binding: db.ChannelChatSessionBinding{
|
|
InstallationID: uid(1),
|
|
ChannelChatID: "C123",
|
|
Config: []byte(`{"channel_id":"C123"}`),
|
|
},
|
|
inst: db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()},
|
|
}
|
|
fs := &fakeSender{}
|
|
|
|
newTestOutbound(q, fs).handleEvent(chatDoneEvent("00000000-0000-0000-0000-000000000001", "private web reply"))
|
|
|
|
if fs.called != 0 {
|
|
t.Fatalf("sender called %d times, want 0 for a direct-chat task", fs.called)
|
|
}
|
|
}
|
|
|
|
func TestOutbound_PostsReplyToBoundSlackChannel(t *testing.T) {
|
|
q := &fakeOutboundQueries{
|
|
// Composite isolation key; real channel + reply thread come from config /
|
|
// last_thread_id.
|
|
binding: db.ChannelChatSessionBinding{
|
|
InstallationID: uid(1),
|
|
ChannelChatID: "C123:1111.0",
|
|
Config: []byte(`{"channel_id":"C123"}`),
|
|
LastThreadID: pgtype.Text{String: "1111.0", Valid: true},
|
|
},
|
|
inst: db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()},
|
|
}
|
|
fs := &fakeSender{}
|
|
o := newTestOutbound(q, fs)
|
|
|
|
o.handleEvent(chatDoneEvent("00000000-0000-0000-0000-000000000001", "**all done**"))
|
|
|
|
if fs.called != 1 {
|
|
t.Fatalf("sender called %d times, want 1", fs.called)
|
|
}
|
|
if fs.got.ChatID != "C123" {
|
|
t.Errorf("ChatID = %q, want the real channel from config (not the composite key)", fs.got.ChatID)
|
|
}
|
|
if fs.got.ThreadID != "1111.0" {
|
|
t.Errorf("ThreadID = %q, want the recorded reply thread", fs.got.ThreadID)
|
|
}
|
|
if fs.got.Text != "**all done**" {
|
|
t.Errorf("Text = %q, want the raw content (Send applies mrkdwn)", fs.got.Text)
|
|
}
|
|
}
|
|
|
|
func TestOutbound_IgnoresNonSlackAndEmptyAndRevoked(t *testing.T) {
|
|
const sid = "00000000-0000-0000-0000-000000000001"
|
|
activeInst := db.ChannelInstallation{ID: uid(1), Status: "active", Config: slackInstallConfigJSON()}
|
|
boundBinding := db.ChannelChatSessionBinding{InstallationID: uid(1), ChannelChatID: "C1", Config: []byte(`{"channel_id":"C1"}`)}
|
|
|
|
cases := []struct {
|
|
name string
|
|
q *fakeOutboundQueries
|
|
evt events.Event
|
|
}{
|
|
{
|
|
name: "no slack binding (Feishu / web session)",
|
|
q: &fakeOutboundQueries{bindingErr: pgx.ErrNoRows},
|
|
evt: chatDoneEvent(sid, "hi"),
|
|
},
|
|
{
|
|
name: "empty completion content",
|
|
q: &fakeOutboundQueries{binding: boundBinding, inst: activeInst},
|
|
evt: chatDoneEvent(sid, ""),
|
|
},
|
|
{
|
|
name: "revoked installation",
|
|
q: &fakeOutboundQueries{binding: boundBinding, inst: db.ChannelInstallation{ID: uid(1), Status: "revoked", Config: slackInstallConfigJSON()}},
|
|
evt: chatDoneEvent(sid, "hi"),
|
|
},
|
|
{
|
|
name: "non-chat event (no session id)",
|
|
q: &fakeOutboundQueries{},
|
|
evt: chatDoneEvent("", "hi"),
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fs := &fakeSender{}
|
|
newTestOutbound(tc.q, fs).handleEvent(tc.evt)
|
|
if fs.called != 0 {
|
|
t.Errorf("%s: sender must not be called, got %d", tc.name, fs.called)
|
|
}
|
|
})
|
|
}
|
|
}
|