Files
Cyrene_v2/internal/services/channelbridge/inbound.go
T
AskaEth a51aadeef4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。
- 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected
- 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留
- 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期)
- Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
2026-10-03 18:26:27 +08:00

352 lines
15 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.
//
// When the read path returns with readCtx still alive, the adapter stopped
// reading on its own — today that is the weixin-iLink rejecting the session
// (errcode=-14, see plugins/channels/weixin_ilink/session_state.go), which used to
// leave the bridge claiming the channel was connected while it could no longer
// deliver anything. A cancelled readCtx is a deliberate stop (bridge Stop or a
// hot reload of this name) and is handled by the caller that cancelled it.
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)
})
if readCtx.Err() == nil {
b.markReadStopped(a)
}
}
// markReadStopped records that an adapter's read loop ended by itself while the
// adapter reports no live session, so the per-name accounting (IsConnected /
// ConnectedCount) stops claiming a channel that cannot deliver. It only ever
// clears the flag — never sets it — so it cannot make a connected channel look
// alive, and it is a no-op when the adapter still reports a live session (a
// reconnect that already happened is never undone by an older loop's exit).
func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) {
if a.IsConnected() {
return
}
name := a.Name()
b.mu.Lock()
defer b.mu.Unlock()
if !b.connected[name] {
return
}
b.connected[name] = false
b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name)
}
// 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() && b.blacklistDrop(msg, a) {
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
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess)
}
// blacklistDrop applies the black-or-white list to a non-owner message, reporting
// whether it must be dropped (design/16 §4.6 §6). A dropped message is audited
// through the OnRejected hook so the decision is visible in the panel.
func (b *Bridge) blacklistDrop(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) bool {
denied, reason := b.blacklisted(msg, a)
if !denied {
return false
}
b.log.Warn("inbound message blacklisted, dropped",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
if b.onRejected != nil {
b.onRejected(msg, reason)
}
return true
}
// inboundTarget is where one admitted inbound message is persisted and mirrored
// (design/16 §4.4 + design/02 §3): an owner private chat is routed into the single
// main web session with the channel platform stamped so the Web client mirrors it,
// while a restricted group/stranger conversation keeps its isolated sess-xxx
// session and its own platform log.
type inboundTarget struct {
// sessionID is the session the turn runs in.
sessionID string
// private marks an owner private chat mirrored into the main web session.
private bool
// platform is the mirrored chat_messages platform tag (private only).
platform string
}
// inboundTargetFor resolves the routing target from the structurally resolved
// session and the message's platform. Only a non-group owner conversation is
// mirrored; everything else stays in its isolated session.
func inboundTargetFor(sess session.Session, platform string) inboundTarget {
if !sess.IsGroup && sess.Owner {
return inboundTarget{sessionID: history.MainSessionID, private: true, platform: platform}
}
return inboundTarget{sessionID: sess.SessionID}
}
// runInboundTurn drives the agent turn for an admitted message: it resolves the
// routing target, builds the model-visible message list, persists the inbound user
// message before the turn (so it is durable even if the channel reply fails), runs
// the loop and hands the result to deliverReply.
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session) {
target := inboundTargetFor(sess, 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)
// 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 target.private {
b.persistToMain(ctx, history.RoleUser, text, "", target.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), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
return
}
b.deliverReply(ctx, msg, a, sess, target, res)
}
// deliverReply persists the assistant reply and sends it back through the adapter
// that received the message. An adapter that does not support outbound text
// (adapter.ErrUnsupported) is a debug-level skip, not a failure: the reply is
// already durable in the history store.
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result) {
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", target.sessionID)
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 target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, target.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", target.sessionID)
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 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"