- 四组并行完成:main/wire*.go、wire_capacity|channels|runtime、channels|persona_*|emotion|observability、 voice|plugins|stream|skills|vision|subagent_tool|extractor|mcp|sessionread_tool|terminal_tool。 - 机械校验(git diff -U0 去 // 行与空行)仅剩 wire_frontend.go:84 一处行尾注释:代码前缀 「\ta.gw = gw 」经 cat -A 与 HEAD 逐字节比对一致(allow 清单已声明)。 - 验收:隔离 worktree(HEAD + 本批 patch)go build/vet 绿、全树 58 包 ok、 linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净。 - 备注:整树两次红均为负载敏感 flake(backthink / orchestration 的 timeout 类用例单独 -count=20 全过)。
206 lines
9.1 KiB
Go
206 lines
9.1 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 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||
// 白名单(configs/users/owner.yaml),**并**热更新渠道 bridge 持有的活主人档案,
|
||
// 因此微信扫码登录的确认会被立即认成主人——无需重启,也无需手工改配置。path 与
|
||
// 活档案都在接线时捕获:启动加载的 *user.OwnerProfile 就是 bridge 判定 actor 时
|
||
// 依据的同一个指针,所以在它上面调 RegisterAccount 对 ResolveActor 立即可见,
|
||
// 无需触碰 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)
|
||
}
|
||
// 热生效:把这次登记扩到 bridge 已经指向的那份活档案上。若档案在启动时加载
|
||
// 失败(r.owner 为 nil),上面的落盘依然写进磁盘、重启后即被识别;此处打日志
|
||
// 是为了让运维知道热路径当时不可用。
|
||
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 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
|
||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||
// 致命错误):拿空配置去连要么失败,要么(weixin-ilink)触发阻塞式扫码登录。
|
||
func (a *app) wireChannels() error {
|
||
// 渠道配置可在运行时编辑:channels.json 存在且有效时以它为准,否则回落到 env
|
||
// 里的渠道块(design/04 §3.1)。工厂与配置读取读的是同一个文件,因此面板保存 +
|
||
// 热重载能让启动口径与运行口径保持一致。
|
||
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,
|
||
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
|
||
// (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
|
||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||
// Light 是 design/18 §3 的参与判定器:对触发命中的群聊/陌生人消息做一次
|
||
// 成本受控的轻量 LLM 决策(角色名提及 / 概率 / 活跃窗口)。只有真正触发
|
||
// 才把消息喂进群聊上下文。
|
||
Light: engageLightFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/engage")),
|
||
TriggerWords: a.defaultTriggerWords(),
|
||
PersonaPrompt: a.defaultInstructions(),
|
||
Engage: channelbridge.DefaultEngageConfig(),
|
||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||
Factory: factory,
|
||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||
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 是「可运行时编辑的渠道配置」面向 gateway 的视图。Load 重读
|
||
// channels.json(文件优先 → env 兜底),Reload 委托给 bridge 的热替换,因此面板的
|
||
// 保存/重载路径驱动的正是 bridge 启动时的同一份来源。
|
||
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 构建渠道 bridge 的复审模型(design/12 §6 策略四);无模型适配器可用时
|
||
// 返回 nil,让闸门回落到 FIFO 驱逐——绝不在适配器为 nil 时调用复审器(那会在
|
||
// .Chat 上 panic)。
|
||
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
|
||
if llm == nil {
|
||
return nil
|
||
}
|
||
return channelbridge.NewLLMReviewer(llm, log)
|
||
}
|
||
|
||
// engageLightFor 构建 design/18 §3 的参与判定器;无模型适配器可用时返回 nil,让
|
||
// bridge 的参与引擎朝「沉默」方向 fail-closed(触发命中但无判定器的消息绝不作答)。
|
||
func engageLightFor(llm model.Adapter, log *slog.Logger) channelbridge.LightJudge {
|
||
if llm == nil {
|
||
return nil
|
||
}
|
||
return channelbridge.NewLLMLightJudge(llm, log)
|
||
}
|
||
|
||
// defaultTriggerWords 返回默认人格的角色名提醒词(design/18 §2),让渠道 bridge
|
||
// 知道哪些群聊消息会引发 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
|
||
}
|