mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
* fix(bitrix24): skip join greeting in open channels [B24:2794] * wip: rework bitrix24 mcp provisioning + skills prompt section (tam thoi, se lam lai) [B24:2794] --------- Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
1019 lines
35 KiB
Go
1019 lines
35 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// newHandleTestChannel builds a Channel ready to accept events without
|
|
// running Start(). Pre-populates botID so mention matching works.
|
|
func newHandleTestChannel(t *testing.T, botID int, requireMention bool) (*Channel, *bus.MessageBus) {
|
|
t.Helper()
|
|
fs := newFakeStore()
|
|
tid := store.GenNewID()
|
|
resetWebhookRouterForTest()
|
|
|
|
mb := bus.New()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
cfg := json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n","dm_policy":"open","group_policy":"open"}`)
|
|
ch, err := fn("b1", nil, cfg, mb, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory: %v", err)
|
|
}
|
|
bc := ch.(*Channel)
|
|
bc.SetTenantID(tid)
|
|
bc.SetRequireMention(requireMention)
|
|
|
|
// Bypass Start — inject minimal state so handleMessage/DispatchEvent have
|
|
// what they need (bot_id for mention regex, client for welcome message).
|
|
bc.startMu.Lock()
|
|
bc.botID = botID
|
|
bc.client = NewClient("portal.bitrix24.com", nil)
|
|
bc.startMu.Unlock()
|
|
return bc, mb
|
|
}
|
|
|
|
func drainOne(mb *bus.MessageBus, timeout time.Duration) (bus.InboundMessage, bool) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
return mb.ConsumeInbound(ctx)
|
|
}
|
|
|
|
func TestDispatchEvent_NilIsNoop(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 1, false)
|
|
defer resetWebhookRouterForTest()
|
|
// Must not panic on nil event.
|
|
ch.DispatchEvent(context.Background(), nil)
|
|
}
|
|
|
|
func TestDispatchEvent_UnknownTypeIgnored(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: "ONIMBOTSOMETHINGNEW",
|
|
Params: EventParams{FromUserID: "99", DialogID: "99", Message: "hi"},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("unknown event type should not publish")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_DMHappyPath_PublishesInbound(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageID: "m-1",
|
|
MessageType: "private",
|
|
Message: "Xin chào",
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("expected an inbound message")
|
|
}
|
|
if msg.Content != "Xin chào" {
|
|
t.Errorf("content = %q; want Xin chào", msg.Content)
|
|
}
|
|
if msg.PeerKind != "direct" {
|
|
t.Errorf("PeerKind = %q; want direct", msg.PeerKind)
|
|
}
|
|
if msg.Metadata["bitrix_dialog_id"] != "42" {
|
|
t.Errorf("missing/wrong bitrix_dialog_id: %v", msg.Metadata)
|
|
}
|
|
if msg.Metadata["bitrix_bot_id"] != "101" {
|
|
t.Errorf("missing/wrong bitrix_bot_id: %v", msg.Metadata)
|
|
}
|
|
if msg.Metadata["bitrix_message_id"] != "m-1" {
|
|
t.Errorf("missing/wrong bitrix_message_id: %v", msg.Metadata)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_SystemMessageSkipped(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageType: "private",
|
|
Message: "User X joined the chat",
|
|
SystemMessage: true,
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("system messages must not trigger agent replies")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_EmptyFromUserIDSkipped(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "",
|
|
DialogID: "42",
|
|
MessageType: "private",
|
|
Message: "hi",
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("messages without FromUserID must be ignored")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_EmptyContentNoMediaSkipped(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageType: "private",
|
|
Message: " ",
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("empty content with no media must be dropped")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_GroupRequireMention_DropsWithoutMention(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, true)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "chat10",
|
|
MessageType: "chat",
|
|
Message: "hey everyone just chatting",
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("group message without @mention must be dropped when RequireMention=true")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_GroupWithMention_Published(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, true)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
// Mention this bot (bot_id 101) → must strip the tag and publish body.
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "chat10",
|
|
MessageType: "chat",
|
|
Message: "[USER=101]Bot[/USER] what time is it?",
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("mentioned group message must publish")
|
|
}
|
|
if strings.Contains(msg.Content, "[USER=101]") {
|
|
t.Errorf("mention not stripped: %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "what time is it?") {
|
|
t.Errorf("body stripped out: %q", msg.Content)
|
|
}
|
|
if msg.PeerKind != "group" {
|
|
t.Errorf("PeerKind = %q; want group", msg.PeerKind)
|
|
}
|
|
}
|
|
|
|
// Regression: Bitrix24 strips ALL `[USER=...]` mentions from MESSAGE on group
|
|
// chats — including mentions of OTHER users — so relying on MESSAGE alone loses
|
|
// the addressed-user context. Handler must read MESSAGE_ORIGINAL when present,
|
|
// strip THIS bot's mention, and surface remaining user mentions to the agent
|
|
// in a readable form. Field-tested on a payload that originally arrived as just
|
|
// "Đây này em", losing two upstream `[USER=...]` mentions to teammates.
|
|
func TestHandleMessage_GroupPreservesOtherUserMentions(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, true)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "614",
|
|
DialogID: "chat4932",
|
|
MessageType: "chat",
|
|
// Bitrix24 strips ALL mentions from MESSAGE — even of other users.
|
|
// MESSAGE_ORIGINAL is the raw BBCode source.
|
|
Message: "Đây này em",
|
|
MessageOriginal: "[USER=982]Ngân Nguyệt - Hàn Lập[/USER] [USER=62]Đặng Văn Tình[/USER] [USER=101]Bot[/USER] Đây này em",
|
|
MentionedList: map[string]string{"101": "101"}, // pass mention check
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("mentioned group message must publish")
|
|
}
|
|
if strings.Contains(msg.Content, "[USER=101]") {
|
|
t.Errorf("THIS bot's mention must be stripped, got: %q", msg.Content)
|
|
}
|
|
if strings.Contains(msg.Content, "[USER=") || strings.Contains(msg.Content, "[/USER]") {
|
|
t.Errorf("remaining BBCode tags must be converted, got: %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "@Ngân Nguyệt - Hàn Lập (ID:982)") {
|
|
t.Errorf("other user mention must be preserved/readable, got: %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "@Đặng Văn Tình (ID:62)") {
|
|
t.Errorf("other user mention must be preserved/readable, got: %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "Đây này em") {
|
|
t.Errorf("body lost, got: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// Legacy portals may omit MESSAGE_ORIGINAL — handler must fall back to
|
|
// MESSAGE (the historical behavior) without panicking.
|
|
func TestHandleMessage_GroupFallsBackToMessageWhenOriginalAbsent(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, true)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "chat10",
|
|
MessageType: "chat",
|
|
Message: "[USER=101]Bot[/USER] hello",
|
|
MentionedList: map[string]string{"101": "101"},
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("expected publish")
|
|
}
|
|
if !strings.Contains(msg.Content, "hello") {
|
|
t.Errorf("body lost on MESSAGE-only fallback: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
func TestIsMentioned_MatchesBOTVariant(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
if !ch.isMentioned("[BOT=101]Bot[/BOT] hello") {
|
|
t.Error("[BOT=<id>] variant should also match")
|
|
}
|
|
if !ch.isMentioned("[USER=101]Bot[/USER] hi") {
|
|
t.Error("[USER=<id>] variant should match")
|
|
}
|
|
if ch.isMentioned("[USER=999]Other[/USER] hi") {
|
|
t.Error("mention of a different bot_id must NOT match")
|
|
}
|
|
if ch.isMentioned("plain text no mention") {
|
|
t.Error("plain text must not register a mention")
|
|
}
|
|
}
|
|
|
|
func TestStripMention_OnlyOurs(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
input := "[USER=999]Alice[/USER] hey [USER=101]Bot[/USER] can you help?"
|
|
got := ch.stripMention(input)
|
|
|
|
if strings.Contains(got, "[USER=101]") {
|
|
t.Errorf("our mention not stripped: %q", got)
|
|
}
|
|
if !strings.Contains(got, "[USER=999]Alice[/USER]") {
|
|
t.Errorf("other users' mentions must be preserved: %q", got)
|
|
}
|
|
}
|
|
|
|
// Regression for the `[^\[]*` → `(?s).*?` fix. A mention whose display text
|
|
// contains nested BBCode used to leave the opening `[USER=...]` + raw content
|
|
// in the stripped string, because the character class stopped at the nested
|
|
// `[`. Non-greedy `.*?` with (?s) handles it.
|
|
func TestStripMention_NestedBBCodeInDisplayName(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"bold display name", "[USER=101][b]Boss[/b][/USER] hello"},
|
|
{"italic + icon", "[USER=101][i]Team[/i] [img]foo[/img][/USER] hi"},
|
|
{"multiline display", "[USER=101]Line1\nLine2[/USER] ping"},
|
|
{"two of our mentions", "[USER=101]A[/USER] and [USER=101]B[/USER] done"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := ch.stripMention(tc.input)
|
|
if strings.Contains(got, "[USER=101]") {
|
|
t.Errorf("opening tag not stripped: %q", got)
|
|
}
|
|
if strings.Contains(got, "[/USER]") {
|
|
t.Errorf("closing tag leaked: %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsMentioned_NestedBBCodeCounts(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
// isMentioned is a pure substring check on `[USER=101]`; nested BBCode in
|
|
// the display text should not affect detection.
|
|
if !ch.isMentioned("[USER=101][b]Boss[/b][/USER] hi") {
|
|
t.Error("nested BBCode inside mention should still count as mentioned")
|
|
}
|
|
}
|
|
|
|
func TestMention_ReturnsNilBeforeBotIDSet(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 0, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
// botID 0 means we haven't registered yet — mention helpers should degrade
|
|
// gracefully instead of panicking.
|
|
if got := ch.mention(); got != nil {
|
|
t.Errorf("mention() = %+v; want nil when botID=0", got)
|
|
}
|
|
if ch.isMentioned("[USER=101]x[/USER]") {
|
|
t.Error("isMentioned should be false when botID=0")
|
|
}
|
|
if got := ch.stripMention("hello"); got != "hello" {
|
|
t.Errorf("stripMention should no-op when botID=0, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDispatchEvent_BotDelete_UnregistersAndMarksStopped(t *testing.T) {
|
|
ch, _ := newHandleTestChannel(t, 555, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
// Register so we can observe the unregister side-effect.
|
|
ch.router.RegisterBot(555, ch)
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventBotDelete,
|
|
Params: EventParams{BotID: 555},
|
|
})
|
|
|
|
ch.router.mu.RLock()
|
|
_, exists := ch.router.byBotID[555]
|
|
ch.router.mu.RUnlock()
|
|
if exists {
|
|
t.Error("router must no longer have the bot dispatcher after ONIMBOTDELETE")
|
|
}
|
|
if ch.IsRunning() {
|
|
t.Error("channel should be marked not-running after ONIMBOTDELETE")
|
|
}
|
|
}
|
|
|
|
func TestDispatchEvent_MessageEditAndDeleteIgnored(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageUpdate,
|
|
Params: EventParams{FromUserID: "42", DialogID: "42", Message: "edited text"},
|
|
})
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageDelete,
|
|
Params: EventParams{FromUserID: "42", DialogID: "42"},
|
|
})
|
|
if _, ok := drainOne(mb, 50*time.Millisecond); ok {
|
|
t.Error("edit/delete events must not produce inbound messages in Phase 03")
|
|
}
|
|
}
|
|
|
|
// --- ContactCollector wiring tests (Phase B) -----------------------------
|
|
|
|
// fakeContactStore captures UpsertContact calls so handle_test can verify
|
|
// the channel invokes ContactCollector.EnsureContact the same way Telegram
|
|
// does. Only implements the methods ContactCollector actually exercises.
|
|
type fakeContactStore struct {
|
|
mu sync.Mutex
|
|
upserts []fakeUpsertCall
|
|
}
|
|
|
|
type fakeUpsertCall struct {
|
|
channelType string
|
|
channelInstance string
|
|
senderID string
|
|
userID string
|
|
peerKind string
|
|
contactType string
|
|
threadID string
|
|
}
|
|
|
|
func (f *fakeContactStore) UpsertContact(_ context.Context, channelType, channelInstance, senderID, userID, _, _, peerKind, contactType, threadID, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.upserts = append(f.upserts, fakeUpsertCall{
|
|
channelType: channelType,
|
|
channelInstance: channelInstance,
|
|
senderID: senderID,
|
|
userID: userID,
|
|
peerKind: peerKind,
|
|
contactType: contactType,
|
|
threadID: threadID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeContactStore) ResolveTenantUserID(_ context.Context, _, _ string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeContactStore) ListContacts(_ context.Context, _ store.ContactListOpts) ([]store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeContactStore) CountContacts(_ context.Context, _ store.ContactListOpts) (int, error) {
|
|
return 0, nil
|
|
}
|
|
func (f *fakeContactStore) GetContactsBySenderIDs(_ context.Context, _ []string) (map[string]store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeContactStore) GetContactByID(_ context.Context, _ uuid.UUID) (*store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeContactStore) GetSenderIDsByContactIDs(_ context.Context, _ []uuid.UUID) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeContactStore) MergeContacts(_ context.Context, _ []uuid.UUID, _ uuid.UUID) error {
|
|
return nil
|
|
}
|
|
func (f *fakeContactStore) UnmergeContacts(_ context.Context, _ []uuid.UUID) error { return nil }
|
|
func (f *fakeContactStore) GetContactsByMergedID(_ context.Context, _ uuid.UUID) ([]store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeContactStore) snapshot() []fakeUpsertCall {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]fakeUpsertCall, len(f.upserts))
|
|
copy(out, f.upserts)
|
|
return out
|
|
}
|
|
|
|
func newChannelWithContactCollector(t *testing.T, botID int, requireMention bool) (*Channel, *bus.MessageBus, *fakeContactStore) {
|
|
t.Helper()
|
|
ch, mb := newHandleTestChannel(t, botID, requireMention)
|
|
fakeStore := &fakeContactStore{}
|
|
ch.SetContactCollector(store.NewContactCollector(fakeStore, cache.NewInMemoryCache[bool]()))
|
|
return ch, mb, fakeStore
|
|
}
|
|
|
|
func TestHandleMessage_DM_CollectsSenderContact(t *testing.T) {
|
|
ch, _, cs := newChannelWithContactCollector(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageID: "m-1",
|
|
MessageType: "P", // DM short code
|
|
Message: "hi",
|
|
},
|
|
})
|
|
|
|
calls := cs.snapshot()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected 1 contact upsert for DM, got %d: %+v", len(calls), calls)
|
|
}
|
|
c := calls[0]
|
|
if c.channelType != ch.Type() || c.channelInstance != ch.Name() {
|
|
t.Errorf("wrong channel routing: %+v", c)
|
|
}
|
|
if c.senderID != "42" || c.userID != "42" {
|
|
t.Errorf("sender/userID mismatch; want both '42', got sender=%q userID=%q", c.senderID, c.userID)
|
|
}
|
|
if c.peerKind != "direct" || c.contactType != "user" {
|
|
t.Errorf("peerKind/contactType mismatch; want direct/user, got %q/%q", c.peerKind, c.contactType)
|
|
}
|
|
if c.threadID != "" {
|
|
t.Errorf("DM must not set threadID, got %q", c.threadID)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_Group_CollectsBothSenderAndGroupContact(t *testing.T) {
|
|
ch, _, cs := newChannelWithContactCollector(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "chat10",
|
|
MessageID: "m-2",
|
|
MessageType: "C", // group short code
|
|
Message: "team ping",
|
|
},
|
|
})
|
|
|
|
calls := cs.snapshot()
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 contact upserts (sender + group), got %d: %+v", len(calls), calls)
|
|
}
|
|
|
|
// Call 0 = sender as user contact
|
|
if calls[0].senderID != "42" || calls[0].peerKind != "group" || calls[0].contactType != "user" {
|
|
t.Errorf("call[0] wrong; expected sender=42 peer=group ctype=user, got %+v", calls[0])
|
|
}
|
|
// Call 1 = group as group contact
|
|
if calls[1].senderID != "chat10" || calls[1].peerKind != "group" || calls[1].contactType != "group" {
|
|
t.Errorf("call[1] wrong; expected sender=chat10 peer=group ctype=group, got %+v", calls[1])
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_ChatEntityForwardedAsMetadata proves the bus.InboundMessage
|
|
// carries the entity binding so MCP tools can resolve "this deal" / "this task"
|
|
// without the agent guessing from CHAT_TITLE strings. Plain user-created chats
|
|
// (no entity binding) must NOT add stale or empty metadata keys — downstream
|
|
// readers do `_, ok := metadata["bitrix_chat_entity_id"]` checks.
|
|
func TestHandleMessage_ChatEntityForwardedAsMetadata(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
entityType string
|
|
entityID string
|
|
messageType string
|
|
wantTypeMeta string // "" means key must be absent
|
|
wantIDMeta string
|
|
}{
|
|
{
|
|
name: "crm_deal_chat",
|
|
entityType: "CRM", entityID: "DEAL|2064", messageType: "C",
|
|
wantTypeMeta: "CRM", wantIDMeta: "DEAL|2064",
|
|
},
|
|
{
|
|
name: "tasks_chat_X_type",
|
|
entityType: "TASKS_TASK", entityID: "2704", messageType: "X",
|
|
wantTypeMeta: "TASKS_TASK", wantIDMeta: "2704",
|
|
},
|
|
{
|
|
name: "plain_group_omits_keys",
|
|
entityType: "", entityID: "", messageType: "C",
|
|
wantTypeMeta: "", wantIDMeta: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "chat999",
|
|
MessageID: "m-entity",
|
|
MessageType: tc.messageType,
|
|
Message: "anything",
|
|
MessageOriginal: "[USER=101]Bot[/USER] anything", // pass mention check for groups
|
|
MentionedList: map[string]string{"101": "101"},
|
|
ChatEntityType: tc.entityType,
|
|
ChatEntityID: tc.entityID,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("expected inbound message")
|
|
}
|
|
gotType, hasType := msg.Metadata["bitrix_chat_entity_type"]
|
|
gotID, hasID := msg.Metadata["bitrix_chat_entity_id"]
|
|
if tc.wantTypeMeta == "" {
|
|
if hasType {
|
|
t.Errorf("bitrix_chat_entity_type unexpectedly set: %q", gotType)
|
|
}
|
|
if hasID {
|
|
t.Errorf("bitrix_chat_entity_id unexpectedly set: %q", gotID)
|
|
}
|
|
return
|
|
}
|
|
if gotType != tc.wantTypeMeta {
|
|
t.Errorf("bitrix_chat_entity_type = %q; want %q", gotType, tc.wantTypeMeta)
|
|
}
|
|
if gotID != tc.wantIDMeta {
|
|
t.Errorf("bitrix_chat_entity_id = %q; want %q", gotID, tc.wantIDMeta)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_Blocked_DoesNotCollectContact(t *testing.T) {
|
|
ch, _, cs := newChannelWithContactCollector(t, 101, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
// System message is filtered BEFORE contact collection — must not record.
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageType: "P",
|
|
Message: "user X joined",
|
|
SystemMessage: true,
|
|
},
|
|
})
|
|
// Empty content also filtered — must not record.
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "42",
|
|
DialogID: "42",
|
|
MessageType: "P",
|
|
Message: " ",
|
|
},
|
|
})
|
|
|
|
if n := len(cs.snapshot()); n != 0 {
|
|
t.Errorf("blocked messages must not record contacts, got %d upserts", n)
|
|
}
|
|
}
|
|
|
|
// --- Open Channel (MESSAGE_TYPE="L") gating ---------------------------------
|
|
|
|
func TestIsOpenChannelParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
params *EventParams
|
|
want bool
|
|
}{
|
|
{name: "nil", params: nil, want: false},
|
|
{name: "message type L", params: &EventParams{MessageType: "L"}, want: true},
|
|
{name: "lines entity", params: &EventParams{ChatEntityType: "LINES"}, want: true},
|
|
{name: "case insensitive", params: &EventParams{ChatEntityType: "lines"}, want: true},
|
|
{name: "regular group", params: &EventParams{MessageType: "C"}, want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isOpenChannelParams(tt.params); got != tt.want {
|
|
t.Errorf("isOpenChannelParams() = %v; want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OpenChannel_ConnectorWithoutMentionDropped covers the
|
|
// customer side of the Open Channel gate on a GROUP-style connector
|
|
// (Zalo personal) without an explicit mention: customers chatting amongst
|
|
// themselves in the same Open Channel chat must NOT trigger the agent.
|
|
// The CHAT_ENTITY_ID prefix is what tells us "this connector is
|
|
// group-style and therefore needs the gate" — connectors that don't
|
|
// match the whitelist are handled by sibling tests (no gate, every
|
|
// message forwards).
|
|
func TestHandleMessage_OpenChannel_ConnectorWithoutMentionDropped(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "960",
|
|
DialogID: "chat4878",
|
|
MessageID: "m-ol-customer",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
ChatEntityID: "synity_zalo_personal|20|grp|960",
|
|
Message: "Em hỏi giá sản phẩm A",
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 100*time.Millisecond); ok {
|
|
t.Error("Open Channel connector (whitelisted group-style) messages without mention must be dropped")
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OpenChannel_OneToOneConnectorWithoutMentionForwarded
|
|
// covers the inverse case: a 1-to-1 connector (e.g. Facebook Messenger,
|
|
// Zalo OA) where each customer has their own Open Channel chat. Every
|
|
// customer message is addressed to the bot by construction; gating
|
|
// would silently drop legitimate customer turns. CHAT_ENTITY_ID prefix
|
|
// "facebook" is not in the require-mention whitelist, so the message
|
|
// MUST forward without a mention.
|
|
func TestHandleMessage_OpenChannel_OneToOneConnectorWithoutMentionForwarded(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1074",
|
|
DialogID: "chat5208",
|
|
MessageID: "m-fb-customer",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
ChatEntityID: "facebook|34|36305652112415023|1074",
|
|
Message: "hello, do you have product X in stock?",
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 200*time.Millisecond); !ok {
|
|
t.Error("1-to-1 connector (facebook) customer messages must forward without requiring mention")
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OpenChannel_ConnectorWithMentionForwarded covers the
|
|
// "customer calls the bot" case: a Zalo/FB customer (IS_CONNECTOR=Y) whose
|
|
// upstream populated MENTIONED_LIST with the bot id must trigger a reply,
|
|
// the same as an internal staff member who @-mentions the bot. Connector
|
|
// status alone is no longer a drop signal — mention is the only gate.
|
|
func TestHandleMessage_OpenChannel_ConnectorWithMentionForwarded(t *testing.T) {
|
|
const botID = 1058
|
|
ch, mb := newHandleTestChannel(t, botID, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "960",
|
|
DialogID: "chat4878",
|
|
MessageID: "m-ol-customer-mention",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "alo bot ơi cho hỏi giá",
|
|
MessageOriginal: "[USER=1058]Tiểu Hà[/USER] alo bot ơi cho hỏi giá",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("Open Channel connector WITH @mention must forward to agent")
|
|
}
|
|
if msg.PeerKind != "group" {
|
|
t.Errorf("PeerKind = %q; want group (OL forced into group routing)", msg.PeerKind)
|
|
}
|
|
if !strings.Contains(msg.Content, "alo bot ơi cho hỏi giá") {
|
|
t.Errorf("content stripped wrong: %q", msg.Content)
|
|
}
|
|
if strings.Contains(msg.Content, "[USER=1058]") {
|
|
t.Errorf("bot mention BBCode not stripped: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OpenChannel_InternalStaffWithoutMentionDropped covers the
|
|
// silent-staff case: an internal Bitrix24 user joins the Open Channel session
|
|
// to supervise but does NOT @-mention the bot — bot stays out.
|
|
func TestHandleMessage_OpenChannel_InternalStaffWithoutMentionDropped(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "610",
|
|
DialogID: "chat4878",
|
|
MessageID: "m-ol-staff-quiet",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "haha được rồi",
|
|
FromIsConnector: false,
|
|
},
|
|
})
|
|
if _, ok := drainOne(mb, 100*time.Millisecond); ok {
|
|
t.Error("Open Channel internal staff without @mention must be dropped")
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OpenChannel_InternalStaffMentionForwarded covers the
|
|
// "call the bot" case: internal staff @-mentions the bot — message goes to the
|
|
// agent like a normal group mention.
|
|
func TestHandleMessage_OpenChannel_InternalStaffMentionForwarded(t *testing.T) {
|
|
const botID = 1058
|
|
ch, mb := newHandleTestChannel(t, botID, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "610",
|
|
DialogID: "chat4878",
|
|
MessageID: "m-ol-staff-mention",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "tổng hợp khách này giúp", // stripped form (Bitrix strips group mentions)
|
|
MessageOriginal: "[USER=1058]Tiểu Hà[/USER] tổng hợp khách này giúp",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: false,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("Open Channel internal staff WITH @mention must forward to agent")
|
|
}
|
|
if msg.PeerKind != "group" {
|
|
t.Errorf("PeerKind = %q; want group (OL forced into group routing)", msg.PeerKind)
|
|
}
|
|
if !strings.Contains(msg.Content, "tổng hợp khách này giúp") {
|
|
t.Errorf("content stripped wrong: %q", msg.Content)
|
|
}
|
|
// The bot's own [USER=1058]…[/USER] mention should be stripped from the
|
|
// forwarded content.
|
|
if strings.Contains(msg.Content, "[USER=1058]") {
|
|
t.Errorf("bot mention BBCode not stripped: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OL_ThreeToken_ConnectorCreatesParticipantIdentity verifies a
|
|
// connector message (IS_CONNECTOR=Y) carrying the 3-token "[Name] #uid #msgId"
|
|
// tag mints a per-participant synthetic identity: participant_user_id contains the
|
|
// uid + chat id, the echo prefix is "#msgId" only, and the tag is stripped from the
|
|
// body the agent sees.
|
|
func TestHandleMessage_OL_ThreeToken_ConnectorCreatesParticipantIdentity(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "960",
|
|
DialogID: "chat4878",
|
|
MessageID: "297178", // Bitrix MESSAGE_ID (distinct from the Zalo msgId)
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "[Trung Hee] #111222 #777888 alo bot",
|
|
MessageOriginal: "[USER=1058]bot[/USER] [Trung Hee] #111222 #777888 alo bot",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("connector 3-token message must forward")
|
|
}
|
|
pid := msg.Metadata[MetaKeyParticipantUserID]
|
|
if !strings.Contains(pid, "111222") {
|
|
t.Errorf("participant_user_id = %q; must contain uid 111222", pid)
|
|
}
|
|
if !strings.Contains(pid, "chat4878") {
|
|
t.Errorf("participant_user_id = %q; must contain chat id chat4878", pid)
|
|
}
|
|
// SenderID is the synthetic per-participant id, not the proxy "960".
|
|
if msg.SenderID != pid {
|
|
t.Errorf("SenderID = %q; want synthetic participant id %q", msg.SenderID, pid)
|
|
}
|
|
if msg.SenderID == "960" {
|
|
t.Error("SenderID must not stay the connector proxy id 960")
|
|
}
|
|
// Echo prefix = "#msgId" only (no name, no uid).
|
|
if pfx := msg.Metadata[MetaKeySenderPrefix]; pfx != "#777888" {
|
|
t.Errorf("MetaKeySenderPrefix = %q; want #777888 (msgId only)", pfx)
|
|
}
|
|
// Bitrix MESSAGE_ID for the v2 fields.replyId stays the Bitrix id, NOT the
|
|
// 13-digit Zalo msgId — the echo prefix is the only place the connector msgId
|
|
// is surfaced.
|
|
if mid := msg.Metadata[MetaKeyMessageID]; mid != "297178" {
|
|
t.Errorf("MetaKeyMessageID = %q; want 297178 (Bitrix MESSAGE_ID preserved)", mid)
|
|
}
|
|
if strings.Contains(msg.Content, "[Trung Hee]") || strings.Contains(msg.Content, "#111222") {
|
|
t.Errorf("content must not contain the sender tag: %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "alo bot") {
|
|
t.Errorf("content lost the body: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OL_ForgedTag_OperatorNotGettingParticipantIdentity is the
|
|
// security gate: an operator message (IS_CONNECTOR=N) whose text mimics the
|
|
// 3-token connector tag must NOT mint a participant identity. Live logs showed an
|
|
// operator typing "[Trung Hee] ..." — without the gate they'd be misattributed as
|
|
// that customer.
|
|
func TestHandleMessage_OL_ForgedTag_OperatorNotGettingParticipantIdentity(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1", // operator
|
|
DialogID: "chat4878",
|
|
MessageID: "297179",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "[Trung Hee] #111222 #777888 do nó bị văng",
|
|
MessageOriginal: "[USER=1058]bot[/USER] [Trung Hee] #111222 #777888 do nó bị văng",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: false, // IS_CONNECTOR=N
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("operator message with mention must still forward")
|
|
}
|
|
if pid := msg.Metadata[MetaKeyParticipantUserID]; pid != "" {
|
|
t.Errorf("operator forged tag must not mint participant_user_id, got %q", pid)
|
|
}
|
|
// SenderID stays the operator's real id (not a synthetic), so memory/contact
|
|
// scope tracks the operator, not the impersonated customer.
|
|
if msg.SenderID != "1" {
|
|
t.Errorf("SenderID = %q; want operator id 1 (no synthetic identity)", msg.SenderID)
|
|
}
|
|
// No echo prefix at all should be minted for the operator — senderTag stays
|
|
// the zero value (TagFormatNone) on the non-connector path.
|
|
if pfx := msg.Metadata[MetaKeySenderPrefix]; pfx != "" {
|
|
t.Errorf("operator forged tag must not produce any echo prefix, got %q", pfx)
|
|
}
|
|
// Bitrix MESSAGE_ID for replyId is the genuine webhook id.
|
|
if mid := msg.Metadata[MetaKeyMessageID]; mid != "297179" {
|
|
t.Errorf("MetaKeyMessageID = %q; want 297179", mid)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OL_LegacyOneToken_NoParticipantID verifies a connector
|
|
// message with the legacy single-number "[Name] #msgId" tag degrades safely:
|
|
// no participant identity (uid absent), sender stays the proxy id, and the echo
|
|
// keeps the canonical "[name] #msgId" shape (unchanged from prior behavior).
|
|
func TestHandleMessage_OL_LegacyOneToken_NoParticipantID(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "960",
|
|
DialogID: "chat4878",
|
|
MessageID: "297180",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "[Trung Hee] #7957717404177 alo",
|
|
MessageOriginal: "[USER=1058]bot[/USER] [Trung Hee] #7957717404177 alo",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("legacy connector message must forward")
|
|
}
|
|
if pid := msg.Metadata[MetaKeyParticipantUserID]; pid != "" {
|
|
t.Errorf("legacy 1-number tag must not mint participant_user_id, got %q", pid)
|
|
}
|
|
if msg.SenderID != "960" {
|
|
t.Errorf("SenderID = %q; want proxy 960 (no synthetic for legacy)", msg.SenderID)
|
|
}
|
|
if pfx := msg.Metadata[MetaKeySenderPrefix]; pfx != "[Trung Hee] #7957717404177" {
|
|
t.Errorf("MetaKeySenderPrefix = %q; want canonical legacy echo", pfx)
|
|
}
|
|
if !strings.Contains(msg.Content, "alo") || strings.Contains(msg.Content, "#7957717404177") {
|
|
t.Errorf("legacy tag not stripped from body: %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OL_NoTag_DegradesToGroupLevel verifies a connector message
|
|
// with NO sender tag (e.g. a bare media/sticker relay) degrades safely: no
|
|
// participant identity and no echo prefix, so the consumer falls back to the
|
|
// group-level userID.
|
|
func TestHandleMessage_OL_NoTag_DegradesToGroupLevel(t *testing.T) {
|
|
ch, mb := newHandleTestChannel(t, 1058, false)
|
|
defer resetWebhookRouterForTest()
|
|
|
|
ch.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "960",
|
|
DialogID: "chat4878",
|
|
MessageID: "297181",
|
|
MessageType: "L",
|
|
ChatEntityType: "LINES",
|
|
Message: "chào shop cho hỏi giá",
|
|
MessageOriginal: "[USER=1058]bot[/USER] chào shop cho hỏi giá",
|
|
MentionedList: map[string]string{"1058": "1058"},
|
|
FromIsConnector: true,
|
|
},
|
|
})
|
|
msg, ok := drainOne(mb, 500*time.Millisecond)
|
|
if !ok {
|
|
t.Fatal("untagged connector message must forward")
|
|
}
|
|
if pid := msg.Metadata[MetaKeyParticipantUserID]; pid != "" {
|
|
t.Errorf("untagged message must not mint participant_user_id, got %q", pid)
|
|
}
|
|
if msg.SenderID != "960" {
|
|
t.Errorf("SenderID = %q; want proxy 960", msg.SenderID)
|
|
}
|
|
if pfx := msg.Metadata[MetaKeySenderPrefix]; pfx != "" {
|
|
t.Errorf("untagged message must not set an echo prefix, got %q", pfx)
|
|
}
|
|
}
|