palz-connector 1.5.9 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.5.9",
4
+ "version": "1.6.0",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": [
7
7
  "palz-connector"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.5.9",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
package/src/bot.js CHANGED
@@ -29,6 +29,61 @@ function extractPlainText(content) {
29
29
  }
30
30
  return "";
31
31
  }
32
+ // ============ 风控重生成 ============
33
+ /** 命中内容风控(80001)后,最多重新生成回复的次数;用尽后发送兜底文案。 */
34
+ const MAX_RISK_REGEN = 2;
35
+ /** 重生成多次仍被风控时,发给用户的固定兜底文案。 */
36
+ const RISK_CONTROL_FALLBACK_TEXT = "抱歉,这个话题我不太方便展开,我们聊点别的吧~";
37
+ /**
38
+ * 清理风控原始错误:移除 requestid / tcloudapirequestid 等链路噪声、截断长度。
39
+ * 不依赖腾讯错误格式做强解析,只做低风险清理,剩余文本整段交给 agent 自行判断敏感点。
40
+ */
41
+ function summarizeRiskError(rawError) {
42
+ return (rawError ?? "")
43
+ // 先 redact tcloudapirequestid,再 redact requestid,避免后者的子串匹配漏掉前者
44
+ .replace(/tcloudapirequestid:[^|)\s]+/gi, "tcloudapirequestid:<redacted>")
45
+ .replace(/requestid:[^|)\s]+/gi, "requestid:<redacted>")
46
+ .trim()
47
+ .slice(0, 500);
48
+ }
49
+ /**
50
+ * 构建"风控重生成"的 inbound context:在原始用户任务基础上追加风控纠偏指令,
51
+ * 复用同一 SessionKey 延续会话;用新的 MessageSid(_regen_N)避开去重。
52
+ *
53
+ * 安全:regenBody 可能含被拦内容,但它只进 ctx.Body(agent 输入),不进 IM send。
54
+ */
55
+ function buildRegenerateCtx(core, baseCtx, attempt, rawError) {
56
+ const reason = summarizeRiskError(rawError);
57
+ // 取干净的当前用户消息作为原始任务(BodyForAgent 不含群聊历史),避免 agent 跑偏。
58
+ const originalTask = typeof baseCtx.BodyForAgent === "string" && baseCtx.BodyForAgent.trim()
59
+ ? baseCtx.BodyForAgent
60
+ : typeof baseCtx.RawBody === "string"
61
+ ? baseCtx.RawBody
62
+ : "";
63
+ const regenBody = [
64
+ "[系统提示·风控重写] 你上一条回复被平台内容风控拦截,未送达用户,需要你重新生成。",
65
+ originalTask ? `原始用户任务:\n${originalTask}` : "",
66
+ reason
67
+ ? `平台返回的错误信息如下,请据此判断是哪部分内容触发了风控并规避:\n${reason}`
68
+ : "平台返回错误码 80001(内容风控)。",
69
+ "要求:保持你原本想表达的核心意思和有用信息,换一种措辞重写,避开上述触发风控的内容(包括其谐音、拼音、缩写、变体);",
70
+ "如果该信息无法在不触及敏感内容的前提下表达,则改为简短地说明这部分内容不便展开。",
71
+ "直接输出重写后的新回复,不要解释你在重写、也不要复述本提示。",
72
+ ]
73
+ .filter(Boolean)
74
+ .join("\n");
75
+ // 注意:finalizeInboundContext 会 mutate 入参对象,必须用 {...baseCtx} 展开成新对象,
76
+ // 绝不能直接传 baseCtx —— 否则会污染原始 ctx,导致下一轮 originalTask 被本轮 regenBody 覆盖。
77
+ return core.channel.reply.finalizeInboundContext({
78
+ ...baseCtx,
79
+ Body: regenBody,
80
+ BodyForAgent: regenBody,
81
+ RawBody: regenBody,
82
+ CommandBody: "",
83
+ MessageSid: `${baseCtx.MessageSid}_regen_${attempt}`,
84
+ Timestamp: Date.now(),
85
+ });
86
+ }
32
87
  // ============ reasoning 激活状态缓存(内存) ============
33
88
  const REASONING_ACTIVATE_TTL_MS = 15 * 60 * 1000; // 15 分钟
34
89
  /** 已激活 reasoning stream 的 session key → 激活时间戳(ms),超过 TTL 后重新发送激活指令 */
@@ -370,6 +425,7 @@ async function dispatchPalzMessage(params) {
370
425
  async function _dispatchPalzMessageInner(params) {
371
426
  const { cfg, msg, runtime, accountId, agentId } = params;
372
427
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
428
+ const error = typeof runtime?.error === "function" ? runtime.error : console.error;
373
429
  const tag = `palz[${accountId}]`;
374
430
  const core = getPalzRuntime();
375
431
  const account = resolvePalzAccount({ cfg, accountId });
@@ -679,7 +735,8 @@ async function _dispatchPalzMessageInner(params) {
679
735
  const step6c = `[STEP 6c 创建dispatcher] 输入: ${JSON.stringify(dispatcherParams)}`;
680
736
  log(`${tag}: ${step6c}`);
681
737
  span?.addEvent(step6c);
682
- const { dispatcher, replyOptions, markDispatchIdle } = createPalzReplyDispatcher({
738
+ // 每轮重生成都需要一个全新的 dispatcher 实例(riskControl 状态随实例重置),抽成工厂。
739
+ const makeDispatcher = () => createPalzReplyDispatcher({
683
740
  cfg,
684
741
  accountId,
685
742
  runtime,
@@ -696,24 +753,78 @@ async function _dispatchPalzMessageInner(params) {
696
753
  sessionKey: route.sessionKey,
697
754
  passthrough: buildPassthroughFromMsg(msg),
698
755
  });
699
- // STEP 6d: 分发消息给 AI
756
+ // STEP 6d: 分发消息给 AI(带风控重生成循环)
700
757
  // channel registry 守卫已在 index.ts 中通过 defineProperty 安装,
701
758
  // 每次读取 state.registry 时会自动注入 palz-connector channel。
702
759
  const step6dStart = `[STEP 6d AI分发] 开始 session=${route.sessionKey} stream=${useStream}`;
703
760
  log(`${tag}: ${step6dStart}`);
704
761
  span?.addEvent(step6dStart);
705
762
  const dispatchStartMs = Date.now();
706
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
707
- dispatcher,
708
- onSettled: () => markDispatchIdle(),
709
- run: () => core.channel.reply.dispatchReplyFromConfig({
710
- ctx,
711
- cfg,
763
+ let queuedFinal = false;
764
+ let counts = {};
765
+ let attempt = 0; // 已重生成次数
766
+ let currentCtx = ctx; // 第 0 轮用原始 ctx,之后用 buildRegenerateCtx 产物
767
+ let lastRiskError;
768
+ let blockedByRiskControl = false; // 最近一轮是否仍被风控拦截
769
+ while (true) {
770
+ const { dispatcher, replyOptions, markDispatchIdle, riskControl } = makeDispatcher();
771
+ const result = await core.channel.reply.withReplyDispatcher({
712
772
  dispatcher,
713
- replyOptions,
714
- }),
715
- });
773
+ onSettled: () => markDispatchIdle(),
774
+ run: () => core.channel.reply.dispatchReplyFromConfig({
775
+ ctx: currentCtx,
776
+ cfg,
777
+ dispatcher,
778
+ replyOptions,
779
+ }),
780
+ });
781
+ queuedFinal = result.queuedFinal;
782
+ counts = result.counts;
783
+ // withReplyDispatcher 返回时 sendChain(含 HTTP send)已 settle,风控结果已确定。
784
+ if (!riskControl.hit) {
785
+ blockedByRiskControl = false;
786
+ break; // 未触发风控 → 正常完成
787
+ }
788
+ blockedByRiskControl = true;
789
+ lastRiskError = riskControl.rawError;
790
+ if (attempt >= MAX_RISK_REGEN) {
791
+ // 重生成次数已用尽,跳出走兜底
792
+ log(`${tag}: [RISK_CONTROL] 已重生成 ${attempt} 次仍被风控, 停止重试`);
793
+ span?.addEvent(`[RISK_CONTROL] exhausted after attempt=${attempt}`);
794
+ break;
795
+ }
796
+ attempt++;
797
+ log(`${tag}: [RISK_CONTROL] 第 ${attempt} 次重生成触发 error=${lastRiskError?.slice(0, 120)}`);
798
+ span?.addEvent(`[RISK_CONTROL] regenerate attempt=${attempt}`);
799
+ // 用清理后的风控摘要 + 原始用户任务构建重生成 ctx
800
+ currentCtx = buildRegenerateCtx(core, ctx, attempt, lastRiskError);
801
+ }
802
+ // 兜底:重生成次数用尽仍被风控 → 直发固定文案,保证用户一定收到回复
803
+ if (blockedByRiskControl) {
804
+ log(`${tag}: [RISK_CONTROL] 重生成 ${MAX_RISK_REGEN} 次仍失败, 发送兜底文案`);
805
+ span?.addEvent(`[RISK_CONTROL] fallback after ${MAX_RISK_REGEN} regenerations`);
806
+ try {
807
+ await sendToPalzIM({
808
+ config,
809
+ conversationId: msg.conversation_id,
810
+ content: RISK_CONTROL_FALLBACK_TEXT,
811
+ conversationType: msg.conversation_type || "direct",
812
+ msgId: msg.msg_id,
813
+ senderId: msg.sender_id,
814
+ msgType: msg.msg_type,
815
+ groupId,
816
+ lobsterId: msg.lobster_id,
817
+ passthrough: buildPassthroughFromMsg(msg),
818
+ log,
819
+ error,
820
+ });
821
+ }
822
+ catch (e) {
823
+ error(`${tag}: [RISK_CONTROL] 兜底文案发送失败: ${String(e)}`);
824
+ }
825
+ }
716
826
  // AI 回复完成后清空群聊历史(已拼入上下文,避免下次重复)
827
+ // 移到重生成循环之后,只清一次,避免重生成轮次里历史被提前清空。
717
828
  if (isGroup && historyKey && groupContextCacheEnabled) {
718
829
  clearGroupHistory(historyKey, log);
719
830
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { getPalzRuntime } from "./runtime.js";
11
11
  import { loadMediaAsOssUrl } from "./media.js";
12
- import { sendToPalzIM } from "./send.js";
12
+ import { sendToPalzIM, PalzRiskControlError } from "./send.js";
13
13
  import { resolveToolDisplay, formatToolStartText, formatToolResultText, formatResultSummary } from "./tool-display.js";
14
14
  const STREAM_THROTTLE_MS = 200;
15
15
  function createStreamingState() {
@@ -27,12 +27,36 @@ export function createPalzReplyDispatcher(params) {
27
27
  const error = typeof runtime?.error === "function" ? runtime.error : console.error;
28
28
  const streamState = enableStreaming ? createStreamingState() : null;
29
29
  const tag = `palz[${accountId}]`;
30
+ // 风控状态(dispatcher 实例级):当前正文回复(palzMsgType===undefined)命中腾讯
31
+ // 内容风控(80001)时置位,rawError 保留原始错误文案供上层回灌给 agent 做规避指引。
32
+ // 每个 dispatcher 实例对应一轮 dispatch,新实例自动重置。
33
+ const riskControl = { hit: false };
30
34
  // 会话级串行队列:保证同一会话内 IM 消息按入队顺序到达
31
35
  // (不同 dispatcher 实例对应不同会话,天然隔离)
32
36
  let imSendQueue = Promise.resolve();
33
37
  const enqueueIMSend = (content, streamOpts, palzMsgType, label) => {
34
- const next = imSendQueue.then(() => sendToIM(content, streamOpts, palzMsgType)).catch((err) => {
35
- error(`${tag}: [IM_QUEUE] ${label} 发送失败: ${err?.message ?? String(err)}`);
38
+ // palzMsgType===undefined 是当前 Palz 正文回复路径的识别条件;
39
+ // tool/thinking 旁路消息带非空 palzMsgType,不参与风控重生成(命中也仅打日志)。
40
+ const isBodyReply = palzMsgType === undefined;
41
+ const next = imSendQueue
42
+ .then(() => {
43
+ // 本轮正文已命中风控 → 跳过后续正文发送,避免继续外泄被拦内容(block streaming 已关,
44
+ // 正常只有单条 final,此处是对未来潜在多条正文的防御)。
45
+ if (isBodyReply && riskControl.hit) {
46
+ log(`${tag}: [RISK_CONTROL] 已命中风控, 跳过后续正文发送 label=${label}`);
47
+ return;
48
+ }
49
+ return sendToIM(content, streamOpts, palzMsgType);
50
+ })
51
+ .catch((err) => {
52
+ if (err instanceof PalzRiskControlError && isBodyReply) {
53
+ riskControl.hit = true;
54
+ riskControl.rawError = err.rawError;
55
+ error(`${tag}: [RISK_CONTROL] 正文回复触发风控(80001): ${err.rawError?.slice(0, 200)}`);
56
+ }
57
+ else {
58
+ error(`${tag}: [IM_QUEUE] ${label} 发送失败: ${err?.message ?? String(err)}`);
59
+ }
36
60
  });
37
61
  imSendQueue = next;
38
62
  return next;
@@ -193,6 +217,9 @@ export function createPalzReplyDispatcher(params) {
193
217
  });
194
218
  const palzReplyOptions = {
195
219
  ...replyOptions,
220
+ // 强制关闭 block streaming:让正文回复收敛为单条 final,避免"前半段已送达、
221
+ // 后半段被风控"的不可回滚问题,保证风控重生成语义干净(见 docs/risk-control-regenerate-plan.md §3.0)。
222
+ disableBlockStreaming: true,
196
223
  // 传入 runId 让 OpenClaw agent execution 使用,与 onAgentEvent 的 evt.runId 对应
197
224
  ...(toolRunId ? { runId: toolRunId } : {}),
198
225
  onPartialReply: enableStreaming
@@ -242,5 +269,5 @@ export function createPalzReplyDispatcher(params) {
242
269
  }
243
270
  : undefined,
244
271
  };
245
- return { dispatcher, replyOptions: palzReplyOptions, markDispatchIdle };
272
+ return { dispatcher, replyOptions: palzReplyOptions, markDispatchIdle, riskControl };
246
273
  }
package/src/send.js CHANGED
@@ -7,6 +7,37 @@
7
7
  import { tracer, trace, SpanStatusCode, buildTraceparentHeader } from "./tracing.js";
8
8
  import { contentPartsToOpenAIContent } from "./content.js";
9
9
  const SEND_TIMEOUT_MS = 20_000;
10
+ /**
11
+ * IM 后端转调腾讯 IM SDK 时可能命中内容风控(敏感词),此时 HTTP 200 但 body
12
+ * `{ok:false, error:"...(code: 80001)"}`。用专用错误类型把它和普通发送失败区分开,
13
+ * 让上层(reply-dispatcher / bot)能据此触发"重新生成回复"。
14
+ */
15
+ export class PalzRiskControlError extends Error {
16
+ code = 80001;
17
+ rawError;
18
+ constructor(rawError) {
19
+ super(`Palz risk control blocked: ${rawError}`);
20
+ this.name = "PalzRiskControlError";
21
+ this.rawError = rawError;
22
+ }
23
+ }
24
+ /** 腾讯内容风控错误码(先支持 80001 脏词,数组化便于后续扩展)。 */
25
+ const RISK_CONTROL_CODES = [80001];
26
+ /**
27
+ * 判定一个 `/bot/send` 响应 body 是否为内容风控失败。
28
+ * 命中返回原始 error 文案(供回灌给 agent 做规避指引),否则返回 null。
29
+ */
30
+ function detectRiskControl(body) {
31
+ if (!body || body.ok !== false)
32
+ return null;
33
+ const err = typeof body.error === "string" ? body.error : "";
34
+ for (const code of RISK_CONTROL_CODES) {
35
+ if (err.includes(`code: ${code}`) || err.includes(`(${code})`) || body.code === code) {
36
+ return err || `risk control code ${code}`;
37
+ }
38
+ }
39
+ return null;
40
+ }
10
41
  let msgSeq = 0;
11
42
  function nextMsgId() {
12
43
  return `bot_reply_${Date.now()}_${++msgSeq}`;
@@ -123,6 +154,16 @@ async function _sendToPalzIMInner(params) {
123
154
  span?.addEvent(failLog);
124
155
  throw new Error(`Palz send failed: ${response.status} ${rawText}`);
125
156
  }
157
+ // HTTP 200 但业务 ok:false 且命中风控码(80001)—— 抛专用错误供上层触发重新生成。
158
+ // 非风控的 ok:false 暂保持现状(往下走 return body),避免扩大改动面。
159
+ const riskErr = detectRiskControl(body);
160
+ if (riskErr) {
161
+ const blockLog = `[HTTP_RES] RISK_CONTROL status=${response.status} elapsed=${elapsedMs}ms code=80001 error=${riskErr.slice(0, 200)}`;
162
+ error(`palz-send: ${blockLog}`);
163
+ span?.addEvent(blockLog);
164
+ span?.setStatus({ code: SpanStatusCode.ERROR, message: "risk_control_80001" });
165
+ throw new PalzRiskControlError(riskErr);
166
+ }
126
167
  const okLog = `[HTTP_RES] OK status=${response.status} elapsed=${elapsedMs}ms msg_id=${resolvedMsgId}${traceparent ? ` Traceparent=${traceparent}` : ""}\n response_body=${rawText.slice(0, 500)}`;
127
168
  log(`palz-send: ${okLog}`);
128
169
  span?.addEvent(okLog);