群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。 - loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话 隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道 消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。 - loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变; false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与 ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写 false 直接 return 跳过(不调用 extractor 不落库)。 - 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner 行为不变);backthink/automations/orchestration 默认 owner 全量。 - 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿; loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。 遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope 过滤选项消除与 Inject 模板的重复(2处)。
385 lines
12 KiB
Go
385 lines
12 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"
|
|
"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/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
)
|
|
|
|
// 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).
|
|
type Options struct {
|
|
Log *slog.Logger
|
|
Loop Chatter
|
|
Sessions SessionStore
|
|
Owner *user.OwnerProfile
|
|
}
|
|
|
|
// 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
|
|
|
|
mu sync.RWMutex
|
|
adapters map[string]adapter.PlatformAdapter
|
|
connected map[string]bool
|
|
closed bool
|
|
|
|
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})
|
|
}
|
|
return &Bridge{
|
|
log: logger.WithSource(log, "services/channelbridge"),
|
|
run: opts.Loop,
|
|
sessions: opts.Sessions,
|
|
owner: opts.Owner,
|
|
adapters: make(map[string]adapter.PlatformAdapter),
|
|
connected: make(map[string]bool),
|
|
cancel: func() {},
|
|
sem: make(chan struct{}, maxConcurrent),
|
|
}, 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
|
|
}
|
|
|
|
// 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.
|
|
func (b *Bridge) Start() {
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.ctx, b.cancel = context.WithCancel(context.Background())
|
|
b.mu.Unlock()
|
|
|
|
for _, a := range b.snapshot() {
|
|
if !b.configured(a) {
|
|
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
|
|
continue
|
|
}
|
|
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
|
err := a.Connect(connCtx)
|
|
cancel()
|
|
if err != nil {
|
|
b.log.Warn("channel connect failed, leaving disconnected",
|
|
"channel", a.Name(), "error", err)
|
|
continue
|
|
}
|
|
b.mu.Lock()
|
|
b.connected[a.Name()] = true
|
|
b.mu.Unlock()
|
|
|
|
a.SetMessageHandler(b.onMessage)
|
|
b.wg.Add(1)
|
|
go b.readLoop(a)
|
|
b.log.Info("channel connected", "channel", a.Name())
|
|
}
|
|
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 a.Name() {
|
|
case "telegram", "weixin-ilink":
|
|
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
|
|
}
|
|
}
|
|
|
|
// readLoop runs an adapter's blocking read path until the bridge stops. 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.
|
|
func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
|
|
defer b.wg.Done()
|
|
ctx := b.ctx
|
|
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
|
|
b.onMessage(ctx, msg)
|
|
})
|
|
}
|
|
|
|
// 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.
|
|
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage) {
|
|
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
|
|
}
|
|
|
|
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.process(ctx, msg)
|
|
}()
|
|
}
|
|
|
|
// process runs the full inbound pipeline: resolve the session/actor, drive the
|
|
// agent loop with the chat text, and send the loop's reply back through the
|
|
// channel. Identity is resolved structurally by the session layer; the bridge
|
|
// never reads message content to decide who the sender is.
|
|
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
|
text := strings.TrimSpace(msg.Content)
|
|
if text == "" {
|
|
b.log.Debug("inbound message has no chat text, skipped",
|
|
"platform", msg.Platform, "channel", msg.ChannelID)
|
|
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)
|
|
|
|
// Thread the resolved isolation boundary into the loop's memory gates: the
|
|
// owner's private conversation reads+writes memory, group chats and strangers
|
|
// are read-only isolation (design/02 §3, design/11 §2).
|
|
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
|
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sess.SessionID, []model.Message{
|
|
model.TextMessage(model.RoleUser, text),
|
|
})
|
|
if err != nil {
|
|
b.log.Warn("agent run failed", "platform", msg.Platform,
|
|
"session", sess.SessionID, "error", err)
|
|
return
|
|
}
|
|
|
|
reply := strings.TrimSpace(res.Text)
|
|
if reply == "" {
|
|
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sess.SessionID)
|
|
return
|
|
}
|
|
if err := b.sendReply(ctx, msg, reply); err != nil {
|
|
if errors.Is(err, adapter.ErrUnsupported) {
|
|
b.log.Debug("channel does not support outbound reply, skipped",
|
|
"platform", msg.Platform, "session", sess.SessionID)
|
|
return
|
|
}
|
|
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
|
|
}
|
|
}
|
|
|
|
// sendReply delivers the loop's text back to the channel's source conversation.
|
|
// It mirrors the inbound message's channel targeting fields so the adapter
|
|
// resolves the same peer the message came from.
|
|
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, text string) error {
|
|
a := b.adapter(in.Platform)
|
|
if a == nil {
|
|
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
|
}
|
|
out := adapter.UnifiedMessage{
|
|
Platform: in.Platform,
|
|
ChannelType: in.ChannelType,
|
|
ChannelID: in.ChannelID,
|
|
GroupID: in.GroupID,
|
|
GroupName: in.GroupName,
|
|
SenderID: in.SenderID,
|
|
Sender: in.Sender,
|
|
Content: text,
|
|
ContentType: adapter.ContentText,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}})
|
|
}
|
|
|
|
// 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 ""
|
|
}
|