fix(qq): NapCat 确认超时提示「不要盲目重发」,版本 1.4.2 → 1.4.3

生产实测(2026-10-13):一次回复发出 4 条相同消息,日志中 3 次
output_send__qq 的调用 id 各不相同 —— 是模型看到「发送失败」后自己重试。

根因(在 NapCat 运行代码 /app/napcat/napcat.mjs 中确认):
sendMsg 把消息交给 QQ 内核后,等 onMsgInfoListUpdate 事件匹配
guildId + KSEND_STATUS_SUCCESS,内部固定等待 10s(附件更长)。
主机 IO 饱和(iowait 94–96%)时主线程被 10–30s 长任务阻塞,回调排不上队,
于是 NapCat 抛 "Timeout: NTEvent ... sendMsg" → 非零 retcode(实测 200)。
但消息**已被 QQ 送达**:同一窗口内确有 16 条「发送 -> 私聊」成功记录。

这与已修的 HTTP 客户端超时是两条不同路径:
- HTTP 超时:client.Post 返回 err(bd26c2a 已告警)
- NapCat 超时:HTTP 200 + 响应体带 NTEvent 字样(本次补)
只报「失败」不说「可能已送出」,模型必重发。

改动:
- napcat() 对发送类动作(napcatSendActions)的 NTEvent 超时包装
  errNapcatTimeout,并附「很可能已送出、不要盲目重发」的告警;
  查询类动作保持普通失败(可安全重试)。
- 不按 retcode 判定:该超时来自 callNormalEventV2 的普通 Error,
  retcode 本身不携带「超时」语义。
- 不以 serviceAndMethod == sendMsg 为条件:调用方已限定发送动作,
  该收紧既无生产证据支持,又会把同类超时退回普通失败重新诱发重发。

测试(全部穿过 napcat() 真实调用链):
- TestNapcatSendTimeoutWarnsAgainstBlindResend(生产原文 retcode=200)
- TestNapcatQueryTimeoutIsNotSendTimeout
- TestNapcatSendActionTimeoutRegardlessOfServiceName

变异验证(3 项,均按预期失败后退回):
① 删告警分支 → 发送用例失败 ② 加回 sendMsg 限定 → 不限服务名用例失败
③ 去掉发送动作门禁 → 查询用例失败
This commit is contained in:
JianFeeeee
2026-10-13 12:52:55 +08:00
parent b73a95bb31
commit 65d2bd9e61
3 changed files with 144 additions and 1 deletions

View File

@ -3425,11 +3425,49 @@ func (p *Plugin) napcat(action string, params map[string]interface{}) (interface
// (如 data:null + "Timeout: NTEvent serviceAndMethod:...sendMsg")
// 也会被当成发送成功。
if err := oneBotError(raw); err != nil {
// ★ NapCat 侧超时(2026-10-13 生产实测:一次回复发了 4 条)。
//
// 与上面的 HTTP 超时**同属「可能已送达」**,但走的是另一条路径:
// NapCat 把发送交给 QQ 内核后等 onMsgInfoListUpdate 事件,
// 超时(默认 10s+)即抛 "Timeout: NTEvent ...sendMsg",
// 映射成非零 retcode(本次实测 retcode=200)。
//
// 若只报「发送失败」,模型会当成真失败而重发 —— 实测中
// 模型连发 3 次(调用 id 各不相同,是模型自己的重试决策)。
// 所以必须与 HTTP 超时给同样的「不要盲目重发」告警。
if napcatSendActions[action] && isNapcatSendUnconfirmed(err) {
return nil, fmt.Errorf("napcat %s: %w(NapCat 未等到 QQ 的发送确认,"+
"但消息**很可能已送出**,**不要盲目重发**;若确认对方没收到再重试): %v",
action, errNapcatTimeout, err)
}
return nil, fmt.Errorf("napcat %s: %w", action, err)
}
return raw, nil
}
// isNapcatSendUnconfirmed 报告 OneBot 错误是否为「NapCat 未等到内核确认」。
//
// 为何按消息文本而不是 retcode 判定:这条超时来自 NapCat 内部的
// callNormalEventV2(napcat.mjs)——它把等待监听事件的超时包成普通 Error,
// 再经 OneBot 适配层转成非零 retcode,所以 **retcode 本身不携带"超时"语义**
// (实测为 200,而非某个专门的超时码)。可辨识的特征只有消息里的
// NTEvent + Timeout 字样。
//
// ★ 为何**不**限定 serviceAndMethod 必须是 sendMsg:
// 调用方已经用 napcatSendActions 限定了"这是发送动作",这里再卡 sendMsg
// 是多余的。生产实测到的超时原文里服务名确实是 sendMsg,因此该限定在现有
// 证据下**既无必要、也无观测依据**;留着一个未被任何证据支持的收紧条件,
// 只会把同样「可能已送达」的情形误判回普通失败,而普通失败会诱发模型重发。
// 变异验证:加上/去掉该限定,对「发送动作 + 非 sendMsg 超时」用例结果不同,
// 说明这行代码是活的(而非冗余),故保持宽松版本。
func isNapcatSendUnconfirmed(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "NTEvent") && strings.Contains(s, "Timeout")
}
// oneBotError 解析 OneBot 响应里的成败字段,成功返回 nil。
//
// 判据以 retcode 为主、status 为辅: