【渠道端到端·接线(agent-72)】 - 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages; 入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。 - cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。 - wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。 - 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动; webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。 【RAG 知识库(agent-71)】 - internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。 ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节 命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。 - gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。 - admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。 - projectindex/store.go:ListProjects/ListChapters。 - 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice), 上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。 【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。 实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。 go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。 遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler 需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
210 lines
6.0 KiB
Go
210 lines
6.0 KiB
Go
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))
|
|
}
|
|
}
|