@jerryliang122/openclaw-qqbot 1.0.0 → 1.0.2
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/README.md +2 -13
- package/README.zh.md +2 -13
- package/dist/index.cjs +9371 -9414
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -5
- package/index.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/adapter/pairing.ts +14 -1
- package/src/adapter/webhook.ts +16 -3
- package/src/commands/bot-approve.ts +2 -2
- package/src/commands/bot-group-always.ts +1 -1
- package/src/commands/bot-help.ts +1 -1
- package/src/commands/bot-streaming.ts +1 -1
- package/src/commands/bot-version.ts +1 -12
- package/src/commands/config-util.ts +8 -0
- package/src/commands/index.ts +49 -14
- package/src/dispatch/dispatch.ts +1 -1
- package/src/features/approval-capability.ts +19 -6
- package/src/features/credential-backup.ts +8 -3
- package/src/features/history-store.ts +8 -1
- package/src/features/quota-manager.ts +5 -2
- package/src/features/typing-refresh.ts +9 -1
- package/src/gateway/event-handlers.ts +36 -12
- package/src/gateway/lifecycle.ts +10 -6
- package/src/gateway/qqbot-gateway.ts +24 -1
- package/src/middleware/access-control.ts +3 -1
- package/src/middleware/secret-capture.ts +5 -3
- package/src/outbound/debounce.ts +19 -2
- package/src/outbound-adapter.ts +10 -1
- package/src/setup/finalize.ts +3 -2
- package/src/tools/platform.ts +8 -0
- package/src/tools/secret-input.ts +8 -0
- package/src/types-plugin.ts +1 -0
- package/src/types.ts +0 -5
- package/src/typing-lifecycle.ts +9 -4
- package/skills/qqbot-upgrade/SKILL.md +0 -56
- package/src/commands/bot-upgrade.ts +0 -56
- package/src/features/update-checker.ts +0 -166
package/src/gateway/lifecycle.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
|
22
22
|
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
23
23
|
import { registerGateway, unregisterGateway, getGateway } from '../outbound/outbound-service.js';
|
|
24
24
|
import { saveCredentialBackup, loadCredentialBackup } from '../features/credential-backup.js';
|
|
25
|
-
import { triggerUpdateCheck } from '../features/update-checker.js';
|
|
26
25
|
import { syncCommandPanels } from '../features/command-panel.js';
|
|
27
26
|
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from '../features/approval-capability.js';
|
|
28
27
|
import { cleanupTypingByAccount } from '../typing-lifecycle.js';
|
|
@@ -74,6 +73,14 @@ export async function startAccountWithCredentialRecovery(ctx: StartAccountContex
|
|
|
74
73
|
}
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// 账号启动留痕:哪个账号、以什么凭证来源、什么传输模式启动了
|
|
77
|
+
// (secret 只打来源不打值)——多账号排障时定位配置实际生效形态
|
|
78
|
+
log.info(
|
|
79
|
+
`account starting accountId=${account.accountId} appId=${account.appId || '(none)'}`
|
|
80
|
+
+ ` enabled=${account.enabled ?? true} secretSource=${account.secretSource ?? '?'}`
|
|
81
|
+
+ ` transport=${account.config.transport}${account.config.transport === 'webhook' ? ` path=${account.config.webhook?.path ?? '(default)'}` : ''}`,
|
|
82
|
+
);
|
|
83
|
+
|
|
77
84
|
// 创建 gateway 实例并注册
|
|
78
85
|
const gw = new QQBotGateway(account, runtime, log);
|
|
79
86
|
registerGateway(account.accountId, gw);
|
|
@@ -111,16 +118,13 @@ async function initFeatures(
|
|
|
111
118
|
ctx: StartAccountContext,
|
|
112
119
|
log: PluginLogger,
|
|
113
120
|
): Promise<void> {
|
|
114
|
-
// 1.
|
|
115
|
-
triggerUpdateCheck(log);
|
|
116
|
-
|
|
117
|
-
// 2. 注册 "approval.native" 运行时上下文。框架的 approval bootstrap 监听此上下文,
|
|
121
|
+
// 1. 注册 "approval.native" 运行时上下文。框架的 approval bootstrap 监听此上下文,
|
|
118
122
|
// 据此为该账户自动 spawn native 审批 handler(消费 exec/plugin approval 事件,
|
|
119
123
|
// 回调 qqbotPlugin.approvalCapability.nativeRuntime 的 availability/presentation/transport)。
|
|
120
124
|
// 账户停止时 abortSignal 触发,上下文自动注销。
|
|
121
125
|
registerApprovalNativeContext(account.accountId, ctx);
|
|
122
126
|
|
|
123
|
-
//
|
|
127
|
+
// 2. 指令面板同步:把 openclaw essential 原生指令注册到 QQ Bot 指令面板(/v2/panels)。
|
|
124
128
|
// 每进程每账号仅一次;失败只记日志并由 once-guard 释放等待重连重试,不影响消息收发。
|
|
125
129
|
syncCommandPanels(account, ctx.cfg, log).catch((e) => {
|
|
126
130
|
log?.error(`[qqbot:${account.accountId}] command panel sync error: ${e}`);
|
|
@@ -306,6 +306,25 @@ export class QQBotGateway {
|
|
|
306
306
|
const { accountId, appId } = this.account;
|
|
307
307
|
const senderName = this.account.config.name ?? appId;
|
|
308
308
|
|
|
309
|
+
// 出站成功的统一 INFO 痕迹:gateway 的 sendText/sendMedia/sendVoice/sendVideo/
|
|
310
|
+
// sendFile 与中间件对 bot.sendText 的直调全部落到这两个被包装的方法上。
|
|
311
|
+
// 此前成功发送在插件层零日志,只能靠 SDK 的 `<<< Status` 状态行倒推。
|
|
312
|
+
const MEDIA_KIND_BY_TYPE = new Map<number, string>([
|
|
313
|
+
[1, 'image'], [2, 'video'], [3, 'voice'], [4, 'file'],
|
|
314
|
+
]);
|
|
315
|
+
const logTx = (
|
|
316
|
+
kind: string,
|
|
317
|
+
target: { scope?: string; targetId?: string; msgId?: string },
|
|
318
|
+
msg: { id?: string },
|
|
319
|
+
contentLen?: number,
|
|
320
|
+
): void => {
|
|
321
|
+
this.log.info(
|
|
322
|
+
`[tx] sent kind=${kind} scope=${target.scope ?? '?'} target=${target.targetId ?? '?'}`
|
|
323
|
+
+ ` msgId=${msg.id || '-'} passive=${target.msgId ? 'true' : 'false'}`
|
|
324
|
+
+ (contentLen !== undefined ? ` contentLen=${contentLen}` : ''),
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
309
328
|
const storeEntry = (msg: MessageResponse, content: string, target: { scope: string; targetId?: string }, mediaKind?: string): void => {
|
|
310
329
|
// 出站 id 先登记回声表(不依赖 ext_info.ref_idx 是否返回),
|
|
311
330
|
// 供 inboundGuard 识别平台回投的 bot 自身消息
|
|
@@ -352,6 +371,7 @@ export class QQBotGateway {
|
|
|
352
371
|
const origSendText = this.bot.sendText.bind(this.bot);
|
|
353
372
|
this.bot.sendText = async (target, text, ...rest) => {
|
|
354
373
|
const result = await origSendText(target, text, ...rest);
|
|
374
|
+
logTx('text', target, result, text.length);
|
|
355
375
|
storeEntry(result, text, target);
|
|
356
376
|
return result;
|
|
357
377
|
};
|
|
@@ -361,7 +381,10 @@ export class QQBotGateway {
|
|
|
361
381
|
this.bot.sendMedia = async (params: any) => {
|
|
362
382
|
const result = await origSendMedia(params);
|
|
363
383
|
const msg = (result as any).message as MessageResponse | undefined;
|
|
364
|
-
if (msg)
|
|
384
|
+
if (msg) {
|
|
385
|
+
logTx(MEDIA_KIND_BY_TYPE.get(params.fileType) ?? 'media', params.target ?? {}, msg);
|
|
386
|
+
storeEntry(msg, '', params.target ?? { scope: '' }, params.mediaKind);
|
|
387
|
+
}
|
|
365
388
|
return result;
|
|
366
389
|
};
|
|
367
390
|
|
|
@@ -116,7 +116,9 @@ async function checkPairingMode(
|
|
|
116
116
|
].join('\n');
|
|
117
117
|
|
|
118
118
|
ctx.log?.info?.(`[access] pairing required for ${opts.senderId}`);
|
|
119
|
-
await ctx.bot.sendText(ctx.replyTarget, reply).catch(() => {
|
|
119
|
+
await ctx.bot.sendText(ctx.replyTarget, reply).catch((err: unknown) => {
|
|
120
|
+
ctx.log?.warn?.(`[access] pairing challenge send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
});
|
|
120
122
|
ctx.stop('access:pairing_required');
|
|
121
123
|
} catch (err) {
|
|
122
124
|
ctx.log?.error?.(`[access] pairing error: ${(err as Error).message}`);
|
|
@@ -49,14 +49,16 @@ async function replyToUser(
|
|
|
49
49
|
try {
|
|
50
50
|
await ctx.bot.sendText(ctx.replyTarget, text);
|
|
51
51
|
return;
|
|
52
|
-
} catch {
|
|
52
|
+
} catch (err) {
|
|
53
53
|
rollback();
|
|
54
|
-
|
|
54
|
+
ctx.log?.warn?.(`[secret] passive reply failed, falling back to proactive: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
const proactiveTarget = { scope: 'c2c' as const, targetId: ctx.message.senderId as string };
|
|
59
|
-
await ctx.bot.sendText(proactiveTarget, text).catch(() => {
|
|
59
|
+
await ctx.bot.sendText(proactiveTarget, text).catch((err: unknown) => {
|
|
60
|
+
ctx.log?.warn?.(`[secret] proactive reply failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
});
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
function formatSetFailure(name: string, result: SecretsStoreSetResult): string {
|
package/src/outbound/debounce.ts
CHANGED
|
@@ -13,6 +13,9 @@ const DEFAULT_WINDOW_MS = 1500;
|
|
|
13
13
|
const DEFAULT_MAX_WAIT_MS = 8000;
|
|
14
14
|
const DEFAULT_SEPARATOR = '\n\n---\n\n';
|
|
15
15
|
|
|
16
|
+
// 最小结构类型:兼容 PluginLogger 与 SDK Logger(与 config-util 同款)
|
|
17
|
+
type WarnSink = { warn?: (msg: string, meta?: Record<string, unknown>) => void };
|
|
18
|
+
|
|
16
19
|
interface PendingDeliver {
|
|
17
20
|
texts: string[];
|
|
18
21
|
firstAt: number;
|
|
@@ -26,15 +29,18 @@ export class DeliverDebouncer {
|
|
|
26
29
|
private readonly maxWaitMs: number;
|
|
27
30
|
private readonly separator: string;
|
|
28
31
|
private readonly flush: (targetId: string, mergedText: string) => Promise<void>;
|
|
32
|
+
private readonly log?: WarnSink;
|
|
29
33
|
|
|
30
34
|
constructor(
|
|
31
35
|
config: DeliverDebounceConfig | undefined,
|
|
32
36
|
flush: (targetId: string, mergedText: string) => Promise<void>,
|
|
37
|
+
log?: WarnSink,
|
|
33
38
|
) {
|
|
34
39
|
this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
35
40
|
this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
36
41
|
this.separator = config?.separator ?? DEFAULT_SEPARATOR;
|
|
37
42
|
this.flush = flush;
|
|
43
|
+
this.log = log;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
get enabled(): boolean {
|
|
@@ -60,7 +66,7 @@ export class DeliverDebouncer {
|
|
|
60
66
|
if (Date.now() - existing.firstAt >= this.maxWaitMs) {
|
|
61
67
|
await this.doFlush(targetId);
|
|
62
68
|
} else {
|
|
63
|
-
existing.timer = setTimeout(() => this.
|
|
69
|
+
existing.timer = setTimeout(() => this.scheduledFlush(targetId), this.windowMs);
|
|
64
70
|
}
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
@@ -70,13 +76,24 @@ export class DeliverDebouncer {
|
|
|
70
76
|
const pending: PendingDeliver = {
|
|
71
77
|
texts: [text],
|
|
72
78
|
firstAt: Date.now(),
|
|
73
|
-
timer: setTimeout(() => this.
|
|
79
|
+
timer: setTimeout(() => this.scheduledFlush(targetId), this.windowMs),
|
|
74
80
|
resolve,
|
|
75
81
|
};
|
|
76
82
|
this.pending.set(targetId, pending);
|
|
77
83
|
});
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 定时器触发的 flush:promise 被 setTimeout 回调丢弃,失败若不就地
|
|
88
|
+
* 捕获会成为 unhandledRejection——记 WARN 封口(pending.resolve 在
|
|
89
|
+
* doFlush 的 finally 中仍会执行,入队方不会被悬挂)。
|
|
90
|
+
*/
|
|
91
|
+
private scheduledFlush(targetId: string): void {
|
|
92
|
+
void this.doFlush(targetId).catch((err: unknown) => {
|
|
93
|
+
this.log?.warn?.(`[debounce] timer flush failed target=${targetId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
private async doFlush(targetId: string): Promise<void> {
|
|
81
98
|
const pending = this.pending.get(targetId);
|
|
82
99
|
if (!pending) return;
|
package/src/outbound-adapter.ts
CHANGED
|
@@ -28,6 +28,11 @@ import { isApprovalPayload } from './features/approval-utils.js';
|
|
|
28
28
|
import { TEXT_CHUNK_LIMIT } from './constants.js';
|
|
29
29
|
import { tryGetQQBotRuntime } from './runtime.js';
|
|
30
30
|
import { getAdapters } from './adapter/resolve.js';
|
|
31
|
+
import { createPluginLogger } from './utils/plugin-logger.js';
|
|
32
|
+
|
|
33
|
+
// 契约入口(框架直发 / message 工具 / cron announce)无调用方 logger 可传,
|
|
34
|
+
// 用模块级 logger 兜底——frameworkSink 每次调用时 resolve,register 早期自动降级 console
|
|
35
|
+
const alog = createPluginLogger({ prefix: '[outbound]' });
|
|
31
36
|
|
|
32
37
|
export type SendTextFn = (params: {
|
|
33
38
|
to: string;
|
|
@@ -178,6 +183,8 @@ export function createQQBotOutboundAdapter(params: QQBotOutboundAdapterParams):
|
|
|
178
183
|
if (canReply && replyToId && result.error) {
|
|
179
184
|
rollback();
|
|
180
185
|
log?.debug?.(`[${resolvedAccountId}] rollback quota: send failed`);
|
|
186
|
+
} else if (!canReply) {
|
|
187
|
+
log?.info?.(`[${resolvedAccountId}] [quota] fallback to proactive media send: quota exhausted or no msgId`);
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
return result;
|
|
@@ -287,6 +294,7 @@ export function createQQBotChannelOutbound(params: QQBotOutboundAdapterParams =
|
|
|
287
294
|
accountId: ctx.accountId ?? undefined,
|
|
288
295
|
replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
|
|
289
296
|
account,
|
|
297
|
+
log: alog,
|
|
290
298
|
});
|
|
291
299
|
if (result.error) throw new Error(result.error);
|
|
292
300
|
return { channel: 'qqbot', messageId: result.messageId ?? '' };
|
|
@@ -302,7 +310,8 @@ export function createQQBotChannelOutbound(params: QQBotOutboundAdapterParams =
|
|
|
302
310
|
accountId: resolvedAccountId,
|
|
303
311
|
replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
|
|
304
312
|
account,
|
|
305
|
-
agentId: resolveMCPAgentId(ctx.to, resolvedAccountId, ctx.cfg),
|
|
313
|
+
agentId: resolveMCPAgentId(ctx.to, resolvedAccountId, ctx.cfg, alog),
|
|
314
|
+
log: alog,
|
|
306
315
|
});
|
|
307
316
|
if (result.error) throw new Error(result.error);
|
|
308
317
|
return { channel: 'qqbot', messageId: result.messageId ?? '' };
|
package/src/setup/finalize.ts
CHANGED
|
@@ -49,13 +49,14 @@ async function linkViaQrCode(cfg: OpenClawConfig, _accountId: string, prompter:
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function linkViaManual(cfg: OpenClawConfig, _accountId: string, prompter: Prompter): Promise<OpenClawConfig> {
|
|
52
|
+
async function linkViaManual(cfg: OpenClawConfig, _accountId: string, prompter: Prompter, rt: Runtime): Promise<OpenClawConfig> {
|
|
53
53
|
const appIdInput = await prompter.text({ message: '请输入 QQ Bot AppID', validate: (v) => v.trim() ? undefined : 'AppID 不能为空' });
|
|
54
54
|
const secret = await prompter.text({ message: '请输入 QQ Bot AppSecret', validate: (v) => v.trim() ? undefined : 'AppSecret 不能为空' });
|
|
55
55
|
const appId = appIdInput.trim();
|
|
56
56
|
const key = resolveAccountKey(cfg, appId);
|
|
57
57
|
let next = applyQQBotAccountConfig(cfg, key, { appId, clientSecret: secret.trim() });
|
|
58
58
|
next = applyAccountDefaults(next, key);
|
|
59
|
+
rt.log(`手动绑定完成!账户: ${key} (AppID: ${appId})`);
|
|
59
60
|
await prompter.note('✔ QQ Bot 配置完成!', 'QQ Bot');
|
|
60
61
|
return next;
|
|
61
62
|
}
|
|
@@ -82,7 +83,7 @@ export async function finalizeQQBotSetup(params: {
|
|
|
82
83
|
if (mode === 'qr') {
|
|
83
84
|
next = await linkViaQrCode(next, accountId, params.prompter, params.runtime);
|
|
84
85
|
} else if (mode === 'manual') {
|
|
85
|
-
next = await linkViaManual(next, accountId, params.prompter);
|
|
86
|
+
next = await linkViaManual(next, accountId, params.prompter, params.runtime);
|
|
86
87
|
} else if (!configured) {
|
|
87
88
|
await params.prompter.note('您可以稍后运行以下命令重新配置:\n openclaw channels add', 'QQ Bot');
|
|
88
89
|
}
|
package/src/tools/platform.ts
CHANGED
|
@@ -2,6 +2,11 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
|
2
2
|
import { listQQBotAccountIds } from '../config.js';
|
|
3
3
|
import { getBotForAccount } from '../bot-instance.js';
|
|
4
4
|
import { getRequestAccountId } from '../request-context.js';
|
|
5
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
6
|
+
|
|
7
|
+
// 工具 execute 无 logger 参数;request-context 活跃期间 enrichMeta
|
|
8
|
+
// 自动带上 accountId/openId/messageId
|
|
9
|
+
const toolLog = createPluginLogger({ prefix: '[tool:platform]' });
|
|
5
10
|
|
|
6
11
|
// ========== JSON Schema ==========
|
|
7
12
|
|
|
@@ -111,6 +116,7 @@ export function registerPlatformTool(api: OpenClawPluginApi): void {
|
|
|
111
116
|
try {
|
|
112
117
|
const bot = getBotForAccount(accountId);
|
|
113
118
|
const apiGateway = bot.api;
|
|
119
|
+
const startedAt = Date.now();
|
|
114
120
|
|
|
115
121
|
let data: unknown;
|
|
116
122
|
switch (method) {
|
|
@@ -131,10 +137,12 @@ export function registerPlatformTool(api: OpenClawPluginApi): void {
|
|
|
131
137
|
break;
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
toolLog.info(`${method} ${p.path} ok (${Date.now() - startedAt}ms)`, { accountId });
|
|
134
141
|
return json(data);
|
|
135
142
|
} catch (err: unknown) {
|
|
136
143
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
137
144
|
const apiErr = err as { httpStatus?: number; bizCode?: number; path?: string };
|
|
145
|
+
toolLog.warn(`${method} ${p.path} failed status=${apiErr.httpStatus ?? '?'} code=${apiErr.bizCode ?? '?'}: ${errMsg}`, { accountId });
|
|
138
146
|
return json({
|
|
139
147
|
error: errMsg,
|
|
140
148
|
status: apiErr.httpStatus,
|
|
@@ -22,8 +22,13 @@ import {
|
|
|
22
22
|
} from '../features/secret-input-store.js';
|
|
23
23
|
import { getRequestAccountId, getRequestTarget } from '../request-context.js';
|
|
24
24
|
import { parseTarget } from '../outbound/target.js';
|
|
25
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
25
26
|
import type { InlineKeyboard, KeyboardButton } from '../types.js';
|
|
26
27
|
|
|
28
|
+
// 工具 execute 无 logger 参数可传;request-context 在工具执行期间处于活跃,
|
|
29
|
+
// 模块级 logger 的 enrichMeta 会自动带上 accountId/openId/messageId
|
|
30
|
+
const toolLog = createPluginLogger({ prefix: '[tool:secret-input]' });
|
|
31
|
+
|
|
27
32
|
// ========== JSON Schema ==========
|
|
28
33
|
|
|
29
34
|
const SecretInputSchema = {
|
|
@@ -162,11 +167,14 @@ export function registerSecretInputTool(api: OpenClawPluginApi): void {
|
|
|
162
167
|
);
|
|
163
168
|
} catch (err) {
|
|
164
169
|
cancelPendingSecretInput(accountId, parsed.targetId);
|
|
170
|
+
toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
|
|
165
171
|
return json({
|
|
166
172
|
error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
|
|
167
173
|
});
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
toolLog.info(`card sent, pending registered name=${name} ttlMinutes=${DEFAULT_SECRET_INPUT_TTL_MS / 60_000}`);
|
|
177
|
+
|
|
170
178
|
return json({
|
|
171
179
|
ok: true,
|
|
172
180
|
name,
|
package/src/types-plugin.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -163,11 +163,6 @@ export interface QQBotAccountConfig {
|
|
|
163
163
|
* 禁用时:公网 URL 始终由插件先下载到本地,再以 Base64 上传(适用于 QQ 平台无法访问目标 URL 的场景)
|
|
164
164
|
*/
|
|
165
165
|
urlDirectUpload?: boolean;
|
|
166
|
-
/**
|
|
167
|
-
* /bot-upgrade 指令返回的升级指引网址
|
|
168
|
-
* 默认: 本仓库 CHANGELOG(含各版本升级说明)
|
|
169
|
-
*/
|
|
170
|
-
upgradeUrl?: string;
|
|
171
166
|
/**
|
|
172
167
|
* 群消息是否默认需要 @机器人才响应(默认 true)
|
|
173
168
|
* 优先级低于 groups.{groupId}.requireMention 和 groups."*".requireMention
|
package/src/typing-lifecycle.ts
CHANGED
|
@@ -59,14 +59,17 @@ export async function startTypingWithRenewal(
|
|
|
59
59
|
|
|
60
60
|
log?.debug?.(`[${accountId}] typing started: ${sessionKey}`);
|
|
61
61
|
|
|
62
|
-
const timer = setTimeout(
|
|
63
|
-
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
// promise 被 setTimeout 回调丢弃,失败不就地捕获会成为 unhandledRejection
|
|
64
|
+
void handleTypingRenewal({
|
|
64
65
|
sessionKey,
|
|
65
66
|
accountId,
|
|
66
67
|
to,
|
|
67
68
|
replyToId,
|
|
68
69
|
log,
|
|
69
70
|
sendTyping,
|
|
71
|
+
}).catch((err: unknown) => {
|
|
72
|
+
log?.warn?.(`[${accountId}] typing renewal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
70
73
|
});
|
|
71
74
|
}, TYPING_RENEWAL_MS);
|
|
72
75
|
|
|
@@ -120,8 +123,10 @@ async function handleTypingRenewal(
|
|
|
120
123
|
state.renewalCount += 1;
|
|
121
124
|
log?.debug?.(`[${accountId}] typing renewed #${state.renewalCount}: ${sessionKey}`);
|
|
122
125
|
|
|
123
|
-
state.timer = setTimeout(
|
|
124
|
-
|
|
126
|
+
state.timer = setTimeout(() => {
|
|
127
|
+
void handleTypingRenewal(params).catch((err: unknown) => {
|
|
128
|
+
log?.warn?.(`[${accountId}] typing renewal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
});
|
|
125
130
|
}, TYPING_RENEWAL_MS);
|
|
126
131
|
|
|
127
132
|
activeTypingSessions.set(sessionKey, state);
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: qqbot-upgrade
|
|
3
|
-
description: 将 openclaw-qqbot 插件(@jerryliang122/openclaw-qqbot)升级到最新版本。当用户要求更新 QQ 机器人插件、升级 qqbot 扩展或同步最新版时使用。
|
|
4
|
-
metadata: {"openclaw":{"emoji":"⬆️","requires":{"config":["channels.qqbot"]}}}
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# QQBot 插件升级
|
|
8
|
-
|
|
9
|
-
## 何时使用
|
|
10
|
-
|
|
11
|
-
用户表达以下意图时,应在**本机终端**执行升级命令(不要只口头说明「去官网升级」):
|
|
12
|
-
|
|
13
|
-
- 更新 / 升级 `openclaw-qqbot` / QQBot 插件 / QQ 机器人插件
|
|
14
|
-
- 同步本仓库(jerryliang122/qqbot-openclaw)最新版
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## 标准命令
|
|
19
|
-
|
|
20
|
-
npm 包(推荐):
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
openclaw plugins install @jerryliang122/openclaw-qqbot
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
指定版本:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
openclaw plugins install @jerryliang122/openclaw-qqbot@<version>
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
或从 GitHub(无法访问 npm 时):
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
openclaw plugins install git+https://github.com/jerryliang122/qqbot-openclaw.git
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
升级完成后重启网关使其生效:
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
openclaw gateway restart
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## 前置条件
|
|
47
|
-
|
|
48
|
-
- OpenClaw >= 2026.9.2(低版本框架无法加载本插件)
|
|
49
|
-
- 需 **Node.js / npm** 环境可访问 npm registry(或 GitHub)
|
|
50
|
-
- 从 2.x 旧版本升级:先阅读仓库 CHANGELOG 的 Breaking Changes 与配置迁移表
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## 执行后
|
|
55
|
-
|
|
56
|
-
根据命令退出码与终端输出向用户简要汇报:成功则提示新版本号并确认网关已重启;失败则摘录关键错误并提示检查网络、npm 权限与 OpenClaw 版本。
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { SlashCommand } from '@tencent-connect/qqbot-nodejs';
|
|
2
|
-
import type { ResolvedQQBotAccount } from '../types.js';
|
|
3
|
-
import { getPackageVersion } from '../utils/pkg-version.js';
|
|
4
|
-
import { getUpdateInfo } from '../features/update-checker.js';
|
|
5
|
-
import { checkCommandAuth } from './config-util.js';
|
|
6
|
-
|
|
7
|
-
const PLUGIN_VERSION = getPackageVersion();
|
|
8
|
-
const DEFAULT_UPGRADE_URL = 'https://github.com/jerryliang122/qqbot-openclaw/blob/main/CHANGELOG.md';
|
|
9
|
-
const GITHUB_URL = 'https://github.com/jerryliang122/qqbot-openclaw';
|
|
10
|
-
|
|
11
|
-
/** /bot-upgrade — 检查更新并查看升级指引 */
|
|
12
|
-
export function botUpgrade(account: ResolvedQQBotAccount): SlashCommand {
|
|
13
|
-
return {
|
|
14
|
-
name: 'bot-upgrade',
|
|
15
|
-
description: '检查更新并查看升级指引',
|
|
16
|
-
scope: 'c2c',
|
|
17
|
-
authorized: checkCommandAuth,
|
|
18
|
-
usage: [
|
|
19
|
-
'/bot-upgrade 检查是否有新版本',
|
|
20
|
-
].join('\n'),
|
|
21
|
-
handler: async () => {
|
|
22
|
-
const url = account.config.upgradeUrl ?? DEFAULT_UPGRADE_URL;
|
|
23
|
-
const info = await getUpdateInfo();
|
|
24
|
-
|
|
25
|
-
if (info.checkedAt === 0) {
|
|
26
|
-
return '⏳ 版本检查中,请稍后再试';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (info.error) {
|
|
30
|
-
return [
|
|
31
|
-
'❌ 主机网络访问异常,无法检查更新',
|
|
32
|
-
'',
|
|
33
|
-
`查看升级指引:[点击查看](${url})`,
|
|
34
|
-
].join('\n');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (!info.hasUpdate) {
|
|
38
|
-
return [
|
|
39
|
-
`✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
|
|
40
|
-
'',
|
|
41
|
-
`项目地址:[GitHub](${GITHUB_URL})`,
|
|
42
|
-
].join('\n');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return [
|
|
46
|
-
'🆕 发现新版本',
|
|
47
|
-
'',
|
|
48
|
-
`当前版本:**v${PLUGIN_VERSION}**`,
|
|
49
|
-
`最新版本:**v${info.latest}**`,
|
|
50
|
-
'',
|
|
51
|
-
`📖 升级指引:[点击查看](${url})`,
|
|
52
|
-
`🌟 GitHub 仓库:[点击前往](${GITHUB_URL})`,
|
|
53
|
-
].join('\n');
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 版本检查器
|
|
3
|
-
*
|
|
4
|
-
* - triggerUpdateCheck(): gateway 启动时调用,后台预热缓存
|
|
5
|
-
* - getUpdateInfo(): 每次实时查询 npm registry,返回最新结果
|
|
6
|
-
*
|
|
7
|
-
* 使用 HTTPS 直接请求 npm registry API(不依赖 npm CLI),
|
|
8
|
-
* 支持多 registry fallback:npmjs.org → npmmirror.com,解决国内网络问题。
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import https from "node:https";
|
|
12
|
-
import { getPackageVersion } from "../utils/pkg-version.js";
|
|
13
|
-
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
14
|
-
|
|
15
|
-
const PKG_NAME = "@jerryliang122/openclaw-qqbot";
|
|
16
|
-
const ENCODED_PKG = encodeURIComponent(PKG_NAME);
|
|
17
|
-
|
|
18
|
-
const REGISTRIES = [
|
|
19
|
-
`https://registry.npmjs.org/${ENCODED_PKG}`,
|
|
20
|
-
`https://registry.npmmirror.com/${ENCODED_PKG}`,
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
let CURRENT_VERSION = getPackageVersion();
|
|
24
|
-
|
|
25
|
-
export interface UpdateInfo {
|
|
26
|
-
current: string;
|
|
27
|
-
latest: string | null;
|
|
28
|
-
stable: string | null;
|
|
29
|
-
alpha: string | null;
|
|
30
|
-
hasUpdate: boolean;
|
|
31
|
-
checkedAt: number;
|
|
32
|
-
error?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function fetchJson(url: string, timeoutMs: number): Promise<any> {
|
|
36
|
-
return new Promise((resolve, reject) => {
|
|
37
|
-
const req = https.get(url, { timeout: timeoutMs, headers: { Accept: "application/json" } }, (res) => {
|
|
38
|
-
if (res.statusCode !== 200) {
|
|
39
|
-
res.resume();
|
|
40
|
-
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
let data = "";
|
|
44
|
-
res.on("data", (chunk: string) => { data += chunk; });
|
|
45
|
-
res.on("end", () => {
|
|
46
|
-
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
req.on("error", reject);
|
|
50
|
-
req.on("timeout", () => { req.destroy(); reject(new Error(`timeout fetching ${url}`)); });
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function fetchDistTags(log?: PluginLogger): Promise<Record<string, string>> {
|
|
55
|
-
for (const url of REGISTRIES) {
|
|
56
|
-
try {
|
|
57
|
-
const json = await fetchJson(url, 10_000);
|
|
58
|
-
const tags = json["dist-tags"];
|
|
59
|
-
if (tags && typeof tags === "object") return tags;
|
|
60
|
-
} catch (e: any) {
|
|
61
|
-
log?.debug(`[update-checker] ${url} failed: ${e.message}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
throw new Error("all registries failed");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function buildUpdateInfo(tags: Record<string, string>): UpdateInfo {
|
|
68
|
-
const currentIsPrerelease = CURRENT_VERSION.includes("-");
|
|
69
|
-
const stableTag = tags.latest || null;
|
|
70
|
-
const alphaTag = tags.alpha || null;
|
|
71
|
-
|
|
72
|
-
const compareTarget = currentIsPrerelease ? alphaTag : stableTag;
|
|
73
|
-
|
|
74
|
-
const hasUpdate = typeof compareTarget === "string"
|
|
75
|
-
&& compareTarget !== CURRENT_VERSION
|
|
76
|
-
&& compareVersions(compareTarget, CURRENT_VERSION) > 0;
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
current: CURRENT_VERSION,
|
|
80
|
-
latest: compareTarget,
|
|
81
|
-
stable: stableTag,
|
|
82
|
-
alpha: alphaTag,
|
|
83
|
-
hasUpdate,
|
|
84
|
-
checkedAt: Date.now(),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Gateway 启动时调用,后台预热版本缓存。
|
|
90
|
-
* log 通过闭包传递,避免模块级可变状态。
|
|
91
|
-
*/
|
|
92
|
-
export function triggerUpdateCheck(log: PluginLogger): void {
|
|
93
|
-
getUpdateInfo(log).then((info) => {
|
|
94
|
-
if (info.hasUpdate) {
|
|
95
|
-
log.info(`[update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`);
|
|
96
|
-
}
|
|
97
|
-
}).catch(() => {});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** 每次实时查询 npm registry */
|
|
101
|
-
export async function getUpdateInfo(log?: PluginLogger): Promise<UpdateInfo> {
|
|
102
|
-
try {
|
|
103
|
-
const tags = await fetchDistTags(log);
|
|
104
|
-
return buildUpdateInfo(tags);
|
|
105
|
-
} catch (err: any) {
|
|
106
|
-
log?.debug(`[update-checker] check failed: ${err.message}`);
|
|
107
|
-
return { current: CURRENT_VERSION, latest: null, stable: null, alpha: null, hasUpdate: false, checkedAt: Date.now(), error: err.message };
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* 检查指定版本是否存在于 npm registry
|
|
113
|
-
*/
|
|
114
|
-
export async function checkVersionExists(version: string, pkgName?: string): Promise<boolean> {
|
|
115
|
-
const registries = pkgName ? buildRegistries(pkgName) : REGISTRIES;
|
|
116
|
-
for (const baseUrl of registries) {
|
|
117
|
-
try {
|
|
118
|
-
const url = `${baseUrl}/${version}`;
|
|
119
|
-
const json = await fetchJson(url, 10_000);
|
|
120
|
-
if (json && json.version === version) return true;
|
|
121
|
-
} catch {
|
|
122
|
-
// try next registry
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function buildRegistries(pkgName: string): string[] {
|
|
129
|
-
const encoded = encodeURIComponent(pkgName);
|
|
130
|
-
return [
|
|
131
|
-
`https://registry.npmjs.org/${encoded}`,
|
|
132
|
-
`https://registry.npmmirror.com/${encoded}`,
|
|
133
|
-
];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function compareVersions(a: string, b: string): number {
|
|
137
|
-
const parse = (v: string) => {
|
|
138
|
-
const clean = v.replace(/^v/, "");
|
|
139
|
-
const [main, pre] = clean.split("-", 2);
|
|
140
|
-
return { parts: main.split(".").map(Number), pre: pre || null };
|
|
141
|
-
};
|
|
142
|
-
const pa = parse(a);
|
|
143
|
-
const pb = parse(b);
|
|
144
|
-
for (let i = 0; i < 3; i++) {
|
|
145
|
-
const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
|
|
146
|
-
if (diff !== 0) return diff;
|
|
147
|
-
}
|
|
148
|
-
if (!pa.pre && pb.pre) return 1;
|
|
149
|
-
if (pa.pre && !pb.pre) return -1;
|
|
150
|
-
if (!pa.pre && !pb.pre) return 0;
|
|
151
|
-
const aParts = pa.pre!.split(".");
|
|
152
|
-
const bParts = pb.pre!.split(".");
|
|
153
|
-
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
154
|
-
const aP = aParts[i] ?? "";
|
|
155
|
-
const bP = bParts[i] ?? "";
|
|
156
|
-
const aNum = Number(aP);
|
|
157
|
-
const bNum = Number(bP);
|
|
158
|
-
if (!isNaN(aNum) && !isNaN(bNum)) {
|
|
159
|
-
if (aNum !== bNum) return aNum - bNum;
|
|
160
|
-
} else {
|
|
161
|
-
if (aP < bP) return -1;
|
|
162
|
-
if (aP > bP) return 1;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return 0;
|
|
166
|
-
}
|