Files
Cyrene_v2/cmd/cyrene/wire_channels.go
T
AskaEth 2a43035f5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00

223 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"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/documents"
"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)
}
}
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
docDataDir := resolveDataDir(a.cfg)
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/24 §3;切片 M1):IM 收到的受支持格式文件落进与
// Web 上传**同一个**存储核心(目录口径与 wire_runtime.go 的文档摄取层
// 同源:`<DataDir>/uploads/documents`),元数据也写进同一张 `documents`
// 表——`documents` 表的写入路径因此全仓只有一条(S7 的引用计数与 GC 有
// 单一事实来源)。未配置数据目录时写入面为 nil,桥接器走「未接线」的
// 诚实路径(模型知道有附件读不到),绝不假装读到了。
DocumentStore: documents.StoreConfig{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
},
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
// (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
}