feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

A1 情绪接线:
- loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步
- persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700)
- 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发
- cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd
- 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟
- Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照)
- 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环

拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批

回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
This commit is contained in:
AskaEth committed 2026-09-30 08:51:38 +08:00
1 parent 610b330025
commit 1a85de555d
27 files changed
+1946 -594

No files matched your search

@@ -0,0 +1,72 @@
package channelbridge
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the per-account allow/deny black-or-white lists (design/16 §4.6
// §6) enforced at the head of the inbound pipeline, plus the config readers the
// list matching depends on.
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
// against allowGroups/denyGroups; direct messages are matched by the sender's user
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
// deny list applies); a non-empty allow list becomes a white list. The owner is
// never blacklisted (the caller skips this for owner messages).
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
if a == nil {
return false, ""
}
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
if msg.IsGroup() {
if sliceContains(denyGroups, msg.GroupID) {
return true, "group_denied"
}
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
return true, "group_not_allowed"
}
return false, ""
}
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
if sliceContains(denyUsers, uid) {
return true, "user_denied"
}
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
return true, "user_not_allowed"
}
return false, ""
}
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
// The lists are stored as plain []string values (set by the multi-account wiring);
// an absent key yields an empty list.
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
if cfg == nil {
return nil, nil, nil, nil
}
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
}
// cfgStrings reads a []string field from an adapter config map, returning nil when
// absent or of a different type.
func cfgStrings(m map[string]any, key string) []string {
if m == nil {
return nil
}
switch v := m[key].(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
@@ -14,11 +14,9 @@ package channelbridge
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand"
"net/http"
"strings"
"sync"
"time"
@@ -257,560 +255,3 @@ func New(opts Options) (*Bridge, error) {
sem: make(chan struct{}, maxConcurrent),
}, nil
}
// Add registers an adapter by its Name. It is safe to call before Start; adding
// after Start has no effect on already-running adapters.
func (b *Bridge) Add(a adapter.PlatformAdapter) {
if a == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.adapters[a.Name()] = a
}
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
// path from the adapter's config. The gateway mounts these to receive external
// webhook callbacks into the runtime. A nil map is returned when no adapter
// provides an inbound handler.
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]http.Handler)
for _, a := range b.adapters {
wh, ok := a.(webhookAdapter)
if !ok {
continue
}
path := wh.WebhookPath()
if path == "" {
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
continue
}
h := wh.InboundHandler()
if h == nil {
continue
}
out[path] = h
}
if len(out) == 0 {
return nil
}
return out
}
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
}
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
// message processing to finish. It is idempotent.
func (b *Bridge) Stop(ctx context.Context) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.closed = true
cancel := b.cancel
b.mu.Unlock()
cancel()
for name, a := range b.snapshot() {
if err := a.Disconnect(ctx); err != nil {
b.log.Warn("channel disconnect", "channel", name, "error", err)
}
}
b.wg.Wait()
b.log.Info("channel bridge stopped")
}
// IsConnected reports whether the named adapter is currently connected.
func (b *Bridge) IsConnected(name string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.connected[name]
}
// ConnectedCount reports how many adapters are currently connected.
func (b *Bridge) ConnectedCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
n := 0
for _, ok := range b.connected {
if ok {
n++
}
}
return n
}
// snapshot returns a copy of the registered adapters for a lock-free iteration.
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
for _, a := range b.adapters {
out = append(out, a)
}
return out
}
// configured reports whether the adapter carries enough configuration to attempt
// a Connect. It inspects the adapter's own Config() snapshot for the credential
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
// when no bot token is present.
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
cfg := a.Config()
switch basePlatform(a.Name()) {
case "telegram", "weixin-ilink", "kook":
return cfgStr(cfg, "bot_token") != ""
case "qq-obv11":
if cfgStr(cfg, "mode") == "client" {
return cfgStr(cfg, "remoteURL") != ""
}
return cfgStr(cfg, "listenAddr") != ""
case "webhook":
// Inbound-only: no external credential is required to hold the session.
return true
default:
return true
}
}
// basePlatform returns the bare platform name of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
// reply-routing fallback treat every account of a platform identically.
func basePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
// for the adapter that received a message, or "" when a is nil. The single-account
// convenience path (process) and a platform with no registered adapter can pass a nil
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
// dereferencing a nil adapter.
func adapterName(a adapter.PlatformAdapter) string {
if a == nil {
return ""
}
return a.Name()
}
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
// The adapter is carried explicitly so the pipeline knows which account received
// the message (multi-account reply routing + per-account allow/deny, design/16
// §4.6 §5).
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.mu.Unlock()
if err := msg.Validate(); err != nil {
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
return
}
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
// messages bypass it entirely (the owner is never throttled or pruned); every
// other message passes a sliding-window rate limit (策略三) and the bounded
// pending set whose saturation triggers the review model to prune noise (策略四).
// Dropped messages are audited through the gate's OnPruned hook (wired to
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() {
verdict := b.gateFor(a).Admit(ctx, msg)
if !verdict.Proceed {
b.log.Warn("inbound message dropped by flow gate",
"platform", msg.Platform, "channel", msg.ChannelID,
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
return
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// Add the worker under the same lock that Stop uses to set closed, so a
// concurrent Stop either sees our Add before it waits, or we observe closed
// and bail without leaking a goroutine.
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
}
// process runs the inbound pipeline using the adapter whose platform matches the
// message (single-account convenience path; the adapter may be a bare
// platform-named registration).
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom runs the full inbound pipeline against the named account adapter:
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
// agent loop with the chat text, and send the loop's reply back through the same
// adapter that received the message. Identity is resolved structurally by the
// session layer; the bridge never reads message content to decide who the sender
// is (a project red line).
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
text := strings.TrimSpace(msg.Content)
if text == "" {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
// the pipeline, before a session is resolved or the loop is driven, so a
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
// owner is always allowed (structural, via the resolved actor).
actor := session.ResolveActor(msg, b.owner)
if actor.IsOwner() {
// fall through: owner messages are never blacklisted
} else if denied, reason := b.blacklisted(msg, a); denied {
b.log.Warn("inbound message blacklisted, dropped",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
if b.onRejected != nil {
b.onRejected(msg, reason)
}
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
// non-owner group/stranger message that the cheap light judgement deems not
// worth a response is registered to its isolated session log and silenced — it
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "reason", reason)
return
}
}
// 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 their own platform log.
sessID := sess.SessionID
isOwnerPrivate := !sess.IsGroup && sess.Owner
platform := ""
if isOwnerPrivate {
sessID = history.MainSessionID
platform = msg.Platform
}
// The third-party boundary only applies in a restricted (group/stranger)
// context: memory is read-only (MemWritable=false), and the sender name is
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
// resolved actor decides who is the owner vs a third party structurally — never
// from prompt text.
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// Persist the inbound user message before the turn so it is durable even if the
// channel reply fails. Owner-private messages land in the main web session and
// are broadcast to the Web client; restricted messages land in their isolated
// session (the adapter log page) and are broadcast as a `channel_persisted` event
// (never into the main/`web` session or the web chat).
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", sessID, "error", err)
return
}
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
return
}
// Persist the assistant reply (owner-private → main session + message_persisted
// broadcast; restricted → isolated log + channel_persisted broadcast) then
// deliver it back to the channel.
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", sessID)
return
}
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
}
}
// buildLoopMessages builds the model-visible message list for one inbound turn. It
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
// the model can see which platform and conversation the message came from (design/16
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
// a restricted (group/stranger) context it additionally prepends a single system
// message that states the third-party identity boundary — "every IM non-admin
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
// untouched (a copy is built with the prefix), so persisted content stays consistent
// with what the source platform delivered.
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
msg := model.TextMessage(model.RoleUser, text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
// The speaker is carried as structured metadata, NOT woven into the user text
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
msg.SenderName = senderName
}
// origin system line: conversation origin + identity-trust rule, one per turn.
origin := originSystemText(src, actor)
if !env.Restricted() {
if origin != "" {
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
}
return []model.Message{msg}
}
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
var sys string
switch {
case origin != "" && thirdPartyBoundaryPrompt != "":
sys = origin + "\n" + thirdPartyBoundaryPrompt
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
}
// sendReply delivers the loop's text back to the channel's source conversation
// through the account adapter that received the message (multi-account safe,
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
// the adapter resolves the same peer the message came from, and it exits through the
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
if a == nil {
a = b.adapter(in.Platform)
}
if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
}
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text)
}
// adapter returns the registered adapter for a platform name, or nil.
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
return b.adapters[platform]
}
// cfgStr reads a string field from an adapter config map, returning "" when
// absent.
func cfgStr(m map[string]any, key string) string {
if m == nil {
return ""
}
if s, ok := m[key].(string); ok {
return s
}
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
}
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
// (group-chat / stranger) context so the model knows the identity boundary. It is
// only injected when the session is structurally restricted (design/16 §4.6 §1);
// the LLM never decides who is a third party — that was already resolved from the
// actor metadata before this message exists.
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
// senderDisplayName returns the human-readable sender name used to label a
// third-party message. It prefers the resolved actor's display name (which the
// channel layer derived from channel metadata + the raw SenderName), falling back
// to the message's own SenderName, so an empty SenderName always means "the
// owner/unlabeled" rather than a nickname that resolved to nothing.
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if actor.DisplayName != "" {
return actor.DisplayName
}
return msg.SenderName
}
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
// against allowGroups/denyGroups; direct messages are matched by the sender's user
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
// deny list applies); a non-empty allow list becomes a white list. The owner is
// never blacklisted (the caller skips this for owner messages).
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
if a == nil {
return false, ""
}
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
if msg.IsGroup() {
if sliceContains(denyGroups, msg.GroupID) {
return true, "group_denied"
}
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
return true, "group_not_allowed"
}
return false, ""
}
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
if sliceContains(denyUsers, uid) {
return true, "user_denied"
}
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
return true, "user_not_allowed"
}
return false, ""
}
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
// The lists are stored as plain []string values (set by the multi-account wiring);
// an absent key yields an empty list.
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
if cfg == nil {
return nil, nil, nil, nil
}
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
}
// cfgStrings reads a []string field from an adapter config map, returning nil when
// absent or of a different type.
func cfgStrings(m map[string]any, key string) []string {
if m == nil {
return nil
}
switch v := m[key].(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
// sliceContains reports whether s is present in list.
func sliceContains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// firstNonEmptyStr returns a when non-empty, otherwise b.
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
+173
View File
@@ -0,0 +1,173 @@
package channelbridge
import (
"context"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the adapter registry and the connect lifecycle of the channel
// bridge (design/08 §0): adapters are registered through Add, dialed by Start
// (each within connectTimeout), torn down by Stop, and skipped by the credential
// gate when they carry no usable configuration.
// Add registers an adapter by its Name. It is safe to call before Start; adding
// after Start has no effect on already-running adapters.
func (b *Bridge) Add(a adapter.PlatformAdapter) {
if a == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.adapters[a.Name()] = a
}
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
// path from the adapter's config. The gateway mounts these to receive external
// webhook callbacks into the runtime. A nil map is returned when no adapter
// provides an inbound handler.
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]http.Handler)
for _, a := range b.adapters {
wh, ok := a.(webhookAdapter)
if !ok {
continue
}
path := wh.WebhookPath()
if path == "" {
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
continue
}
h := wh.InboundHandler()
if h == nil {
continue
}
out[path] = h
}
if len(out) == 0 {
return nil
}
return out
}
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
}
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
// message processing to finish. It is idempotent.
func (b *Bridge) Stop(ctx context.Context) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.closed = true
cancel := b.cancel
b.mu.Unlock()
cancel()
for name, a := range b.snapshot() {
if err := a.Disconnect(ctx); err != nil {
b.log.Warn("channel disconnect", "channel", name, "error", err)
}
}
b.wg.Wait()
b.log.Info("channel bridge stopped")
}
// IsConnected reports whether the named adapter is currently connected.
func (b *Bridge) IsConnected(name string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.connected[name]
}
// ConnectedCount reports how many adapters are currently connected.
func (b *Bridge) ConnectedCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
n := 0
for _, ok := range b.connected {
if ok {
n++
}
}
return n
}
// snapshot returns a copy of the registered adapters for a lock-free iteration.
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
for _, a := range b.adapters {
out = append(out, a)
}
return out
}
// configured reports whether the adapter carries enough configuration to attempt
// a Connect. It inspects the adapter's own Config() snapshot for the credential
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
// when no bot token is present.
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
cfg := a.Config()
switch basePlatform(a.Name()) {
case "telegram", "weixin-ilink", "kook":
return cfgStr(cfg, "bot_token") != ""
case "qq-obv11":
if cfgStr(cfg, "mode") == "client" {
return cfgStr(cfg, "remoteURL") != ""
}
return cfgStr(cfg, "listenAddr") != ""
case "webhook":
// Inbound-only: no external credential is required to hold the session.
return true
default:
return true
}
}
// cfgStr reads a string field from an adapter config map, returning "" when
// absent.
func cfgStr(m map[string]any, key string) string {
if m == nil {
return ""
}
if s, ok := m[key].(string); ok {
return s
}
return ""
}
// adapter returns the registered adapter for a platform name, or nil.
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
return b.adapters[platform]
}
@@ -0,0 +1,62 @@
package channelbridge
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the small package-level helpers shared across the bridge files:
// platform/account naming, the sender label, and generic slice readers.
// basePlatform returns the bare platform name of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
// reply-routing fallback treat every account of a platform identically.
func basePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
// for the adapter that received a message, or "" when a is nil. The single-account
// convenience path (process) and a platform with no registered adapter can pass a nil
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
// dereferencing a nil adapter.
func adapterName(a adapter.PlatformAdapter) string {
if a == nil {
return ""
}
return a.Name()
}
// senderDisplayName returns the human-readable sender name used to label a
// third-party message. It prefers the resolved actor's display name (which the
// channel layer derived from channel metadata + the raw SenderName), falling back
// to the message's own SenderName, so an empty SenderName always means "the
// owner/unlabeled" rather than a nickname that resolved to nothing.
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if actor.DisplayName != "" {
return actor.DisplayName
}
return msg.SenderName
}
// sliceContains reports whether s is present in list.
func sliceContains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// firstNonEmptyStr returns a when non-empty, otherwise b.
func firstNonEmptyStr(a, b string) string {
if a != "" {
return a
}
return b
}
+282
View File
@@ -0,0 +1,282 @@
package channelbridge
import (
"context"
"errors"
"strings"
"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"
)
// This file holds the inbound path of the channel bridge (design/08 §1-§2): the
// per-adapter read loop, the bounded inbound callback, and the full
// session→actor→loop→reply pipeline including the structural identity boundary.
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
// The adapter is carried explicitly so the pipeline knows which account received
// the message (multi-account reply routing + per-account allow/deny, design/16
// §4.6 §5).
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return
}
b.mu.Unlock()
if err := msg.Validate(); err != nil {
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
return
}
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
// messages bypass it entirely (the owner is never throttled or pruned); every
// other message passes a sliding-window rate limit (策略三) and the bounded
// pending set whose saturation triggers the review model to prune noise (策略四).
// Dropped messages are audited through the gate's OnPruned hook (wired to
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() {
verdict := b.gateFor(a).Admit(ctx, msg)
if !verdict.Proceed {
b.log.Warn("inbound message dropped by flow gate",
"platform", msg.Platform, "channel", msg.ChannelID,
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
return
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// Add the worker under the same lock that Stop uses to set closed, so a
// concurrent Stop either sees our Add before it waits, or we observe closed
// and bail without leaking a goroutine.
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
}
// process runs the inbound pipeline using the adapter whose platform matches the
// message (single-account convenience path; the adapter may be a bare
// platform-named registration).
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom runs the full inbound pipeline against the named account adapter:
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
// agent loop with the chat text, and send the loop's reply back through the same
// adapter that received the message. Identity is resolved structurally by the
// session layer; the bridge never reads message content to decide who the sender
// is (a project red line).
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
text := strings.TrimSpace(msg.Content)
if text == "" {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
// the pipeline, before a session is resolved or the loop is driven, so a
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
// owner is always allowed (structural, via the resolved actor).
actor := session.ResolveActor(msg, b.owner)
if actor.IsOwner() {
// fall through: owner messages are never blacklisted
} else if denied, reason := b.blacklisted(msg, a); denied {
b.log.Warn("inbound message blacklisted, dropped",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
if b.onRejected != nil {
b.onRejected(msg, reason)
}
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
// non-owner group/stranger message that the cheap light judgement deems not
// worth a response is registered to its isolated session log and silenced — it
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "reason", reason)
return
}
}
// 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 their own platform log.
sessID := sess.SessionID
isOwnerPrivate := !sess.IsGroup && sess.Owner
platform := ""
if isOwnerPrivate {
sessID = history.MainSessionID
platform = msg.Platform
}
// The third-party boundary only applies in a restricted (group/stranger)
// context: memory is read-only (MemWritable=false), and the sender name is
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
// resolved actor decides who is the owner vs a third party structurally — never
// from prompt text.
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// Persist the inbound user message before the turn so it is durable even if the
// channel reply fails. Owner-private messages land in the main web session and
// are broadcast to the Web client; restricted messages land in their isolated
// session (the adapter log page) and are broadcast as a `channel_persisted` event
// (never into the main/`web` session or the web chat).
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleUser, text, "", platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", sessID, "error", err)
return
}
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
return
}
// Persist the assistant reply (owner-private → main session + message_persisted
// broadcast; restricted → isolated log + channel_persisted broadcast) then
// deliver it back to the channel.
if isOwnerPrivate {
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}
if err := b.sendReply(ctx, msg, a, reply); err != nil {
if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", sessID)
return
}
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
}
}
// 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
}
// buildLoopMessages builds the model-visible message list for one inbound turn. It
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
// the model can see which platform and conversation the message came from (design/16
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
// a restricted (group/stranger) context it additionally prepends a single system
// message that states the third-party identity boundary — "every IM non-admin
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
// untouched (a copy is built with the prefix), so persisted content stays consistent
// with what the source platform delivered.
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
msg := model.TextMessage(model.RoleUser, text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
// The speaker is carried as structured metadata, NOT woven into the user text
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
msg.SenderName = senderName
}
// origin system line: conversation origin + identity-trust rule, one per turn.
origin := originSystemText(src, actor)
if !env.Restricted() {
if origin != "" {
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
}
return []model.Message{msg}
}
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
var sys string
switch {
case origin != "" && thirdPartyBoundaryPrompt != "":
sys = origin + "\n" + thirdPartyBoundaryPrompt
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
}
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
// (group-chat / stranger) context so the model knows the identity boundary. It is
// only injected when the session is structurally restricted (design/16 §4.6 §1);
// the LLM never decides who is a third party — that was already resolved from the
// actor metadata before this message exists.
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
@@ -0,0 +1,29 @@
package channelbridge
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// This file holds the outbound reply path (design/16 §4.6 §5): the loop's text is
// delivered back to the source conversation through the same account adapter that
// received the message.
// sendReply delivers the loop's text back to the channel's source conversation
// through the account adapter that received the message (multi-account safe,
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
// the adapter resolves the same peer the message came from, and it exits through the
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
if a == nil {
a = b.adapter(in.Platform)
}
if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
}
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text)
}
+7 -4
View File
@@ -57,14 +57,17 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
// for IM. adapterID is the account adapter registration name that received the message
// (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream;
// it is empty for Web-originated messages. 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, adapterID string) {
// it is empty for Web-originated messages. mood is the persona's mood for that turn
// (design/01 §2 情绪状态机), carried on the assistant reply only (design/16 §3.3: the
// assistant message carries mode/mood); a user message and an untracked mood pass "".
// 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, mood, platform, deviceID, adapterID 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, AdapterID: adapterID, Mode: modeChat,
SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
})
if err != nil {
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID)
+1 -1
View File
@@ -73,7 +73,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// §4.4 + §4.6.1). Without this the model would not see what it proactively said, so
// a follow-up owner reply would feel incoherent (the message "loses" its history).
// It is the same mirror path as owner-private IM replies, scoped to MainSessionID.
b.persistToMain(ctx, history.RoleAssistant, msg.Content, target.platform,
b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", target.platform,
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
target.platform)
return nil
+103
View File
@@ -0,0 +1,103 @@
package gateway
import (
"context"
"io"
"log/slog"
"testing"
"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"
)
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
// test can assert the mood the loop reported is what gets persisted.
type moodRunner struct {
mood string
}
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
}
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
// path: the mood the loop's turn-boundary hook reported lands on the persisted
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
// `message_persisted` and history replay carry real data instead of an always
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
// mood to the assistant reply).
func TestOrchestratorPersistsTurnMood(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: "happy"}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
if !sr.Accepted || res == nil {
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
}
wire := toHistoryMessage(recs[1])
if wire.Mood != "happy" {
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
}
if recs[0].Mood != "" {
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
}
}
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
// empty rather than being fabricated.
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: ""}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2", len(recs))
}
if recs[1].Mood != "" {
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
}
}
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
// affection ride along as extra detail.
func TestServerBroadcastMood(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
c := newFakeConn(env.srv.hub, 32)
env.srv.hub.register(c)
env.srv.BroadcastMood("cyrene", "happy", 60)
ev := readFakeFrame(t, c)
if ev.Type != "mood" {
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
}
if got, ok := data["affection"].(float64); !ok || got != 60 {
t.Fatalf("frame affection = %v, want 60", data["affection"])
}
}
+24
View File
@@ -681,6 +681,30 @@ func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool str
})
}
// wsMoodEvent is the WS event type carrying the persona's mood after a turn
// (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing
// `mood` handler (ws.ts) reads data.mood and shows the 心情 badge.
const wsMoodEvent = "mood"
// BroadcastMood fans one emotion change to every Web client as a `mood` frame
// (design/07 §3). It is called only when the mood actually changed — the
// machinery in the composition root is the gate — so the badge does not flicker
// with an unchanged value on every turn. data matches what the frontend `mood`
// handler reads ({mood}); affection/personaId ride along as the honest extra
// detail (affection is the persona's current 好感度, personaId the persona whose
// mood moved) and are ignored by clients that do not render them. The mood is a
// persona-level value, not a per-session one, so no sessionId is attached.
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
s.hub.broadcast(wsEvent{
Type: wsMoodEvent,
Data: map[string]any{
"mood": mood,
"personaId": personaID,
"affection": affection,
},
})
}
// sourcedLog returns a logger tagged with source "gateway", substituting a
// discard logger for nil so Server can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
+6 -3
View File
@@ -78,14 +78,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
// the reply with the client that sent the turn (design/16 §4.5) and mode is the
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when
// empty. It is a no-op when no history store is wired or the turn produced no text
// (interrupted/errored turns leave res nil). The write runs under a fresh
// empty. Mood is the persona's mood for that turn (design/01 §2 情绪状态机), taken
// off the loop result, so the stored/DTO/`message_persisted` mood is real data
// rather than a permanently empty column; a turn with no mood tracking stores an
// empty mood. It is a no-op when no history store is wired or the turn produced no
// text (interrupted/errored turns leave res nil). The write runs under a fresh
// background context so a canceled turn context never aborts it.
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) {
if o.history == nil || res == nil || res.Text == "" {
return
}
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, DeviceID: deviceID, Mode: modeOrDefault(mode)})
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode)})
if err != nil {
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
return