Commit Graph
32 Commits
Author SHA1 Message Date
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00
AskaEth fd8a62edf5 Merge branch 'feat/lanequeue-decouple-inbound-turns-and-add' into dev 2026-10-24 20:17:17 +08:00
AskaEth e3e8878ead fix(channelbridge): 车道键与会话键同源 + 主人私聊溢出区保序(M22 复审)
复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键
(platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text
channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」
—— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。

- P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID
  与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改);
  `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键),
  **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session),
  出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。
- P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的
  `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。
- P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收
  最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。
- P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计
  计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。

断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、
telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、
TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、
internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
2026-10-24 20:08:28 +08:00
AskaEth 76d972f0f2 fix(channel/flowgate): 审查移出准入路径、节流覆盖失败路径、BOM 解析韧性
- Admit 不再同步调用审查模型:触发时只把审查**派发**到后台 goroutine,准入只读
  当前已有结论。三重上界:同一适配器至多一次在飞(reviewInFlight)、同一会话每窗口
  至多一次(lastReview)、整个适配器每窗口至多 defaultReviewPerWindow=6 次(reviewHits,
  写成可测断言)。
- st.lastReview 改为在**派发时**置位 ⇒ 成功与失败两条路径同样被节流。修复前它只在
  err == nil 分支置位,而实测 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满后每条入站帧
  都付一次 ~6s 的模型调用(qq-ws/kook-ws/weixin-poll 三路单读 goroutine 全被钉住)。
- 新增 adapter.ParseReviewIndices:容忍 UTF-8 BOM、首尾空白/换行、Markdown 代码围栏、
  数组外解说文字;越界/重复编号照旧丢弃。channelbridge.parseKeepIndices 改为委派它
  (仅函数体 + 注释,未动限流/丢弃/审查触发语义)。
- 语义差异(已写进 flowgate_review.go 文件头):rate_limited 的门口丢与 FIFO 上限
  一字不改;审查修剪降级为后台积压去噪,只修剪它当时看到的那批历史条目 ——
  review_pruned 不再从门口丢消息(实测 41–74 条/日),完整性↑、噪声↑,是否开口仍由
  engage_judge/冷避/owner 闸判(三处判据未动)。
- cmd/cyrene:逐账号启动日志如实报出生效的入口流控(塔裁决 A:零行为改动、不翻默认),
  让「没配流控键的账号闸门仍开着」一眼可见。
- 测试:4s 慢审查替身证明入站帧不被阻塞(变异回同步 ⇒ 红)、节流在失败路径生效
  (摘掉派发时置位 ⇒ 红)、每适配器窗口预算上界(摘掉预算 ⇒ 红)、同适配器单飞
  (摘掉 in-flight ⇒ 红)、在飞期间到达的消息不被连带修剪、带 BOM 字节的夹具
  (夹具去掉 BOM ⇒ 红)。闸门用例 -race 通过。
2026-10-24 19:43:57 +08:00
AskaEth b504df01e0 feat(channel): 出站消息 id 采集(可选 seam + qq_obv11 两分支取回)
「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻
就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。

- internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus
  + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进
  PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标);
  也不用回调:id 是某一次投递的属性,必须随那次投递返回。
- qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者
  (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到
  一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。
- send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键,
  撞键即拿不到自己的回执)。
- 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。

验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui
的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
2026-10-13 20:38:12 +08:00
AskaEth b26bc55a15 feat(channel): 消息契约加「她自己账号 id / 是否在叫她」结构化判据并接线 qq_obv11
契约(internal/channel/adapter):UnifiedMessage 新增三个字段 —— SelfID(她自己在该
平台的账号 id)、MentionAll(是否 @全体,design/26 §2.4 的 mention_all)、AddressedToMe
(本条是否指向她本人);判据的唯一出处是新增的 MentionedSelf(结构化 id 精确比对,
空 selfID 一律 false)与 AddressedToSelf(私聊恒 true;群聊 = 点名她或 @全体;
平台未给 SelfID ⇒ fail-closed)。全程不匹配正文文本。

接线(plugins/channels/qq_obv11):新增 addressed.go,把事件里早已解出、此前只用于
debug 日志与 notice 判定的 self_id 接进 toUnified,并从 at 段解出 @全体(qq=all),
在 ChannelType 判定之后统一回写三个字段。

design/18 §2 触发① 的旧口径「Mentions 非空」= @谁不论(实测群 672432767 里 @别人两次
被回);本片让消费侧(light.go:142,归 M12)能改用 AddressedToMe || MentionAll。
既有字段 Mentions/ReplyToID/ReplyToText 语义与形状不变。

测试:qq_obv11 十种帧形态(私聊/@她/@别人/@全体/无@/CQ 形态/fail-closed/纯回复)
逐条断言;adapter 侧覆盖 MentionedSelf 与 AddressedToSelf。变异自证:判据改回
「Mentions 非空」⇒ group_mention_other 红;摘掉 selfId 接线 ⇒ group_mention_me 红。
2026-10-13 20:17:56 +08:00
AskaEth b4d55f412e fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00
AskaEth 353293e807 feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`backthink IM push failed … weixin_ilink: proactive push outside 24h window`
—— 她的话**送进了 Web 活跃客户端,但没送进微信**,原因是**平台规则**(微信 iLink
只允许用户最后一条消息后 24h 窗口内主动推送),不是崩溃。

本片只做「如实说清」,不绕规则:
- **原因码**:新增契约层分类件 `internal/channel/adapter/delivery.go`
  (`DeliveryCategory` + 4 个码 + 可选自描述接口 `DeliveryFailure` / 读取函数),
  **沿用**既有 voice `Category(err)` 与 `SystemNoticeNormalizer` 的范式,**不造第二套枚举**;
  平台码由产生错误的层声明:`weixin_ilink` 的 `ErrProactiveWindow` 自报
  `proactive_window_closed`(哨兵文本与 `errors.Is` 判定**逐字未变**)。
- **三处可见**:① 结构化 WARN(补齐 category/channel/platform/route);
  ② 审计 `backthink.push.im_undelivered`(与 `backthink.self_wake.*` 同族);
  ③ 推送结果 `Detail` 追加稳定标签 `im_undelivered=<渠道>:<原因码>` —— 面板**已在
  渲染 Detail**(`Backthink.tsx:185`)⇒ 主人现在就能看到,**无需改前端**。
- **不丢消息**:IM 路失败照旧走活跃客户端路,且**只在 Web 真送达时** `pushed=true`;
  有测试钉住「IM 失败不影响另一条路」(同一条消息没被丢)。
- **不做的**:不重试刷屏、不换渠道冒充微信、不伪造送达、不新增 `.env` 键。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;4 个包 vet/测试全绿;
我用 -overlay 把 `ErrProactiveWindow` 退成裸 `errors.New` ⇒ weixin 包两条用例变红
(`category = "failed", want proactive_window_closed`),channelbridge/backthink 亦红。

⚠️ 未落地的建议(已登记,未硬改):给她的"推送回执"(`backthink_records` 加
`push_category`/`push_channel` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00
AskaEth a057a0f61f feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
2026-10-05 13:50:29 +08:00
AskaEth ba17820040 feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00
AskaEth 7ae4298871 feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
2026-10-05 13:26:15 +08:00
AskaEth 5139945bfe feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- notice:adapter 归一化 seam + 结构化分类 + 净化(ANSI/控制/零宽/双向字符、200 runes 截断)
  + fail-safe 写死为「静默」(未装配审查/超时/失败 ⇒ 仅登记、不驱动 loop)
- 身份面零改动:notice 发送者恒 RoleSystem + SenderID=system
- 复用策略四的同一个审查模型(Options.Review),未另造第二套
- 入站帧脱敏 DEBUG(post_type/message_type/sub_type/self_id/user_id/sender_id/group_id/段类型/at 目标)
- ctrl.bat 启动前归档轮转(保留 20 份)+ bash 侧改追加(不再重启即截断)
2026-10-05 11:55:05 +08:00
AskaEth 6a545badbd feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00
AskaEth d532eb3fcd fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值;
owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**);
而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate
对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话),
但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、
落盘 actor_role 永不更新)。

修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘
(updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正
(覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息);
**身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。

QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id →
sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人
可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event
已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。

测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时
陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单
热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」
「跨进程继承的旧行被就地纠正」)。
变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown /
还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
2026-10-05 10:53:05 +08:00
AskaEth a918819878 feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00
AskaEth 3be70f8232 feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00
AskaEth b1f9b11d6e test(flake): 4 条负载敏感用例确定性化(只改测试,生产代码零改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- #1 TestSpawnTimeoutFromToolDefault:墙钟竞态(Spawn→Wait 的调度抖动 vs 120ms 工具时限)⇒ 用「托底子代理占住唯一槽位 + gate 放行」把 running 变成由门控保证;并新增 DeadlineExceeded 断言。

- #2 TestParallelLimit:真实成因**不是**「同进程迭代状态泄漏」(任务书推测错了)—— 是 transitionRunning 与 runner 内 active++ 之间的**抢占窗口**(复现:-count=200 六次红、三进程并行 1/9)⇒ 改等 runner 内部 activeCount()>=2 这一**稳定态**,并新增「整轮排空峰值==2」。

- #3 TestBackthink_List_NewestFirst:真实成因**不是**时间戳并列(SQL 是 ORDER BY id DESC)—— 是**后台心跳 writer 在两次 List 之间插入新行**(决定性证据:心跳周期缩到 1ms ⇒ 21/200 红,且第二次读到的 id 更大)⇒ 不再 Start() 心跳,改同步 think 写 3 条。

- #4 TestClient_OnConnectErrorTriggersRedial:固定 60ms 睡眠 vs 每周期耗时不定的墙钟竞态(改动前基线 5/9 红)⇒ 改为等第 2 次拨号事件;顺带改正与内容不符的旧注释。

- 证据:逐条 -count=20 绿;整树连跑 3 次 59 ok/0 FAIL;**三进程并行整树 9/9 绿(改动前基线 5/9 红)**;GOMAXPROCS=1 ×4 进程加压;-count=200/500/500/300;-race 三包绿。

- 主会话独立复验:隔离 worktree build/vet 绿、四条 -count=20 全绿、两进程并行整树双 59 ok/0 FAIL;**变异独立复现**(TestParallelLimit 的 MaxParallel 2→3 ⇒ max concurrent runs = 3, want 2 红)。

- 顺带发现第 5 条既有 flake:internal/runtime/exec/TestTerminal_Timeout_Killed(改动前基线 2/9 红);以及 audit/00-总览.md:321/372 对 #2/#3 成因的记载有误(DevDocs 由主会话更正)。
2026-10-04 18:51:25 +08:00
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
AskaEth fbbfcc95ee fix(persist,channel): 同类定宽时间戳修复 — checkpoint 恢复与渠道会话列表排序
- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点)
- channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值
- 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
2026-10-03 16:08:51 +08:00
Aska 36bd19e997 fix(weixin): 扫码登录二维码真实可扫——前端生成QR(修坏图) + DecodeConfig容忍空config
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
weixin qrcode_img_content 是微信liteapp HTML页(非图片),后端fetch转base64只得<!doctype>→前端img坏图。
- 后端 login-qr 改返回 qrUrl(qrcode_img_content URL)+qrCode(原始qrcode),去掉坏 qrImage
- 前端用 qrcode 库从 qrUrl 生成真实二维码图片(微信扫https链接即开连接页), 新增 qrcode+@types/qrcode(纯UI依赖)
- DecodeConfig: nil config map 当空map解码(不报错), 否则 pending账号(空config)构建被跳过→未注册→登录端点404
- 测试更新(DecodeConfigNil/admin QR端点断言 qrUrl+qrCode)
- 另有 weixinAccountFor 裸weixin兜底(此前提交 d3b0921) 保证 accountId 命中
- 实测: login-qr 200{waiting, qrUrl=89}, 前端能看到可扫二维码
build/vet/test 全绿, 已部署.
2026-09-07 21:32:19 +08:00
Aska c1bd740f7c fix(kook): KOOK 机器人鉴权用 Authorization: Bot <token> 而非 Bearer
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
httpclient 硬编码 Bearer,导致 KOOK bot token 被当 OAuth2 用户令牌发,
/ gateway/index 返回 401『登录环境异常』。按 KOOK reference 鉴权规范,
给共享 httpclient 增加可配置 authScheme(默认 Bearer),KOOK 用 Bot。

- httpclient: 新增 WithAuthScheme 选项 + authScheme 字段,do() 按 scheme 组装
- kook/adapter: 装配时传 WithAuthScheme("Bot")
- 同步修正 config.go 注释与 send/identity 测试断言
2026-09-07 16:59:53 +08:00
Aska 8cde0162e6 feat: 公共平台最小化打扰(design/12 §6)——5条策略: 分级+轻思考+限流+审查剔噪
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
降噪A(策略一/二/五):
- channelbridge/light.go: 非owner分级门——小事件当系统消息登记(不进loop)、非owner轻响应(她判断回/不回,规则优先mention/关键词+轻LLM兜底fail-closed静默)
- backthink 触发降噪: SubjectSystemEvent 低价值不唤醒thinking(仅高价值/owner才think), iot/reminder不受影响
- quiet hours: 安静时段非owner无信号静默,被@才回(非硬性静默); config QUIET_START/END_HOUR 单源共用

限流B(策略三/四):
- adapter/flowgate.go: per-adapter 滑窗限流器+总上限+审查剔噪闸门(Admit两连:速率+上限/审查), owner私聊绕过
- channelbridge/flowgate.go: onMessage入站最前挂闸, 超限/被剔不占worker槽; 审查模型NewLLMReviewer复用model.Adapter(仅>80%阈值触发+跨分钟节流,失败回退FIFO,绝不判身份)
- config: ChannelAccount 加 GroupMsgPerMin(默认6)/MsgPerMin/MaxMessages(默认50), CHANNELS_JSON每账号, 向后兼容
- 被剔消息走OnRejected审计不静默丢

main wire接线: 加modelAdapter字段+Review注入(reviewFor nil-safe,无LLM回退FIFO)
全仓 go build/vet/test 绿(53包)+交叉编译, 零新依赖, 前端无涉
2026-09-07 12:18:21 +08:00
Aska 764d592859 feat(channel): KOOK 渠道适配器 + 框架兼容增强(wsutil/httpclient)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- plugins/channels/kook: config/adapter/gateway/websocket/send/message/errors — 实现 PlatformAdapter 13方法
  - WS 有状态网关: 取gateway地址→连WS→信令0/1/2/3/4/6 + 30s心跳/6s超时 + resume(拼接sn/session_id) + 指数退避重连
  - 收事件: 信令0 → UnifiedMessage(word注 channel_type/type/target_id/extra.author→字段)
  - 发消息: /message/create(频道) + /direct-message/create(私聊,修正正确端点,非user-chat) + Bearer鉴权
  - 注册 channels.go + CHANNELS_JSON 多账号 + channelbridge.configured 加 kook(bot_token非空才连)
- 框架增强: internal/channel/adapter/wsutil(连接管理/心跳/重连/resume骨架,复用gorilla,KOOK用上) + httpclient(Bearer+envelope归一,KOOK用)
- 全仓 go build/vet/test 绿, 零新依赖(复用餐gorilla), 未动telegram/qq/weixin/webhook契约
- §4.6 隔离/黑白名单/第三方边界未破坏

注: KOOK WS/WH 双模式待补(ReceiveMode), 实测需主人提供 CHANNEL_KOOK_TOKEN
2026-09-07 12:01:35 +08:00
Aska 04bfa2c7a7 feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
2026-09-06 14:05:34 +08:00
Aska 7adaf490c7 feat(channel/session): 渠道↔会话映射 + 主人识别(actor元数据+白名单,不读消息内容) + 群聊隔离 + 主动推送策略(iLink24h/OneBot限频) 2026-09-06 06:36:27 +08:00
Aska c52ddfd9c7 feat(channel): 跨平台 IM 接入抽象契约(UnifiedMessage/PlatformAdapter/Capabilities/Actor 身份) 2026-09-06 04:33:59 +08:00