Files
Cyrene_v2/cmd/cyrene/wire_channels.go
T
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

216 lines
9.2 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// ownerRegistrar implements gateway.OwnerRegistrar: it persists a channel account
// id into the owner whitelist (configs/users/owner.yaml) AND hot-updates the live
// owner profile the channel bridge holds, so a weixin scan-login confirmation is
// recognized as the owner immediately — no restart, no manual edit. path and the
// live profile are captured at wiring time: the boot-loaded *user.OwnerProfile is
// the same pointer the bridge resolves actors against, so RegisterAccount on it is
// visible to ResolveActor without touching the bridge.
type ownerRegistrar struct {
path string
owner *user.OwnerProfile
log *slog.Logger
}
func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
if strings.TrimSpace(accountID) == "" {
return nil
}
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
}
// Hot-effect: extend the live profile the bridge already points at. If the
// profile failed to load at boot (r.owner nil) the persist above still lands
// on disk and is recognized after a restart; log so the operator knows the
// hot path was unavailable.
if r.owner != nil {
r.owner.RegisterAccount(platform, accountID)
} else {
r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)",
"platform", platform, "accountId", accountID)
}
r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID)
return nil
}
// LiveOwnerProfile 是 ownerRegistrar 对 gateway.liveOwnerProfile 可选扩展的实现(裁决
// 10.4.5):管理面板 PUT /admin/owner-profile 保存后,gateway 需要拿到**同一个**活档案指针
// 做热替换——渠道 bridge 判定 actor 用的就是这个指针,所以热替换无需重启即生效。启动时档案
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// wireChannels constructs the four IM channel adapters from environment
// configuration, registers them so the admin API can enumerate them, and wires
// them into the channel bridge so a configured adapter connects and routes
// inbound messages through session→actor→loop→reply. Adapters without
// credentials stay disconnected (logged, never fatal): connecting with an empty
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
// login.
func (a *app) wireChannels() error {
// The channel config is runtime-editable: channels.json (when present and
// valid) is authoritative, otherwise the env channel block is the fallback
// (design/04 §3.1). The factory and the store read the same file so a panel
// save + hot-reload keeps boot and runtime consistent.
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
adapters := buildChannelAdapters(factory)
for _, ch := range adapters {
capp := registry.Capability{
Name: ch.Name(),
Kind: registry.KindChannel,
Description: ch.Name(),
Value: ch,
}
if err := a.caps.Register(capp); err != nil {
a.log.Warn("channel register skipped", "channel", ch.Name(), "error", err)
}
}
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
Sessions: a.sessions,
Owner: a.owner,
History: a.history,
QuietStartHour: a.cfg.Background.QuietStartHour,
QuietEndHour: a.cfg.Background.QuietEndHour,
// Optional review model prunes low-value queued messages near the per-adapter
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
// Light is the design/18 §3 participation judge: a cost-controlled light LLM
// decision on a triggered group/stranger message (role-name mention / probability
// / active window). Only an actual trigger feeds the group context.
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
TriggerWords: a.defaultTriggerWords(),
PersonaPrompt: a.defaultInstructions(),
Engage: channelbridge.DefaultEngageConfig(),
// EngageFor is left nil (P2a); per-group overrides arrive with P2c.
// Factory lets the panel save hot-replace a platform's adapters without a
// restart (design/04 §3.1).
Factory: factory,
// ChannelPersona resolves an account's channel-level role from the same
// config the factory builds from (design/22 §2 方案 b); the bridge caches it
// per connected adapter and refreshes it on every reload.
ChannelPersona: factory.Persona,
})
if berr != nil {
return fmt.Errorf("channel bridge: %w", berr)
}
for _, ch := range adapters {
bridge.Add(ch)
}
bridge.Start()
a.channelBridge = bridge
// 保留具体类型:gateway 只看到 ChannelConfigStore 接口,而启动播种管理员白名单
// 需要 concrete runtimeChannels 的方法。
rc := &runtimeChannels{
path: channelsPath,
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
// channels.json 里声明的 adminIds 从第一帧入站消息起就参与判主。
rc.applyAdminIDs(rc.loadAccounts())
a.channels = rc
a.log.Info("channel adapters registered", "count", len(adapters))
return nil
}
// runtimeChannels is the gateway-facing view of the runtime-editable channel
// config. Load re-reads channels.json (file wins → env fallback) and Reload
// delegates to the bridge's hot-replace, so the panel's save/reload path drives
// the same source the bridge boots from.
type runtimeChannels struct {
path string
fallback map[string][]config.ChannelAccount
bridge *channelbridge.Bridge
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
}
func (r *runtimeChannels) Path() string { return r.path }
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)
return accounts
}
// loadAccounts 只负责「读配置」:文件优先、env 兜底(design/04 §3.1)。
func (r *runtimeChannels) loadAccounts() map[string][]config.ChannelAccount {
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
if warn != nil {
r.log.Warn("channels.json load fell back to env", "error", warn)
}
return accounts
}
// applyAdminIDs 把各平台声明的 adminIds 整体替换进活主人档案(裁决 06.4.2)。整体替换
// 语义保证热重载后已删除的管理员 id 立即失效;owner.yaml 的账号白名单不受影响。
func (r *runtimeChannels) applyAdminIDs(accounts map[string][]config.ChannelAccount) {
if r.owner == nil {
return
}
for platform, ids := range runtimeconfig.AdminIDs(accounts) {
r.owner.SetPlatformAdmins(platform, ids)
}
}
func (r *runtimeChannels) Reload(platform string) error {
// 先灌管理员白名单再重载适配器:面板一次保存(adminIds + 凭据)两条路径同时生效。
r.applyAdminIDs(r.loadAccounts())
return r.bridge.Reload(platform)
}
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil
// when no model adapter is available so the gate falls back to FIFO eviction — the
// reviewer is never invoked with a nil adapter (which would panic on .Chat).
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if llm == nil {
return nil
}
return channelbridge.NewLLMReviewer(llm, log)
}
// engageLightFor builds the design/18 §3 participation judge, or nil when no model
// adapter is available so the bridge's participation engine fails closed toward
// silence (a triggered message without a judge is never answered).
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
if llm == nil {
return nil
}
return channelbridge.NewLLMLightJudge(llm, log)
}
// defaultTriggerWords returns the default persona's role-name reminder words
// (design/18 §2) so the channel bridge knows which group messages cause a PROBING.
func (a *app) defaultTriggerWords() []string {
if c, ok := a.personas.DefaultCharacter(); ok {
if p, ok := a.personas.Persona(c.PersonaID); ok {
return append([]string(nil), p.TriggerWords...)
}
}
return nil
}