package channelbridge import ( "context" "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/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 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 sends []adapter.UnifiedMessage handler adapter.MessageHandler sendErr error } func (f *fakeAdapter) Name() string { return f.name } func (f *fakeAdapter) Config() map[string]any { 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 } func directMessage() adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "100", ChannelType: adapter.ChannelDirect, SenderID: "100", 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) } if chat.sessionID != "sess-1" { t.Errorf("loop session id = %q, want sess-1", chat.sessionID) } 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 != "sess-1" { t.Errorf("loop should still run for valid text, 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) msg := adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", GroupName: "Gang", SenderID: "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)) } }