package gateway // 本文件是渠道事件的**实时面**(design/26 §2.6 / §4.5;切片 E7)。 // // 与同族的 `admin_channel_events.go` 分工明确、互不重叠: // // - 那边是**只读查询面**(REST,面板轮询的权威):`/admin/channels/events` 与 // `/counters`; // - 这边是**增量面**:周期采样账号状态 → 判定迁移 → 落一行 T2(`events.StatusTracker`) // → 推上进程内 bus(`channel.event`)→ hub 的 relay 规范成 `channel_event` 帧 → // 角色门 → 连接。**不新开监听端口、不做 HTTP 互调**(AGENTS §9 红线)。 // // 为什么采样要**常驻**(而不是像可观测推送那样「有订阅才跑」):§0.2 的事故正是 // 「没人看面板的那几分钟里,账号掉线没有任何痕迹」。留存面必须**不看面板也在跑**, // 否则「可查的历史」只在有人盯着时才存在。推帧只是顺带的产物。 // // 采样节拍 60s 与 `qq_obv11` 的 `get_status` 探活同拍:采得比探活快只会反复读到 // 同一个值(纯浪费),慢于探活只会推迟发现掉线。它**不是可配项** —— 调优类旋钮 // 一律走 `$DATA_DIR/config/**`(AGENTS §10 的 `.env` 冻结红线),而这里没有任何 // 值得调的东西。 import ( "context" "sort" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // wsChannelEvent 是渠道事件实时帧的类型名。它**不在** `ws_frame_scope.go` 的 // `frameReachesAll` 白名单里,因此按 fail-closed 默认档只发 `owner` / `admin` // 连接;`viewer` 继续走既有的 REST 只读档(design/11 §7.1)。帧壳: // // {"type":"channel_event","data":{"event":{…ChannelEventJSON…}}} // // 帧里**没有**任何平台原文 / 媒体 URL / 凭据:它承载的就是 `channel_events` 的一行, // 那行的列在迁移 0021 里就没有那些字段(§4.2)。 const wsChannelEvent = "channel_event" // channelAccountStatusSamples 采集注册表里**每一个可观测账号**的在线状态快照。 // // 与 `liveAccountOnline`(管理列表用)的三点差别,每一点都是刻意的: // // 1. **未知也收**:那边「只收确知的结果」,known=false 的账号整类不进 map(面板上 // 表现为字段缺席);这边 **known=false 正是要留痕的状态之一**(§2.6 的「尚未探到」 // ≠ 离线),归一化成 `unknown` 照常落行。这正是本片要补的洞:未知不是「不报」, // 而是「如实报未知」。 // 2. **带上账号 id**:管理列表按平台聚合,留存行按 `account_id` 落列(§4.1)。 // 3. **排序确定**:注册表的 List 走 map,顺序随机;这里按(平台, 账号)排序, // 使日志与测试顺序可复现(同一拍里多个账号的先后不该是随机的)。 // // 没有那条状态面的适配器(telegram / webhook / weixin-ilink…)**不进快照**:本能力 // 观测的是「问得到的账号状态」,凭空为它们造一个 `unknown` 等于把「这个平台根本没有 // 这条信号」写成「我们探不到」—— 那是两件事(与 §3.4 对微信「不提供非消息事件」的 // 如实标注同一个方向:没有就说没有,不伪造)。 func (s *Server) channelAccountStatusSamples() []events.StatusSample { if s == nil || s.caps == nil { return nil } out := make([]events.StatusSample, 0, 4) for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } as, ok := asAccountStatusAware(cap.Value) if !ok { continue } online, known := as.AccountStatus() platform, account := splitAdapterName(ch.Name()) out = append(out, events.StatusSample{ Platform: platform, AccountID: account, Online: online, Known: known, // 这条状态面当前**唯一**的写入点是 `qq_obv11/probe.go` 的 // recordAccountStatus(get_status 主动探活)。将来若把 meta_event 心跳 // 也接进同一状态面,通道码应改由适配器一并报告(那时这个常量就要让位)。 Source: events.StatusSourceProbe, }) } sort.Slice(out, func(i, j int) bool { if out[i].Platform != out[j].Platform { return out[i].Platform < out[j].Platform } return out[i].AccountID < out[j].AccountID }) return out } // channelEventWriter 返回渠道事件的**写入面**。 // // `Deps.EventStore` 声明的类型是只读查询面(E6 的两个端点只需要 List/Count/Counters), // 而组合根实际注入的是实现了完整 `events.Store` 的具体存储。这里按**能力断言**取 // 写入面(与 `asAccountStatusAware` / `asRescanAware` 同一套路): // // - 拿到 ⇒ 实时面可用(落行 + 推帧); // - 拿不到(真 nil 接口,或测试里的只读替身)⇒ 返回 nil,实时面**整个不启动**, // 绝不伪造一个写不进去的替身、也绝不留一条「看着在跑其实什么都没记」的循环。 // // ⚠️ 这条断言是**零改动的接线**(不动 deps.go / server.go / contracts.go 三个热点文件)。 // 若将来把写入面正式纳入 `Deps`(另一个字段),这里的断言应换成那个字段。 func (s *Server) channelEventWriter() events.Store { if s == nil || s.channelEvents == nil { return nil } store, ok := s.channelEvents.(events.Store) if !ok { return nil } return store } // statusSourceFunc 把「返回快照的函数」适配成 events.StatusSource(网关不实现实体类型, // 只交出这个函数,避免为一个方法再引入一个结构体)。 type statusSourceFunc func() []events.StatusSample // AccountStatusSamples 实现 events.StatusSource;函数为 nil 时返回空快照。 func (f statusSourceFunc) AccountStatusSamples() []events.StatusSample { if f == nil { return nil } return f() } // startChannelEventWatch 启动渠道事件的实时面(常驻,直到 ctx 取消)。 // // 启停**都留一行日志**(对齐 `qq_obv11/probe.go:logProbeStart` 的同一条纪律): // 「实时面根本没跑」与「跑了但一直没迁移」在日志里否则长得一模一样(都是没有), // 而排障时要能一眼分开这两件事。落点是**启动**(带生效间隔、有无 bus、有无状态源) // 与**未启动**(带原因)两种;停止不单独记(进程退出本身有日志,再记一行只是噪音)。 // // 未接线(没有写入面)时记一行 INFO 说明原因后安静返回:这既不是错误也不是降级, // 只是「这份构建没有留存面」。真出错(构造失败)记 WARN —— 绝不静默。 func (h *hub) startChannelEventWatch(ctx context.Context) { if h == nil { return } if h.eventStore == nil { h.log.Info("channel event watch disabled", "reason", "no writable event store wired") return } w, err := events.NewWatcher(events.WatcherConfig{ Store: h.eventStore, Source: statusSourceFunc(h.accountStatus), Publisher: h.bus, Log: h.log, }) if err != nil { h.log.Warn("channel event watch disabled", "err", err) return } h.channelWatch = w w.Start(ctx) h.log.Info("channel event watch started", "interval", w.Interval().String(), "publisher", h.bus != nil, "source", h.accountStatus != nil) }