后端: - 迁移 0007:chat_messages 加 adapter_id 列 + 索引 - history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name()) - channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a - Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留 - 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId - 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言 前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
619 lines
22 KiB
Go
619 lines
22 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
|
)
|
|
|
|
// fakeAdapter is a minimal PlatformAdapter that records outbound sends so the
|
|
// bridge's reply path can be asserted without network I/O.
|
|
type fakeAdapter struct {
|
|
name string
|
|
connected bool
|
|
cfg map[string]any
|
|
sends []adapter.UnifiedMessage
|
|
handler adapter.MessageHandler
|
|
sendErr error
|
|
}
|
|
|
|
func (f *fakeAdapter) Name() string { return f.name }
|
|
func (f *fakeAdapter) Config() map[string]any {
|
|
if f.cfg != nil {
|
|
return f.cfg
|
|
}
|
|
return map[string]any{"bot_token": "configured"}
|
|
}
|
|
func (f *fakeAdapter) Connect(context.Context) error { f.connected = true; return nil }
|
|
func (f *fakeAdapter) Disconnect(context.Context) error {
|
|
f.connected = false
|
|
return nil
|
|
}
|
|
func (f *fakeAdapter) IsConnected() bool { return f.connected }
|
|
func (f *fakeAdapter) HealthCheck(context.Context) error { return nil }
|
|
func (f *fakeAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
|
|
<-ctx.Done()
|
|
}
|
|
func (f *fakeAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
|
|
for _, m := range resp.Messages {
|
|
f.sends = append(f.sends, m)
|
|
}
|
|
return f.sendErr
|
|
}
|
|
func (f *fakeAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
|
|
func (f *fakeAdapter) SetMessageHandler(fn adapter.MessageHandler) { f.handler = fn }
|
|
func (f *fakeAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
|
|
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
|
|
|
|
// stubChatter records the session id and messages passed to the agent loop.
|
|
type stubChatter struct {
|
|
sessionID string
|
|
messages []model.Message
|
|
res *loop.Result
|
|
err error
|
|
}
|
|
|
|
func (s *stubChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
|
|
s.sessionID = sessionID
|
|
s.messages = msgs
|
|
return s.res, s.err
|
|
}
|
|
|
|
// stubSessions records the inbound message and returns a fixed session.
|
|
type stubSessions struct {
|
|
msg adapter.UnifiedMessage
|
|
owner *user.OwnerProfile
|
|
sess session.Session
|
|
err error
|
|
}
|
|
|
|
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
|
|
s.msg = msg
|
|
s.owner = owner
|
|
if s.err != nil {
|
|
return session.Session{}, s.err
|
|
}
|
|
return s.sess, nil
|
|
}
|
|
|
|
// directMessage is an owner-private direct message: the sender is tagged as the
|
|
// owner (structural, not from text), so it exercises the owner-private path. The
|
|
// single-account stub session in newTestBridge reports Owner=true to match, since
|
|
// the production SessionManager derives Owner from the resolved actor.
|
|
func directMessage() adapter.UnifiedMessage {
|
|
return adapter.UnifiedMessage{
|
|
Platform: "telegram",
|
|
ChannelID: "100",
|
|
ChannelType: adapter.ChannelDirect,
|
|
SenderID: "100",
|
|
Sender: adapter.Actor{Role: adapter.RoleOwner},
|
|
Content: "hello",
|
|
ContentType: adapter.ContentText,
|
|
}
|
|
}
|
|
|
|
func newTestBridge(t *testing.T) (*Bridge, *stubChatter, *stubSessions, *fakeAdapter) {
|
|
t.Helper()
|
|
chat := &stubChatter{res: &loop.Result{Text: "hi back"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
|
ad := &fakeAdapter{name: "telegram"}
|
|
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
b.Add(ad)
|
|
return b, chat, sess, ad
|
|
}
|
|
|
|
func TestProcessRunsPipelineAndReplies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, chat, sess, ad := newTestBridge(t)
|
|
ctx := context.Background()
|
|
|
|
b.process(ctx, directMessage())
|
|
|
|
if sess.msg.SenderID != "100" {
|
|
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
|
|
}
|
|
// An owner private chat routes into the single main web session (design/16 §4.4).
|
|
if chat.sessionID != history.MainSessionID {
|
|
t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
|
|
}
|
|
if len(chat.messages) != 1 || chat.messages[0].Role != model.RoleUser {
|
|
t.Fatalf("loop messages = %+v, want single user message", chat.messages)
|
|
}
|
|
if got := chat.messages[0].Content[0].Text; got != "hello" {
|
|
t.Errorf("loop text = %q, want hello", got)
|
|
}
|
|
if len(ad.sends) != 1 {
|
|
t.Fatalf("send count = %d, want 1", len(ad.sends))
|
|
}
|
|
reply := ad.sends[0]
|
|
if reply.Content != "hi back" || reply.ContentType != adapter.ContentText {
|
|
t.Errorf("reply = %+v, want text 'hi back'", reply)
|
|
}
|
|
if reply.ChannelID != "100" || reply.Platform != "telegram" {
|
|
t.Errorf("reply target = %+v, want platform telegram channel 100", reply)
|
|
}
|
|
}
|
|
|
|
func TestProcessSkipsEmptyText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, chat, _, ad := newTestBridge(t)
|
|
msg := directMessage()
|
|
msg.Content = " "
|
|
|
|
b.process(context.Background(), msg)
|
|
|
|
if chat.sessionID != "" {
|
|
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
|
|
}
|
|
if len(ad.sends) != 0 {
|
|
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
|
|
}
|
|
}
|
|
|
|
func TestProcessLoopErrorDoesNotSend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, chat, _, ad := newTestBridge(t)
|
|
chat.res = nil
|
|
chat.err = context.DeadlineExceeded
|
|
|
|
b.process(context.Background(), directMessage())
|
|
|
|
if chat.sessionID != history.MainSessionID {
|
|
t.Errorf("loop should still run for valid text on the main session, session = %q", chat.sessionID)
|
|
}
|
|
if len(ad.sends) != 0 {
|
|
t.Errorf("send count = %d, want 0 on loop error", len(ad.sends))
|
|
}
|
|
}
|
|
|
|
func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, chat, _, ad := newTestBridge(t)
|
|
// A non-owner group message that is addressed (mentioned) gets a response under
|
|
// design/12 §6 策略二, so it still exercises group reply targeting.
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram",
|
|
ChannelID: "-20",
|
|
ChannelType: adapter.ChannelGroup,
|
|
GroupID: "-20",
|
|
GroupName: "Gang",
|
|
SenderID: "9",
|
|
Sender: adapter.Actor{Role: adapter.RoleMember},
|
|
Mentions: []adapter.Mention{{UserID: "9"}},
|
|
Content: "hello all",
|
|
ContentType: adapter.ContentText,
|
|
}
|
|
chat.res = &loop.Result{Text: "group ack"}
|
|
|
|
b.process(context.Background(), msg)
|
|
|
|
if len(ad.sends) != 1 {
|
|
t.Fatalf("send count = %d, want 1", len(ad.sends))
|
|
}
|
|
reply := ad.sends[0]
|
|
if reply.ChannelType != adapter.ChannelGroup || reply.GroupID != "-20" {
|
|
t.Errorf("group reply target = %+v, want group -20", reply)
|
|
}
|
|
}
|
|
|
|
func TestProcessEmptyReplySendsNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, chat, _, ad := newTestBridge(t)
|
|
chat.res = &loop.Result{Text: " "}
|
|
|
|
b.process(context.Background(), directMessage())
|
|
|
|
if len(ad.sends) != 0 {
|
|
t.Errorf("send count = %d, want 0 for empty reply", len(ad.sends))
|
|
}
|
|
}
|
|
|
|
// captureHistory records every Append for asserting the bridge writes owner-private
|
|
// messages into the main web session (design/16 §4.4).
|
|
type captureHistory struct {
|
|
mu sync.Mutex
|
|
appends []history.Message
|
|
}
|
|
|
|
func (c *captureHistory) Append(_ context.Context, msg history.Message) error {
|
|
_, err := c.AppendFull(context.Background(), msg)
|
|
return err
|
|
}
|
|
|
|
func (c *captureHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.appends = append(c.appends, msg)
|
|
return msg, nil
|
|
}
|
|
|
|
func (c *captureHistory) List(context.Context, string, int64, int, string) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureHistory) Recent(context.Context, string, int) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureHistory) ListAdapterLog(context.Context, string, int64, int) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureHistory) ListAllLog(context.Context, int64, int) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
|
|
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (c *captureHistory) ListSessions(context.Context) ([]history.SessionMeta, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *captureHistory) records() []history.Message {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]history.Message(nil), c.appends...)
|
|
}
|
|
|
|
func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
|
hist := &captureHistory{}
|
|
var persisted []history.Message
|
|
b, err := New(Options{
|
|
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
|
History: hist,
|
|
OnPersisted: func(m history.Message) { persisted = append(persisted, m) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
// Register the account adapter that pretends to receive the message, so the
|
|
// persisted stream carries the adapter registration name (e.g. "telegram").
|
|
b.Add(&fakeAdapter{name: "telegram"})
|
|
|
|
msg := directMessage() // Platform = "telegram"
|
|
b.process(context.Background(), msg)
|
|
|
|
recs := hist.records()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
|
}
|
|
// Both land on the main web session and carry the channel platform + adapter.
|
|
if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser ||
|
|
recs[0].Content != "hello" || recs[0].Platform != "telegram" || recs[0].AdapterID != "telegram" {
|
|
t.Fatalf("user record = %+v, want web/telegram/hello/telegram", recs[0])
|
|
}
|
|
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
|
|
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" {
|
|
t.Fatalf("assistant record = %+v, want web/telegram/微信回复/telegram", recs[1])
|
|
}
|
|
// Every persisted message was handed to the broadcast hook.
|
|
if len(persisted) != 2 {
|
|
t.Fatalf("persisted notifications = %d, want 2", len(persisted))
|
|
}
|
|
// And the loop ran against the main session, not the isolated sess-1.
|
|
if chat.sessionID != history.MainSessionID {
|
|
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
|
|
}
|
|
}
|
|
|
|
func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
|
hist := &captureHistory{}
|
|
b, err := New(Options{
|
|
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
|
History: hist,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
// directMessage() → Platform telegram / ChannelID 100 / SenderID 100, so the
|
|
// deviceID is built as <platform>:<channelID>:<senderID> (design/16 §4.5).
|
|
b.process(context.Background(), directMessage())
|
|
|
|
recs := hist.records()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
|
}
|
|
// Both the user turn and the assistant reply carry the derived deviceID and the
|
|
// channel platform, so the Web client can label the source end.
|
|
for i, rec := range recs {
|
|
if rec.DeviceID != "telegram:100:100" {
|
|
t.Fatalf("record[%d] DeviceID = %q, want telegram:100:100", i, rec.DeviceID)
|
|
}
|
|
if rec.Platform != "telegram" {
|
|
t.Fatalf("record[%d] Platform = %q, want telegram", i, rec.Platform)
|
|
}
|
|
}
|
|
if recs[0].Role != history.RoleUser || recs[0].Content != "hello" {
|
|
t.Fatalf("user record = %+v", recs[0])
|
|
}
|
|
if recs[1].Role != history.RoleAssistant || recs[1].Content != "微信回复" {
|
|
t.Fatalf("assistant record = %+v", recs[1])
|
|
}
|
|
}
|
|
|
|
func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "群聊回复"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
|
|
hist := &captureHistory{}
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
// Addressed (mentioned) group message → responds (design/12 §6 策略二), still
|
|
// isolated to the group session.
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
|
GroupID: "-20", GroupName: "Gang", SenderID: "9",
|
|
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
|
Content: "hello all", ContentType: adapter.ContentText,
|
|
}
|
|
b.process(context.Background(), msg)
|
|
|
|
// design/16 §4.6: a group/stranger conversation stays isolated — it is NOT
|
|
// persisted into the main web session, and the loop runs against the isolated
|
|
// session id. Messages land only in the isolated session's own platform log
|
|
// (the adapter log page), so nothing reaches the Web main session.
|
|
recs := hist.records()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("group appends = %d, want 2 (isolated user + assistant)", len(recs))
|
|
}
|
|
for i, rec := range recs {
|
|
if rec.SessionID != "sess-g" {
|
|
t.Fatalf("record[%d] SessionID = %q, want isolated sess-g (not web)", i, rec.SessionID)
|
|
}
|
|
if rec.Platform != "telegram" {
|
|
t.Fatalf("record[%d] Platform = %q, want telegram", i, rec.Platform)
|
|
}
|
|
}
|
|
if chat.sessionID != "sess-g" {
|
|
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
|
|
}
|
|
}
|
|
|
|
func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
|
|
// A stranger's group session that would otherwise be processed.
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
|
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
|
|
"bot_token": "configured",
|
|
"denyGroups": []string{"-100"},
|
|
}}
|
|
var rejected []string
|
|
b, err := New(Options{
|
|
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
|
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
b.Add(ad)
|
|
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram", ChannelID: "-100", ChannelType: adapter.ChannelGroup,
|
|
GroupID: "-100", SenderID: "9", SenderName: "张三",
|
|
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "spam",
|
|
ContentType: adapter.ContentText,
|
|
}
|
|
b.process(context.Background(), msg)
|
|
|
|
// The blacklisted group message never reaches the agent loop and never sends a
|
|
// reply; the rejection reason is surfaced for the audit trail.
|
|
if chat.sessionID != "" || len(chat.messages) != 0 {
|
|
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages)
|
|
}
|
|
if len(ad.sends) != 0 {
|
|
t.Fatalf("blacklisted message sent %d replies, want 0", len(ad.sends))
|
|
}
|
|
if len(rejected) != 1 || rejected[0] != "group_denied" {
|
|
t.Fatalf("rejection reasons = %v, want [group_denied]", rejected)
|
|
}
|
|
}
|
|
|
|
func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}}
|
|
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
|
|
"bot_token": "configured",
|
|
"allowUsers": []string{"111"},
|
|
}}
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{}})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
b.Add(ad)
|
|
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram", ChannelID: "200", ChannelType: adapter.ChannelDirect,
|
|
SenderID: "200", Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hi",
|
|
ContentType: adapter.ContentText,
|
|
}
|
|
b.process(context.Background(), msg)
|
|
|
|
// A direct message from a user NOT in the allow whitelist is dropped; the owner
|
|
// is never affected (this is a stranger).
|
|
if chat.sessionID != "" || len(chat.messages) != 0 {
|
|
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages)
|
|
}
|
|
if len(ad.sends) != 0 {
|
|
t.Fatalf("non-whitelisted user sent %d replies, want 0", len(ad.sends))
|
|
}
|
|
}
|
|
|
|
func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
|
hist := &captureHistory{}
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
// Addressed (mentioned) group message → responds (design/12 §6 策略二), so the
|
|
// third-party boundary and the [第三方 · 张三] prefix are injected.
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
|
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
|
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
|
Content: "hello all", ContentType: adapter.ContentText,
|
|
}
|
|
b.process(context.Background(), msg)
|
|
|
|
// The loop sees a leading third-party boundary system message plus the user
|
|
// message already prefixed with [第三方 · 张三] (the raw content is preserved in
|
|
// history, so no source data is lost).
|
|
if len(chat.messages) != 2 {
|
|
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
|
|
}
|
|
if chat.messages[0].Role != model.RoleSystem || !strings.Contains(chat.messages[0].Content[0].Text, "非管理员账户") {
|
|
t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0])
|
|
}
|
|
user := chat.messages[1]
|
|
if user.SenderName != "张三" || user.Content[0].Text != "[第三方 · 张三] hello all" {
|
|
t.Fatalf("loop user message = %+v, want SenderName 张三 and [第三方 · 张三] prefix", user)
|
|
}
|
|
// History keeps the RAW content and carries the sender name so the adapter log
|
|
// page can label the stream without altering the source message.
|
|
recs := hist.records()
|
|
if len(recs) != 2 || recs[0].Content != "hello all" || recs[0].SenderName != "张三" {
|
|
t.Fatalf("isolated records = %+v, want raw content with SenderName 张三", recs)
|
|
}
|
|
}
|
|
|
|
func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
|
// Owner speaking inside a group: the session stays restricted (mem read-only),
|
|
// but the owner is never labeled as a third party (design/16 §4.6 §1-2).
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
|
|
hist := &captureHistory{}
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
|
GroupID: "-20", SenderID: "owner_uid", SenderName: "主人",
|
|
Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里",
|
|
ContentType: adapter.ContentText,
|
|
}
|
|
b.process(context.Background(), msg)
|
|
|
|
// Restricted context → a third-party boundary system message is still injected.
|
|
if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem {
|
|
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
|
|
}
|
|
// Owner's message is NOT prefixed as third-party and carries no SenderName.
|
|
user := chat.messages[1]
|
|
if user.SenderName != "" || user.Content[0].Text != "我在群里" {
|
|
t.Fatalf("owner group message = %+v, want no SenderName and no [第三方] prefix", user)
|
|
}
|
|
// Owner group speech still lands in the isolated session (not the main web session).
|
|
recs := hist.records()
|
|
if len(recs) != 2 || recs[0].SessionID != "sess-g" {
|
|
t.Fatalf("owner group records = %+v, want isolated sess-g, not web", recs)
|
|
}
|
|
}
|
|
|
|
func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
|
|
t.Parallel()
|
|
// A bridge built without a history store persists nothing but still runs/replies.
|
|
b, chat, _, ad := newTestBridge(t)
|
|
b.process(context.Background(), directMessage())
|
|
if len(ad.sends) != 1 {
|
|
t.Fatalf("send count = %d, want 1", len(ad.sends))
|
|
}
|
|
if chat.sessionID != history.MainSessionID {
|
|
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
|
|
}
|
|
}
|
|
|
|
// newWebhookBridge builds a bare bridge (no adapters) wired with the stubs the
|
|
// other tests use, so a WebhookHandlers test can add adapters explicitly.
|
|
func newWebhookBridge(t *testing.T) *Bridge {
|
|
t.Helper()
|
|
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
|
|
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
|
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestWebhookHandlersCollectsKookWebhook(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := newWebhookBridge(t)
|
|
b.Add(kook.New(kook.Config{BotToken: "t", ReceiveMode: "webhook", WebhookPath: "/webhook/kook"}))
|
|
|
|
handlers := b.WebhookHandlers()
|
|
h, ok := handlers["/webhook/kook"]
|
|
if !ok || h == nil {
|
|
t.Fatalf("kook webhook handler not mounted; got %v", handlers)
|
|
}
|
|
}
|
|
|
|
func TestWebhookHandlersSkipsWSModeKook(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := newWebhookBridge(t)
|
|
b.Add(kook.New(kook.Config{BotToken: "t", ReceiveMode: "ws"}))
|
|
|
|
if got := b.WebhookHandlers(); got != nil {
|
|
t.Fatalf("ws-mode kook exposed a webhook handler, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestWebhookHandlersMountsWebhookChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := newWebhookBridge(t)
|
|
b.Add(webhook.New(webhook.WithConfig(webhook.Config{EndpointPath: "/cb"})))
|
|
|
|
handlers := b.WebhookHandlers()
|
|
h, ok := handlers["/cb"]
|
|
if !ok || h == nil {
|
|
t.Fatalf("webhook channel handler not mounted; got %v", handlers)
|
|
}
|
|
}
|