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 }