Files
Cyrene_v2/internal/services/channelbridge/reload.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

179 lines
6.6 KiB
Go

package channelbridge
// This file holds the hot-reload machinery for the channel bridge (design/04
// §3.1). The panel saves a platform's account config to channels.json and calls
// Bridge.Reload(platform): the factory rebuilds that platform's adapters, they
// connect first (new messages use the new config), then the old adapters are
// gracefully stopped. A message already in flight finishes on its old adapter
// because processing is spawned with the bridge-lifetime context, not the
// per-adapter read context that reload cancels.
import (
"context"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// PlatformFactory rebuilds the adapters for a platform from its current effective
// account config (channels.json, falling back to env). It is injected so the
// bridge can hot-replace a platform's adapters when the panel saves a new config
// without a restart. The concrete implementation lives in the composition root
// (cmd/cyrene), which owns the platform adapter constructors.
type PlatformFactory interface {
// Build returns the adapters for platform from its effective account config.
// The returned slice may be empty when the platform has no configured account.
Build(platform string) ([]adapter.PlatformAdapter, error)
}
// ErrNilFactory is returned when a reload is requested without a platform factory
// injected (no hot-reload configuration source).
var ErrNilFactory = errors.New("channelbridge: nil platform factory")
// ErrBridgeClosed is returned when a reload is requested on a stopped bridge.
var ErrBridgeClosed = errors.New("channelbridge: bridge is not running")
// connectAndRun dials a single adapter (bound by connectTimeout) and, on success,
// installs its inbound handler and launches its read loop under a per-adapter
// context. The per-adapter context is what a hot reload cancels to stop the old
// read loop; message processing is spawned with the bridge-lifetime context so an
// in-flight message started before a Reload completes on its old adapter.
func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
if a == nil {
return
}
if !b.configured(a) {
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
return
}
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)
return
}
// Close over the adapter so the inbound callback knows which account received
// the message — required for multi-account reply routing and for reading that
// account's allow/deny lists (design/16 §4.6 §5).
a.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
b.mu.Lock()
if b.closed {
b.mu.Unlock()
ctx, c := context.WithTimeout(context.Background(), connectTimeout)
_ = a.Disconnect(ctx)
c()
return
}
readCtx, rcancel := context.WithCancel(b.ctx)
b.readCancels[a.Name()] = rcancel
b.connected[a.Name()] = true
b.mu.Unlock()
// Cache the account's channel-level role now that the adapter is live: the
// inbound path reads the cache instead of the config, and a reload (which the
// panel's save triggers) re-runs this to pick up a new binding.
b.rememberAdapterPersona(a.Name())
b.wg.Add(1)
go b.readLoop(a, readCtx)
b.log.Info("channel connected", "channel", a.Name())
}
// Reload rebuilds the adapters for a platform from its current effective config
// (via the injected factory) and hot-replaces the running ones without a restart
// (design/04 §3.1). The rebuilt adapters connect first; messages that arrived on
// the old adapters finish on them, and new messages are picked up by the new
// configuration. An account that was removed stops its old adapter, and an
// unchanged-name account stops the old adapter once the new one is live.
func (b *Bridge) Reload(platform string) error {
if b.factory == nil {
return ErrNilFactory
}
b.mu.Lock()
started := b.started
closed := b.closed
b.mu.Unlock()
if !started || closed {
return ErrBridgeClosed
}
newAdapters, err := b.factory.Build(platform)
if err != nil {
return fmt.Errorf("channelbridge: build %s: %w", platform, err)
}
old, oldCancels := b.adapterSnap(platform)
newNames := make(map[string]bool, len(newAdapters))
for _, a := range newAdapters {
newNames[a.Name()] = true
}
for _, a := range newAdapters {
b.connectAndRun(a)
}
for _, o := range old {
if newNames[o.Name()] {
// Same-name account replaced by the rebuilt adapter: stop the old read
// loop but leave the registry entry — it now points at the new adapter.
b.cancelAndDisconnect(o, oldCancels[o.Name()])
continue
}
// Account removed (or renamed) entirely: stop it and drop its registry
// entries, so a subsequent snapshot no longer sees it.
b.removeAdapter(o, oldCancels[o.Name()])
}
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
return nil
}
// adapterSnap returns the adapters currently registered for a platform, together
// with the context-cancel func that controls each one's read loop. It is used by
// Reload to stop the old adapters after the rebuilt ones are live.
func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[string]context.CancelFunc) {
b.mu.RLock()
defer b.mu.RUnlock()
var list []adapter.PlatformAdapter
cancels := make(map[string]context.CancelFunc)
for name, a := range b.adapters {
if basePlatform(name) != platform {
continue
}
list = append(list, a)
if c, ok := b.readCancels[name]; ok {
cancels[name] = c
}
}
return list, cancels
}
// cancelAndDisconnect stops an adapter's read loop and tears down its session.
// It never touches the registry map: callers that replace a same-name adapter
// want the (new) entry to stay, and callers that remove an account use
// removeAdapter.
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
if c != nil {
c()
}
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
_ = o.Disconnect(ctx)
cancel()
b.log.Info("channel (reload) stopped", "channel", o.Name())
}
// removeAdapter stops an account's adapter and drops its registry entries. It is
// only called for names that were NOT rebuilt by Reload, so the delete can never
// remove a replacement adapter that now owns the name.
func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc) {
b.cancelAndDisconnect(o, c)
name := o.Name()
b.mu.Lock()
delete(b.adapters, name)
delete(b.connected, name)
delete(b.readCancels, name)
b.mu.Unlock()
b.forgetAdapterPersona(name)
b.log.Info("channel (reload) removed", "channel", name)
}