@jerryliang122/openclaw-qqbot 1.0.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.
- package/LICENSE +22 -0
- package/README.md +967 -0
- package/README.zh.md +790 -0
- package/dist/index.cjs +18072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1222 -0
- package/index.ts +92 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +69 -0
- package/preload.cjs +19 -0
- package/scripts/link-sdk-core.cjs +268 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/skills/qqbot-channel/SKILL.md +285 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/skills/qqbot-upgrade/SKILL.md +56 -0
- package/src/adapter/contract.ts +63 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +40 -0
- package/src/adapter/pairing.ts +95 -0
- package/src/adapter/resolve.ts +255 -0
- package/src/adapter/setup.ts +13 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +21 -0
- package/src/agent-prompt-adapter.ts +26 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +230 -0
- package/src/commands/bot-approve.ts +143 -0
- package/src/commands/bot-clear-storage.ts +114 -0
- package/src/commands/bot-group-always.ts +62 -0
- package/src/commands/bot-group-info.ts +48 -0
- package/src/commands/bot-help.ts +40 -0
- package/src/commands/bot-logs.ts +248 -0
- package/src/commands/bot-me.ts +18 -0
- package/src/commands/bot-pairing.ts +50 -0
- package/src/commands/bot-ping.ts +33 -0
- package/src/commands/bot-streaming.ts +55 -0
- package/src/commands/bot-upgrade.ts +56 -0
- package/src/commands/bot-version.ts +41 -0
- package/src/commands/config-util.ts +96 -0
- package/src/commands/index.ts +51 -0
- package/src/config.ts +403 -0
- package/src/constants.ts +6 -0
- package/src/dispatch/body-assembler.ts +308 -0
- package/src/dispatch/ctx-builder.ts +127 -0
- package/src/dispatch/dispatch.ts +667 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/features/approval-capability.ts +302 -0
- package/src/features/approval-helpers.ts +271 -0
- package/src/features/approval-utils.ts +21 -0
- package/src/features/command-panel.ts +301 -0
- package/src/features/credential-backup.ts +74 -0
- package/src/features/group-mode-store.ts +79 -0
- package/src/features/history-store.ts +75 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/outbound-echo-store.ts +46 -0
- package/src/features/proactive-budget.ts +57 -0
- package/src/features/proactive.ts +549 -0
- package/src/features/question-helpers.ts +771 -0
- package/src/features/quota-manager.ts +173 -0
- package/src/features/ref-index-store.ts +289 -0
- package/src/features/secret-input-store.ts +118 -0
- package/src/features/secret-store-cli.ts +324 -0
- package/src/features/typing-refresh.ts +51 -0
- package/src/features/update-checker.ts +166 -0
- package/src/gateway/event-handlers.ts +456 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +236 -0
- package/src/gateway/middleware-setup.ts +173 -0
- package/src/gateway/qqbot-gateway.ts +458 -0
- package/src/gateway-adapter.ts +44 -0
- package/src/heartbeat-adapter.ts +57 -0
- package/src/message-adapter.ts +40 -0
- package/src/messaging-adapter.ts +78 -0
- package/src/middleware/access-control.ts +125 -0
- package/src/middleware/attachment.ts +373 -0
- package/src/middleware/inbound-guard.ts +102 -0
- package/src/middleware/policy-injector.ts +71 -0
- package/src/middleware/secret-capture.ts +161 -0
- package/src/middleware/typing.ts +110 -0
- package/src/openclaw-plugin-sdk.d.ts +543 -0
- package/src/outbound/chunker.ts +80 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +408 -0
- package/src/outbound/outbound-service.ts +298 -0
- package/src/outbound/reply-limiter.ts +139 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +332 -0
- package/src/outbound/target.ts +109 -0
- package/src/outbound-adapter.ts +323 -0
- package/src/plugin-base.ts +42 -0
- package/src/request-context.ts +50 -0
- package/src/runtime.ts +42 -0
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +110 -0
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +40 -0
- package/src/status-adapter.ts +56 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +308 -0
- package/src/tools/secret-input.ts +185 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types-plugin.ts +82 -0
- package/src/types.ts +620 -0
- package/src/typing-lifecycle.ts +182 -0
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +23 -0
- package/src/utils/platform.ts +459 -0
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +132 -0
- package/src/utils/stt.ts +150 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +64 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 被动回复限额管理
|
|
3
|
+
*
|
|
4
|
+
* QQ Bot 被动回复有限额(同一条消息最多回复 N 次,超时后不能再被动回复)。
|
|
5
|
+
* 当限额耗尽或消息过期时,标记应降级为主动消息(proactive)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ReplyLimiterConfig {
|
|
9
|
+
/** 每条消息最大被动回复次数(默认 4) */
|
|
10
|
+
limit?: number;
|
|
11
|
+
/** 消息 ID 过期时间 ms(默认 1 小时) */
|
|
12
|
+
ttlMs?: number;
|
|
13
|
+
/** 最大跟踪消息数(LRU 驱逐,默认 10000) */
|
|
14
|
+
maxTrackedMessages?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReplyLimitResult {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
remaining: number;
|
|
20
|
+
shouldFallbackToProactive: boolean;
|
|
21
|
+
fallbackReason?: 'expired' | 'limit_exceeded';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TrackedMessage {
|
|
25
|
+
count: number;
|
|
26
|
+
firstSeenAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 被动回复限额管理器
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600_000 });
|
|
35
|
+
* const result = limiter.checkLimit(messageId);
|
|
36
|
+
* if (!result.allowed) {
|
|
37
|
+
* // 降级为主动消息
|
|
38
|
+
* } else {
|
|
39
|
+
* limiter.record(messageId);
|
|
40
|
+
* // 正常被动回复
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class ReplyLimiter {
|
|
45
|
+
private readonly limit: number;
|
|
46
|
+
private readonly ttlMs: number;
|
|
47
|
+
private readonly maxTracked: number;
|
|
48
|
+
private readonly messages = new Map<string, TrackedMessage>();
|
|
49
|
+
|
|
50
|
+
constructor(config?: ReplyLimiterConfig) {
|
|
51
|
+
this.limit = config?.limit ?? 4;
|
|
52
|
+
this.ttlMs = config?.ttlMs ?? 3600_000; // 1 小时
|
|
53
|
+
this.maxTracked = config?.maxTrackedMessages ?? 10_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 检查是否允许对指定消息继续被动回复
|
|
58
|
+
*/
|
|
59
|
+
checkLimit(messageId: string): ReplyLimitResult {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const tracked = this.messages.get(messageId);
|
|
62
|
+
|
|
63
|
+
if (!tracked) {
|
|
64
|
+
return { allowed: true, remaining: this.limit, shouldFallbackToProactive: false };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// TTL 过期
|
|
68
|
+
if (now - tracked.firstSeenAt > this.ttlMs) {
|
|
69
|
+
this.messages.delete(messageId);
|
|
70
|
+
return {
|
|
71
|
+
allowed: false,
|
|
72
|
+
remaining: 0,
|
|
73
|
+
shouldFallbackToProactive: true,
|
|
74
|
+
fallbackReason: 'expired',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 限额检查
|
|
79
|
+
const remaining = Math.max(0, this.limit - tracked.count);
|
|
80
|
+
if (remaining <= 0) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: false,
|
|
83
|
+
remaining: 0,
|
|
84
|
+
shouldFallbackToProactive: true,
|
|
85
|
+
fallbackReason: 'limit_exceeded',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { allowed: true, remaining, shouldFallbackToProactive: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 记录一次被动回复
|
|
94
|
+
*/
|
|
95
|
+
record(messageId: string): void {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const tracked = this.messages.get(messageId);
|
|
98
|
+
|
|
99
|
+
if (tracked) {
|
|
100
|
+
tracked.count++;
|
|
101
|
+
} else {
|
|
102
|
+
// LRU 驱逐
|
|
103
|
+
if (this.messages.size >= this.maxTracked) {
|
|
104
|
+
const firstKey = this.messages.keys().next().value;
|
|
105
|
+
if (firstKey) this.messages.delete(firstKey);
|
|
106
|
+
}
|
|
107
|
+
this.messages.set(messageId, { count: 1, firstSeenAt: now });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 尝试占用一个被动回复配额(检查 + 记录原子完成)。
|
|
113
|
+
* 用于 typing 等非回复用途:占用失败时调用方应降级为主动发送(不带 msg_id)。
|
|
114
|
+
*/
|
|
115
|
+
tryAcquire(messageId: string): boolean {
|
|
116
|
+
const result = this.checkLimit(messageId);
|
|
117
|
+
if (!result.allowed) return false;
|
|
118
|
+
this.record(messageId);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 获取统计信息
|
|
124
|
+
*/
|
|
125
|
+
getStats(): { trackedMessages: number; totalReplies: number } {
|
|
126
|
+
let totalReplies = 0;
|
|
127
|
+
for (const [, v] of this.messages) {
|
|
128
|
+
totalReplies += v.count;
|
|
129
|
+
}
|
|
130
|
+
return { trackedMessages: this.messages.size, totalReplies };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 清除所有跟踪数据
|
|
135
|
+
*/
|
|
136
|
+
clear(): void {
|
|
137
|
+
this.messages.clear();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 文本消毒
|
|
3
|
+
*
|
|
4
|
+
* 剥离框架内部脚手架标签(system-reminder、thinking、reasoning 等),
|
|
5
|
+
* 保留 Markdown/HTML 用于 QQ Bot Markdown 渲染。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const INTERNAL_TAGS = [
|
|
9
|
+
// 框架脚手架标签
|
|
10
|
+
/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi,
|
|
11
|
+
/<previous_response\b[^>]*>[\s\S]*?<\/previous_response>/gi,
|
|
12
|
+
/<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*\/?\s*>/gi,
|
|
13
|
+
// 模型推理/思考内容
|
|
14
|
+
// deepseek: `think`...`/think` — 匹配完整标签块,标签名 think 必须完整
|
|
15
|
+
// 格式说明:deepseek 用 `` ` `` (反引号) 替代 XML 的 `<` `>` 作为标签定界符
|
|
16
|
+
// `think` ≡ <think> 开标签
|
|
17
|
+
// `/think` ≡ </think> 闭标签
|
|
18
|
+
/`think`[\s\S]*?`\/think`/gi,
|
|
19
|
+
/<\s*\/?\s*think\b[^>]*\/?\s*>/gi,
|
|
20
|
+
// claude: <thinking>...</thinking>
|
|
21
|
+
/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi,
|
|
22
|
+
/<\s*\/?\s*thinking\b[^>]*\/?\s*>/gi,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** 剥离内部运行时脚手架块 + 模型推理内容 */
|
|
26
|
+
export function sanitizeQQBotText(text: string): string {
|
|
27
|
+
let result = text;
|
|
28
|
+
for (const re of INTERNAL_TAGS) {
|
|
29
|
+
result = result.replace(re, '');
|
|
30
|
+
}
|
|
31
|
+
return result.trim();
|
|
32
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 流式消息控制器
|
|
3
|
+
*
|
|
4
|
+
* 核心约束:QQ 流式 API 替换模式下,已下发文本的**前缀不可变更**。
|
|
5
|
+
*
|
|
6
|
+
* 状态机:
|
|
7
|
+
* IDLE → (first chunk) → STREAMING → (complete) → DONE
|
|
8
|
+
* → (prefix changed) → DONE
|
|
9
|
+
* → (new reply) → IDLE → …
|
|
10
|
+
*
|
|
11
|
+
* onPartialReply(text) 输入:模型全量文本,持续增长。
|
|
12
|
+
* finalize() 标记:框架通知回复结束,用 lastAccepted 收尾。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReplyTarget, StreamSession } from '@tencent-connect/qqbot-nodejs';
|
|
16
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
17
|
+
import type { QQBotGateway } from '../gateway/qqbot-gateway.js';
|
|
18
|
+
|
|
19
|
+
// ── 类型 ──
|
|
20
|
+
|
|
21
|
+
export type StreamingPhase = 'idle' | 'streaming' | 'done' | 'failed';
|
|
22
|
+
|
|
23
|
+
export type StreamingSendMode = 'stream' | 'static';
|
|
24
|
+
|
|
25
|
+
export interface StreamingControllerDeps {
|
|
26
|
+
gateway: QQBotGateway;
|
|
27
|
+
target: ReplyTarget;
|
|
28
|
+
accountId: string;
|
|
29
|
+
replyToId: string;
|
|
30
|
+
log?: PluginLogger;
|
|
31
|
+
/**
|
|
32
|
+
* 文本下发通道:
|
|
33
|
+
* - 'stream'(默认)QQ 流式打印机:openStream/update/complete
|
|
34
|
+
* - 'static' 流结束时用一条普通 sendText 发完整文本
|
|
35
|
+
* partial 接收逻辑(状态机/串行/去重)在两种模式下完全一致。
|
|
36
|
+
*/
|
|
37
|
+
sendMode?: StreamingSendMode;
|
|
38
|
+
/**
|
|
39
|
+
* static 模式专用:在 finalize 收尾时把累积的完整文本一次性发出。
|
|
40
|
+
* stream 模式下不使用。未提供时 static 模式将降级为 shouldFallbackToStatic。
|
|
41
|
+
*/
|
|
42
|
+
sendStatic?: (fullText: string) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 控制器 ──
|
|
46
|
+
|
|
47
|
+
export class StreamingController {
|
|
48
|
+
private phase: StreamingPhase = 'idle';
|
|
49
|
+
private session: StreamSession | null = null;
|
|
50
|
+
|
|
51
|
+
/** QQ 已接受的最新文本 — 单源真理 */
|
|
52
|
+
private lastAcceptedFull = '';
|
|
53
|
+
|
|
54
|
+
/** 已成功发送的分片数(降级:=0 则走静态消息兜底) */
|
|
55
|
+
private sentChunkCount = 0;
|
|
56
|
+
|
|
57
|
+
/** 同步标志:收到第一个 onPartialReply 即置 true(不等 async 完成) */
|
|
58
|
+
private _hasStarted = false;
|
|
59
|
+
|
|
60
|
+
/** 串行队列 */
|
|
61
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
62
|
+
|
|
63
|
+
constructor(private readonly deps: StreamingControllerDeps) {}
|
|
64
|
+
|
|
65
|
+
/** 是否走静态(普通 sendText)下发通道 */
|
|
66
|
+
private get isStaticMode(): boolean { return this.deps.sendMode === 'static'; }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 是否处于 static 下发模式(公开访问器,供 dispatch 协同判断)。
|
|
70
|
+
* static 模式下 dispatch 不应在 tool/final 事件提前 finalize,
|
|
71
|
+
* 由 controller 靠 new_reply 检测自主分段,避免终态与发送延迟。
|
|
72
|
+
*/
|
|
73
|
+
get isStaticSendMode(): boolean { return this.isStaticMode; }
|
|
74
|
+
|
|
75
|
+
// ── 公共访问器 ──
|
|
76
|
+
|
|
77
|
+
get currentPhase(): StreamingPhase { return this.phase; }
|
|
78
|
+
|
|
79
|
+
/** 是否已成功发送至少一个流式分片 */
|
|
80
|
+
get hasSentChunks(): boolean { return this.sentChunkCount > 0; }
|
|
81
|
+
|
|
82
|
+
/** 同步标志:流式已启动(不等异步完成),用于 final 去重 */
|
|
83
|
+
get hasStarted(): boolean { return this._hasStarted; }
|
|
84
|
+
|
|
85
|
+
get isTerminal(): boolean {
|
|
86
|
+
return this.phase === 'done' || this.phase === 'failed';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get shouldFallbackToStatic(): boolean {
|
|
90
|
+
return this.isTerminal && this.sentChunkCount === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 入口 ──
|
|
94
|
+
|
|
95
|
+
onPartialReply(text: string): Promise<void> {
|
|
96
|
+
this._hasStarted = true; // 同步置位:不等异步发送,final 就能感知流式已启动
|
|
97
|
+
this.chain = this.chain.then(() => this.handleChunk(text)).catch((err) => {
|
|
98
|
+
this.deps.log?.error(`onPartialReply error: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
this.transition('failed', 'chunk_error');
|
|
100
|
+
});
|
|
101
|
+
return this.chain as Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
finalize(): Promise<void> {
|
|
105
|
+
this.chain = this.chain.then(() => this.handleFinalize()).catch((err) => {
|
|
106
|
+
this.deps.log?.error(`finalize error: ${err instanceof Error ? err.message : String(err)}`);
|
|
107
|
+
this.transition('failed', 'finalize_error');
|
|
108
|
+
});
|
|
109
|
+
return this.chain as Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* static 模式:把当前累积段立即发出并重置缓冲,**不进入终态**。
|
|
114
|
+
* 供框架 onAssistantMessageStart 回调调用(工具调用后新一段推理开始时触发)。
|
|
115
|
+
* 对齐 telegram rotateLaneForNewMessage:固化旧段 + 开始新段。
|
|
116
|
+
* 无累积内容时跳过(第一段开始时 lastAcceptedFull 为空)。
|
|
117
|
+
*/
|
|
118
|
+
flushSegment(): Promise<void> {
|
|
119
|
+
this.chain = this.chain.then(async () => {
|
|
120
|
+
if (this.isTerminal || !this.isStaticMode) return;
|
|
121
|
+
if (this.lastAcceptedFull && this.deps.sendStatic) {
|
|
122
|
+
this.deps.log?.info(`flush segment chars=${this.lastAcceptedFull.length}`);
|
|
123
|
+
try {
|
|
124
|
+
await this.deps.sendStatic(this.lastAcceptedFull);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
this.deps.log?.error(`flushSegment send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
this.transition('failed', 'flush_send_error');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// 重置缓冲,开始新一段累积(保持 streaming 态,不进终态)
|
|
131
|
+
this.lastAcceptedFull = '';
|
|
132
|
+
}
|
|
133
|
+
}).catch((err) => {
|
|
134
|
+
this.deps.log?.error(`flushSegment error: ${err instanceof Error ? err.message : String(err)}`);
|
|
135
|
+
this.transition('failed', 'flush_error');
|
|
136
|
+
});
|
|
137
|
+
return this.chain as Promise<void>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async abort(reason?: string): Promise<void> {
|
|
141
|
+
if (this.isTerminal) return;
|
|
142
|
+
this.deps.log?.warn(`aborting stream reason=${reason ?? 'manual'} sent=${this.sentChunkCount}`);
|
|
143
|
+
if (this.session) {
|
|
144
|
+
try { await this.session.complete(); } catch (e) {
|
|
145
|
+
this.deps.log?.error(`abort complete failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
146
|
+
}
|
|
147
|
+
this.session = null;
|
|
148
|
+
}
|
|
149
|
+
this.transition('failed', `abort:${reason ?? 'manual'}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── 核心逻辑 ──
|
|
153
|
+
|
|
154
|
+
private async handleChunk(text: string): Promise<void> {
|
|
155
|
+
if (this.isTerminal || !text) return;
|
|
156
|
+
|
|
157
|
+
// 正常续写:新文本前缀匹配(含空白归一化)
|
|
158
|
+
if (prefixMatches(this.lastAcceptedFull, text)) {
|
|
159
|
+
if (text.length !== this.lastAcceptedFull.length) {
|
|
160
|
+
await this.sendUpdate(text);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 无已下发内容 → 首发
|
|
166
|
+
if (!this.lastAcceptedFull) {
|
|
167
|
+
await this.sendUpdate(text);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// static 模式:分段由框架 onAssistantMessageStart 明确驱动(flushSegment)。
|
|
172
|
+
// 前缀不匹配时视为新一段开始 → 直接覆盖累积(不发送,发送由 flushSegment 负责)。
|
|
173
|
+
// 不依赖"长度回退"启发式(长度相等时会误判为追加导致文本粘连)。
|
|
174
|
+
if (this.isStaticMode) {
|
|
175
|
+
this.deps.log?.info(`static new segment: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length}`);
|
|
176
|
+
this.lastAcceptedFull = text;
|
|
177
|
+
this.sentChunkCount++;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// stream 模式:前缀不匹配 — 长度回退 → 新回复(工具调用后)
|
|
182
|
+
if (text.length < this.lastAcceptedFull.length) {
|
|
183
|
+
this.deps.log?.info(`new reply: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length}`);
|
|
184
|
+
await this.completeSession('new_reply');
|
|
185
|
+
this.lastAcceptedFull = '';
|
|
186
|
+
await this.sendUpdate(text);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 前缀不匹配但长度增长 → 模型重写尾部,同一条流追加
|
|
191
|
+
const commonLen = longestCommonPrefix(this.lastAcceptedFull, text);
|
|
192
|
+
const extra = text.slice(Math.max(commonLen, 0));
|
|
193
|
+
const merged = this.lastAcceptedFull + extra;
|
|
194
|
+
this.deps.log?.warn(
|
|
195
|
+
`prefix retry: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length} common=${commonLen} extra=${extra.length}, appending`,
|
|
196
|
+
);
|
|
197
|
+
await this.sendUpdate(merged);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async handleFinalize(): Promise<void> {
|
|
201
|
+
if (this.isTerminal) return;
|
|
202
|
+
|
|
203
|
+
// 静态模式:用累积的完整文本一次性 sendStatic 收尾
|
|
204
|
+
if (this.isStaticMode) {
|
|
205
|
+
if (this.sentChunkCount > 0 && this.lastAcceptedFull && this.deps.sendStatic) {
|
|
206
|
+
await this.completeSession();
|
|
207
|
+
// completeSession 内部 sendStatic 失败会先转 failed,此处不可回退为 done
|
|
208
|
+
if (!this.isTerminal) {
|
|
209
|
+
this.transition('done', 'finalize');
|
|
210
|
+
this.deps.log?.info(`static done chunks=${this.sentChunkCount} chars=${this.lastAcceptedFull.length}`);
|
|
211
|
+
}
|
|
212
|
+
} else if (this.sentChunkCount > 0 && this.lastAcceptedFull) {
|
|
213
|
+
// 未提供 sendStatic → 降级标记,由上层兜底发送
|
|
214
|
+
this.transition('failed', 'finalize:no_sendstatic');
|
|
215
|
+
} else {
|
|
216
|
+
this.transition('done', 'finalize:fallback');
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// stream 模式:用已下发文本收尾 — 不用框架 deliver 的文本(框架可能追加 ⚠️ 标记)
|
|
222
|
+
if (this.session) {
|
|
223
|
+
await this.completeSession();
|
|
224
|
+
this.transition('done', 'finalize');
|
|
225
|
+
this.deps.log?.info(`stream done chunks=${this.sentChunkCount} chars=${this.lastAcceptedFull.length}`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 无会话 — 视有无下发决定终态
|
|
230
|
+
if (this.sentChunkCount > 0) {
|
|
231
|
+
this.transition('done', 'finalize:no_session');
|
|
232
|
+
} else {
|
|
233
|
+
this.transition('done', 'finalize:fallback');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── QQ 交互 ──
|
|
238
|
+
|
|
239
|
+
private async sendUpdate(text: string): Promise<void> {
|
|
240
|
+
// 静态模式:仅累积文本到内存,不发任何网络请求;
|
|
241
|
+
// 收尾时由 completeSession 一次性调用 sendStatic 发送完整文本。
|
|
242
|
+
if (this.isStaticMode) {
|
|
243
|
+
if (this.phase === 'idle') {
|
|
244
|
+
this.transition('streaming', 'first_chunk');
|
|
245
|
+
this.deps.log?.info(`static accumulate (firstChunk=${text.length})`);
|
|
246
|
+
}
|
|
247
|
+
this.lastAcceptedFull = text;
|
|
248
|
+
this.sentChunkCount++;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// stream 模式:打开/复用流式会话并下发分片
|
|
253
|
+
if (!this.session) {
|
|
254
|
+
this.session = this.deps.gateway.openStream(this.deps.target, this.deps.replyToId);
|
|
255
|
+
this.transition('streaming', 'first_chunk');
|
|
256
|
+
this.deps.log?.info(`stream opened (firstChunk=${text.length})`);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
await this.session.update(text);
|
|
260
|
+
this.lastAcceptedFull = text;
|
|
261
|
+
this.sentChunkCount++;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
this.deps.log?.error(`update failed (len=${text.length}): ${err instanceof Error ? err.message : String(err)}`);
|
|
264
|
+
this.session = null;
|
|
265
|
+
this.transition('failed', 'update_error');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async completeSession(reason?: string): Promise<void> {
|
|
270
|
+
// 静态模式:把累积的完整文本一次性发出
|
|
271
|
+
if (this.isStaticMode) {
|
|
272
|
+
if (this.deps.sendStatic && this.lastAcceptedFull) {
|
|
273
|
+
this.deps.log?.info(`static send (sent=${this.sentChunkCount} chars=${this.lastAcceptedFull.length} reason=${reason ?? 'done'})`);
|
|
274
|
+
try {
|
|
275
|
+
await this.deps.sendStatic(this.lastAcceptedFull);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.deps.log?.error(`static send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
this.transition('failed', 'static_send_error');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// stream 模式:关闭流式会话,发送 DONE 帧
|
|
285
|
+
if (!this.session) return;
|
|
286
|
+
this.deps.log?.info(`completing stream (sent=${this.sentChunkCount} chars=${this.lastAcceptedFull.length} reason=${reason ?? 'done'})`);
|
|
287
|
+
try {
|
|
288
|
+
await this.session.complete();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
this.deps.log?.error(`complete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
291
|
+
}
|
|
292
|
+
this.session = null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── 状态机 ──
|
|
296
|
+
|
|
297
|
+
private transition(next: StreamingPhase, reason: string): void {
|
|
298
|
+
if (this.phase === next) return;
|
|
299
|
+
this.deps.log?.info(`phase: ${this.phase} → ${next} (${reason})`);
|
|
300
|
+
this.phase = next;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 归一化空白符:连续换行/空格合并为单个空格 */
|
|
305
|
+
function normalizeWs(s: string): string {
|
|
306
|
+
return s.replace(/\s+/g, ' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 忽略空白前缀比较 */
|
|
310
|
+
function prefixMatches(accepted: string, incoming: string): boolean {
|
|
311
|
+
if (incoming.startsWith(accepted)) return true;
|
|
312
|
+
return normalizeWs(incoming).startsWith(normalizeWs(accepted));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function longestCommonPrefix(a: string, b: string): number {
|
|
316
|
+
let i = 0;
|
|
317
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
318
|
+
return i;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── 入口判断 ──
|
|
322
|
+
|
|
323
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
324
|
+
|
|
325
|
+
export function shouldUseStreaming(
|
|
326
|
+
account: ResolvedQQBotAccount,
|
|
327
|
+
targetScope: 'c2c' | 'group' | 'channel',
|
|
328
|
+
): boolean {
|
|
329
|
+
if (targetScope !== 'c2c') return false;
|
|
330
|
+
const streaming = account.config?.streaming;
|
|
331
|
+
return !!streaming && streaming.mode !== 'off';
|
|
332
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站目标地址解析
|
|
3
|
+
*
|
|
4
|
+
* 将 OpenClaw 规范的目标地址字符串(如 qqbot:c2c:xxx / qqbot:group:xxx)
|
|
5
|
+
* 转换为 SDK 的 ReplyTarget 结构。
|
|
6
|
+
*
|
|
7
|
+
* 也导出共享的正则常量供 channel.ts messaging 段复用。
|
|
8
|
+
*/
|
|
9
|
+
import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
|
|
10
|
+
|
|
11
|
+
// ── 共享正则常量 ──
|
|
12
|
+
|
|
13
|
+
/** 32 位十六进制 OpenID(不带连字符) */
|
|
14
|
+
export const OPENID_HEX_RE = /^[0-9a-fA-F]{32}$/;
|
|
15
|
+
|
|
16
|
+
/** UUID 格式的 OpenID(带连字符) */
|
|
17
|
+
export const OPENID_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
18
|
+
|
|
19
|
+
/** 带 qqbot: 前缀的目标格式 */
|
|
20
|
+
export const QQBOT_PREFIX_RE = /^qqbot:(c2c|group|channel):/i;
|
|
21
|
+
|
|
22
|
+
/** 不带前缀但有 scope 标识 */
|
|
23
|
+
export const SCOPE_PREFIX_RE = /^(c2c|group|channel):/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 判断 ID 是否看起来像 QQ Bot 目标格式
|
|
27
|
+
*/
|
|
28
|
+
export function isQQBotTarget(id: string): boolean {
|
|
29
|
+
if (QQBOT_PREFIX_RE.test(id)) return true;
|
|
30
|
+
if (SCOPE_PREFIX_RE.test(id)) return true;
|
|
31
|
+
if (OPENID_HEX_RE.test(id)) return true;
|
|
32
|
+
return OPENID_UUID_RE.test(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 规范化目标地址字符串
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeTarget(target: string): string | undefined {
|
|
39
|
+
const id = target.replace(/^qqbot:/i, '');
|
|
40
|
+
if (id.startsWith('c2c:') || id.startsWith('group:') || id.startsWith('channel:')) {
|
|
41
|
+
return `qqbot:${id}`;
|
|
42
|
+
}
|
|
43
|
+
if (OPENID_HEX_RE.test(id)) return `qqbot:c2c:${id}`;
|
|
44
|
+
if (OPENID_UUID_RE.test(id)) return `qqbot:c2c:${id}`;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析目标地址字符串为 SDK ReplyTarget
|
|
50
|
+
*
|
|
51
|
+
* 注意:此函数总是返回 ReplyTarget,即使输入无效(targetId 可能为空字符串)
|
|
52
|
+
* 如果需要严格验证,请使用 tryParseTarget
|
|
53
|
+
*/
|
|
54
|
+
export function parseTarget(to: string): ReplyTarget {
|
|
55
|
+
// 空字符串或无效输入返回默认值
|
|
56
|
+
if (!to || typeof to !== 'string') {
|
|
57
|
+
return { scope: 'c2c', targetId: '' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const id = to.replace(/^qqbot:/i, '');
|
|
61
|
+
|
|
62
|
+
if (id.startsWith('c2c:')) {
|
|
63
|
+
return { scope: 'c2c', targetId: id.slice(4) };
|
|
64
|
+
}
|
|
65
|
+
if (id.startsWith('group:')) {
|
|
66
|
+
return { scope: 'group', targetId: id.slice(6) };
|
|
67
|
+
}
|
|
68
|
+
// channel 不被 SDK ChatScope 支持,当作 c2c 处理
|
|
69
|
+
if (id.startsWith('channel:')) {
|
|
70
|
+
return { scope: 'c2c', targetId: id.slice(8) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 默认当作 c2c(32 位十六进制 / UUID 格式的 openid)
|
|
74
|
+
return { scope: 'c2c', targetId: id };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 严格解析目标地址字符串,验证输入有效性
|
|
79
|
+
* @returns ReplyTarget 或 null(如果输入无效)
|
|
80
|
+
*/
|
|
81
|
+
export function tryParseTarget(to: string): ReplyTarget | null {
|
|
82
|
+
// 空字符串或无效输入返回 null
|
|
83
|
+
if (!to || typeof to !== 'string') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const id = to.replace(/^qqbot:/i, '');
|
|
88
|
+
|
|
89
|
+
// 检查是否是有效的目标格式
|
|
90
|
+
if (id.startsWith('c2c:')) {
|
|
91
|
+
const targetId = id.slice(4);
|
|
92
|
+
if (!targetId) return null; // c2c: 后面必须有内容
|
|
93
|
+
return { scope: 'c2c', targetId };
|
|
94
|
+
}
|
|
95
|
+
if (id.startsWith('group:')) {
|
|
96
|
+
const targetId = id.slice(6);
|
|
97
|
+
if (!targetId) return null; // group: 后面必须有内容
|
|
98
|
+
return { scope: 'group', targetId };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 默认当作 c2c(32 位十六进制 / UUID 格式的 openid)
|
|
102
|
+
// 但必须匹配有效的 openid 格式
|
|
103
|
+
if (OPENID_HEX_RE.test(id) || OPENID_UUID_RE.test(id)) {
|
|
104
|
+
return { scope: 'c2c', targetId: id };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 无效格式返回 null
|
|
108
|
+
return null;
|
|
109
|
+
}
|