feat: IM平台列 + deviceID标识 + 会话持久化完善
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- agent-96: chat_messages 加 platform 列(迁移0004) + IM主人私聊落主会话web(带platform) + 前端按平台过滤契约 + message_persisted 带 platform(复用 BroadcastPersisted)
- agent-97: chat_messages 加 device_id 列(迁移0005) + history.Message.DeviceID + Web发消息带deviceId(localStorage getClientId) + IM persistToMain 带 deviceID + 前端气泡展示 SourceLabel + 修主会话基础2个bug(fn scanMessage语法/DDL缺列)
- design/16 §4.4(平台) + §4.5(deviceID) 已入档
- 实机: POST /chat 带 deviceId 读回 user/assistant 均带 deviceId+platform; 后端 build/vet/test 绿 + 前端 build 绿
- 红线: 身份靠 actor 元数据; 禁止 shell 读 .env(JWT冒烟由主会话跑)
This commit is contained in:
Aska committed 2026-09-07 04:51:41 +08:00
1 parent 1bf60c1ae4
commit 37e98ec584
26 files changed
+828 -116

No files matched your search

@@ -24,6 +24,7 @@ import (
"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/logger"
"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"
@@ -72,21 +73,30 @@ type SessionStore interface {
// Options carries the bridge's injected dependencies. Loop and Sessions are
// required; Owner may be nil (then no account is recognized as owner, the safe
// default-deny posture from design/11 §0).
// default-deny posture from design/11 §0). History and OnPersisted are optional
// (design/16 §4.4): when a history store is wired, owner-private messages are
// routed into the main web session and tagged with the channel platform, and every
// persisted message is handed to OnPersisted so the gateway can broadcast a
// `message_persisted` event; when both are absent the bridge runs exactly as
// before (isolated sess-xxx, no chat_messages writes).
type Options struct {
Log *slog.Logger
Loop Chatter
Sessions SessionStore
Owner *user.OwnerProfile
Log *slog.Logger
Loop Chatter
Sessions SessionStore
Owner *user.OwnerProfile
History history.HistoryStore
OnPersisted func(history.Message)
}
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
// is safe for concurrent use.
type Bridge struct {
log *slog.Logger
run Chatter
sessions SessionStore
owner *user.OwnerProfile
log *slog.Logger
run Chatter
sessions SessionStore
owner *user.OwnerProfile
history history.HistoryStore
onPersisted func(history.Message)
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
@@ -114,14 +124,16 @@ func New(opts Options) (*Bridge, error) {
log = logger.New(logger.Options{Out: nil})
}
return &Bridge{
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
sessions: opts.Sessions,
owner: opts.Owner,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
sessions: opts.Sessions,
owner: opts.Owner,
history: opts.History,
onPersisted: opts.OnPersisted,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
}
@@ -353,28 +365,52 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
// main web session (shared across platforms) and persisted to chat_messages with
// the channel platform, so the Web client mirrors it; group chats and strangers
// keep their isolated sess-xxx context and are NOT persisted to the main session.
sessID := sess.SessionID
isOwnerPrivate := !sess.IsGroup && sess.Owner
platform := ""
if isOwnerPrivate {
sessID = history.MainSessionID
platform = msg.Platform
}
// Persist the inbound user message into the main web session before the turn, so
// the Web client sees it even if the channel reply fails (design/16 §4.4).
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg))
}
// Thread the resolved isolation boundary into the loop's memory gates: the
// owner's private conversation reads+writes memory, group chats and strangers
// are read-only isolation (design/02 §3, design/11 §2).
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sess.SessionID, []model.Message{
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, []model.Message{
model.TextMessage(model.RoleUser, text),
})
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", sess.SessionID, "error", err)
"session", sessID, "error", err)
return
}
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sess.SessionID)
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
return
}
// Persist the assistant reply into the main web session (design/16 §4.4) and
// broadcast, then deliver it back to the channel — so the Web client mirrors the
// IM reply too.
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg))
}
if err := b.sendReply(ctx, msg, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", sess.SessionID)
"platform", msg.Platform, "session", sessID)
return
}
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
@@ -422,3 +458,20 @@ func cfgStr(m map[string]any, key string) string {
}
return ""
}
// deviceIDFor builds the client deviceID tag for an inbound IM message
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
// explicit deviceId field yet, so the local config id and the user platform uid are
// taken from the normalized channel binding: ChannelID stands in for the local
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
//
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
// bridge gains a local config id), replace the ChannelID stand-in here and return
// that value directly.
func deviceIDFor(msg adapter.UnifiedMessage) string {
if msg.SenderID != "" {
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
}
return msg.Platform + ":" + msg.ChannelID
}
@@ -2,10 +2,12 @@ package channelbridge
import (
"context"
"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"
@@ -113,8 +115,9 @@ func TestProcessRunsPipelineAndReplies(t *testing.T) {
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)
// 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)
@@ -160,8 +163,8 @@ func TestProcessLoopErrorDoesNotSend(t *testing.T) {
b.process(context.Background(), directMessage())
if chat.sessionID != "sess-1" {
t.Errorf("loop should still run for valid text, session = %q", chat.sessionID)
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))
@@ -207,3 +210,165 @@ func TestProcessEmptyReplySendsNothing(t *testing.T) {
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) 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)
}
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.
if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser ||
recs[0].Content != "hello" || recs[0].Platform != "telegram" {
t.Fatalf("user record = %+v, want web/telegram/hello", recs[0])
}
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" {
t.Fatalf("assistant record = %+v, want web/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)
}
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", Content: "hello all",
ContentType: adapter.ContentText,
}
b.process(context.Background(), msg)
// Group chat stays isolated: nothing is written to the main web session and the
// loop runs against the isolated session id.
if recs := hist.records(); len(recs) != 0 {
t.Fatalf("group appends = %+v, want none persisted", recs)
}
if chat.sessionID != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
}
}
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)
}
}
@@ -0,0 +1,55 @@
package channelbridge
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// This file holds the channelbridge's chat-history persistence hooks (design/16
// §4.4): an owner private chat is routed INTO the single main web session and
// tagged with the channel platform, then broadcast so the Web client mirrors it
// without a refresh. Kept separate from channelbridge.go so the bridge's inbound
// pipeline file stays under the module size bound.
// SetOnPersisted wires (or replaces) the transport hook called after an
// owner-private message is persisted into the main web session. The gateway is
// built after the bridge in the composition root, so the hook is attached post-New
// through this setter; it is a no-op-safe call and is safe for concurrent use with
// in-flight processing.
func (b *Bridge) SetOnPersisted(fn func(history.Message)) {
b.mu.Lock()
defer b.mu.Unlock()
b.onPersisted = fn
}
// notifyPersisted hands a just-persisted message to the transport hook when one is
// wired, so the gateway can broadcast `message_persisted` for cross-end sync.
func (b *Bridge) notifyPersisted(msg history.Message) {
b.mu.RLock()
fn := b.onPersisted
b.mu.RUnlock()
if fn != nil {
fn(msg)
}
}
// persistToMain writes one message into the main web session with an explicit source
// platform and client deviceID, then notifies the transport hook so Web clients receive
// a `message_persisted` event without a refresh (design/16 §4.4). deviceID is the
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
// for IM. It is a no-op when no history store is wired, so the bridge runs unchanged
// without storage.
func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID string) {
if b.history == nil {
return
}
full, err := b.history.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID,
})
if err != nil {
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID)
return
}
b.notifyPersisted(full)
}