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 注释被转弯引号
283 lines
12 KiB
Go
283 lines
12 KiB
Go
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 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
|