817 lines
32 KiB
Go
817 lines
32 KiB
Go
// Package channelbridge wires the registered IM channel adapters into the Cyrene
|
|
// runtime: it connects each configured adapter, installs the inbound message
|
|
// handler, and routes every normalized inbound message through the
|
|
// channel→session→actor→loop→reply pipeline (DevDocs/design/08 §0-§2).
|
|
//
|
|
// The bridge is deliberately generic over adapter.PlatformAdapter. It owns the
|
|
// per-platform Connect lifecycle and the inbound→reply path; the concrete
|
|
// adapters themselves (and their configuration, sourced from env) are assembled
|
|
// in cmd/cyrene/wire.go and handed in via Add. It never reads message text to
|
|
// decide identity — that is ResolveActor's job (actor metadata + owner whitelist,
|
|
// a project red line).
|
|
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// webhookAdapter is the optional capability a channel adapter may expose to
|
|
// source an HTTP inbound callback. *webhook.Adapter and *kook.Adapter (webhook
|
|
// mode) implement it. The bridge reports these so the gateway can mount them.
|
|
type webhookAdapter interface {
|
|
// InboundHandler returns the http.Handler the gateway mounts at the route to
|
|
// receive external callbacks.
|
|
InboundHandler() http.Handler
|
|
// WebhookPath returns the route the gateway should mount the handler at. It
|
|
// returns "" when the adapter is not currently exposing a webhook, letting the
|
|
// bridge skip it (e.g. a websocket-mode KOOK).
|
|
WebhookPath() string
|
|
}
|
|
|
|
// Knob constants. Compile-time so no bare magic number appears in the run path
|
|
// (project rule §10).
|
|
const (
|
|
// connectTimeout bounds a single adapter Connect so a hung dial cannot stall
|
|
// boot (a failed connect logs a warning and is skipped, never fatal).
|
|
connectTimeout = 10 * time.Second
|
|
// maxConcurrent bounds how many inbound messages run the agent loop at once,
|
|
// so a burst of channel messages cannot spawn unbounded goroutines.
|
|
maxConcurrent = 16
|
|
)
|
|
|
|
// ErrNilLoop is returned when the bridge is built without a chat runner.
|
|
var ErrNilLoop = errors.New("channelbridge: nil loop runner")
|
|
|
|
// ErrNilSessions is returned when the bridge is built without a session store.
|
|
var ErrNilSessions = errors.New("channelbridge: nil session store")
|
|
|
|
// Chatter runs a single agent turn. *loop.Loop is the production implementation;
|
|
// tests inject a stub.
|
|
type Chatter interface {
|
|
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
|
|
}
|
|
|
|
// SessionStore resolves the channel↔session binding for an inbound message.
|
|
// *session.SessionManager is the production implementation.
|
|
type SessionStore interface {
|
|
GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error)
|
|
}
|
|
|
|
// 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). 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` (web chat) or `channel_persisted` (IM adapter log) 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
|
|
History history.HistoryStore
|
|
OnPersisted func(history.Message)
|
|
// OnRejected, when non-nil, is called after a message is dropped by the
|
|
// allow/deny black-or-white lists (design/16 §4.6 §6), so the composition root
|
|
// can route it into an audit trail. It is invoked with the rejected message and
|
|
// a short reason token ("group_denied", "user_not_allowed", ...). Nil = log only.
|
|
//
|
|
// It is also wired as the flow gate's OnPruned audit hook (design/12 §6 策略三/四),
|
|
// so a message dropped by the rate limiter or the review prune reaches the same
|
|
// audit trail with reason "rate_limited", "review_pruned" or "capped".
|
|
OnRejected func(adapter.UnifiedMessage, string)
|
|
// Review is the optional review model (design/12 §6 策略四) that prunes low-value
|
|
// queued messages. Nil falls back to FIFO eviction at the total cap. Use
|
|
// NewLLMReviewer to wrap a model.Adapter into this signature.
|
|
Review adapter.Reviewer
|
|
// Light is the optional light-response judge for non-owner group/stranger
|
|
// messages (design/12 §6 策略二/五 + design/18 §3). When nil the bridge uses its
|
|
// built-in rule-based judge and fails-closed to silence, so no chat message is sent
|
|
// to an LLM just to decide whether to bother the owner. It is consulted when the
|
|
// rule judge is inconclusive and the participation engine (design/18) has either
|
|
// been triggered (role-name mention / probability) or is in an ACTIVE window. Use
|
|
// NewLLMLightJudge to wrap a model.Adapter into a cost-controlled judge.
|
|
Light LightJudge
|
|
// TriggerWords are the current persona's role-name reminder words (design/18 §2).
|
|
// A group message containing any one routes into participation PROBING even without
|
|
// a direct @. They never imply identity (that stays structural). Empty keeps the
|
|
// bridge on the legacy design/12 §6 light judgement.
|
|
TriggerWords []string
|
|
// PersonaPrompt is the current persona's identity/system-prompt block handed to the
|
|
// participation judge so it can weigh the role's tone. It carries no owner secret.
|
|
PersonaPrompt string
|
|
// Engage is the participation-engine config (probability, activity window, cooldown,
|
|
// context size). When its Enabled/Probability are zero AND TriggerWords is empty the
|
|
// bridge keeps the legacy light judgement and never consults the engine. Use
|
|
// DefaultEngageConfig for the design/18 defaults.
|
|
Engage EngageConfig
|
|
// EngageFor is an optional per-conversation override (design/18 §6 per-group knobs).
|
|
// Nil keeps the base Engage config for every conversation (P2a).
|
|
EngageFor func(platform, groupID string) *EngageConfig
|
|
// EngageRand is the injectable [0,1) source for the probability trigger. Nil uses a
|
|
// default math/rand source; tests inject a fixed source for determinism.
|
|
EngageRand func() float64
|
|
// QuietStartHour and QuietEndHour bound the do-not-disturb window (local clock
|
|
// hours), shared with the backthink service so 策略五 reuses the same definition.
|
|
// Equal non-zero hours disable the window; both zero pick the backthink defaults
|
|
// (23-07). This is the price of keeping one source of truth across services.
|
|
QuietStartHour int
|
|
QuietEndHour int
|
|
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
|
|
// which makes time-dependent behaviour injectable in tests.
|
|
Now func() time.Time
|
|
// Factory rebuilds a platform's adapters from its effective account config
|
|
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
|
|
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
|
|
Factory PlatformFactory
|
|
}
|
|
|
|
// 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
|
|
history history.HistoryStore
|
|
onPersisted func(history.Message)
|
|
onRejected func(adapter.UnifiedMessage, string)
|
|
review adapter.Reviewer
|
|
|
|
light LightJudge
|
|
quietStartHour int
|
|
quietEndHour int
|
|
now func() time.Time
|
|
|
|
// Participation-engine state (design/18): per-conversation in-memory state machine
|
|
// + the persona reminder words / engage knobs that gate it, and the injectable
|
|
// randomness source for the probability trigger.
|
|
triggerWords []string
|
|
personaPrompt string
|
|
engage EngageConfig
|
|
engageFor func(platform, groupID string) *EngageConfig
|
|
engageRand func() float64
|
|
// bubbleGapFn is the injectable delay between IM outbound bubbles (0.5-2s),
|
|
// defaulting to the package bubbleGap. Tests inject a fixed short gap so the
|
|
// multi-bubble delay is asserted deterministically without sleeping a real 0.5-2s.
|
|
bubbleGapFn func() time.Duration
|
|
engageMu sync.Mutex
|
|
engageGroups map[string]*engageGroupState
|
|
|
|
mu sync.RWMutex
|
|
adapters map[string]adapter.PlatformAdapter
|
|
connected map[string]bool
|
|
readCancels map[string]context.CancelFunc
|
|
started bool
|
|
closed bool
|
|
factory PlatformFactory
|
|
// proactiveCursor is the rotation cursor for the proactive-push no-signal
|
|
// fallback (channelbridge/proactive.go), so the default channel is not always
|
|
// the first whitelisted owner channel. Guarded by mu.
|
|
proactiveCursor int
|
|
|
|
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
|
|
// built lazily on the first inbound message per account and are independent of
|
|
// the adapter/connected lifecycle, so the same gate survives reconnects.
|
|
gateMu sync.Mutex
|
|
gates map[string]*adapter.FlowGate
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
|
|
sem chan struct{}
|
|
}
|
|
|
|
// New builds a Bridge from opts. It returns an error when a required dependency
|
|
// is missing so the caller fails fast rather than silently dropping messages.
|
|
func New(opts Options) (*Bridge, error) {
|
|
if opts.Loop == nil {
|
|
return nil, ErrNilLoop
|
|
}
|
|
if opts.Sessions == nil {
|
|
return nil, ErrNilSessions
|
|
}
|
|
log := opts.Log
|
|
if log == nil {
|
|
log = logger.New(logger.Options{Out: nil})
|
|
}
|
|
// Quiet hours default to the same window the backthink service uses, so the two
|
|
// services never disagree about the do-not-disturb window (design/12 §6 策略五).
|
|
qStart, qEnd := opts.QuietStartHour, opts.QuietEndHour
|
|
if qStart == 0 && qEnd == 0 {
|
|
qStart, qEnd = defaultQuietStartHour, defaultQuietEndHour
|
|
}
|
|
now := opts.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
engageRand := opts.EngageRand
|
|
if engageRand == nil {
|
|
engageRand = func() float64 { return rand.Float64() }
|
|
}
|
|
return &Bridge{
|
|
log: logger.WithSource(log, "services/channelbridge"),
|
|
run: opts.Loop,
|
|
sessions: opts.Sessions,
|
|
owner: opts.Owner,
|
|
history: opts.History,
|
|
onPersisted: opts.OnPersisted,
|
|
onRejected: opts.OnRejected,
|
|
review: opts.Review,
|
|
light: opts.Light,
|
|
quietStartHour: qStart,
|
|
quietEndHour: qEnd,
|
|
now: now,
|
|
triggerWords: opts.TriggerWords,
|
|
personaPrompt: opts.PersonaPrompt,
|
|
engage: opts.Engage,
|
|
engageFor: opts.EngageFor,
|
|
engageRand: engageRand,
|
|
bubbleGapFn: bubbleGap,
|
|
engageGroups: make(map[string]*engageGroupState),
|
|
adapters: make(map[string]adapter.PlatformAdapter),
|
|
connected: make(map[string]bool),
|
|
readCancels: make(map[string]context.CancelFunc),
|
|
gates: make(map[string]*adapter.FlowGate),
|
|
factory: opts.Factory,
|
|
cancel: func() {},
|
|
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
|
|
}
|