@tencent-connect/openclaw-qqbot 1.6.0-alpha.1 → 1.6.0-alpha.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.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/scripts/upgrade-via-source.sh +119 -72
  3. package/skills/qqbot-media/SKILL.md +1 -1
  4. package/src/gateway.ts +132 -43
  5. package/src/slash-commands.ts +162 -24
  6. package/src/types.ts +1 -1
  7. package/src/update-checker.ts +102 -0
  8. package/src/utils/media-tags.ts +34 -2
  9. package/dist/index.d.ts +0 -17
  10. package/dist/index.js +0 -22
  11. package/dist/scripts/upgrade-via-npm.sh +0 -168
  12. package/dist/src/api.d.ts +0 -147
  13. package/dist/src/api.js +0 -583
  14. package/dist/src/channel.d.ts +0 -3
  15. package/dist/src/channel.js +0 -337
  16. package/dist/src/config.d.ts +0 -25
  17. package/dist/src/config.js +0 -161
  18. package/dist/src/gateway.d.ts +0 -18
  19. package/dist/src/gateway.js +0 -2483
  20. package/dist/src/image-server.d.ts +0 -68
  21. package/dist/src/image-server.js +0 -462
  22. package/dist/src/known-users.d.ts +0 -100
  23. package/dist/src/known-users.js +0 -263
  24. package/dist/src/message-queue.d.ts +0 -267
  25. package/dist/src/message-queue.js +0 -558
  26. package/dist/src/onboarding.d.ts +0 -10
  27. package/dist/src/onboarding.js +0 -203
  28. package/dist/src/outbound.d.ts +0 -203
  29. package/dist/src/outbound.js +0 -1103
  30. package/dist/src/proactive.d.ts +0 -170
  31. package/dist/src/proactive.js +0 -399
  32. package/dist/src/ref-index-store.d.ts +0 -70
  33. package/dist/src/ref-index-store.js +0 -274
  34. package/dist/src/runtime.d.ts +0 -3
  35. package/dist/src/runtime.js +0 -10
  36. package/dist/src/session-store.d.ts +0 -52
  37. package/dist/src/session-store.js +0 -254
  38. package/dist/src/slash-commands.d.ts +0 -61
  39. package/dist/src/slash-commands.js +0 -211
  40. package/dist/src/types.d.ts +0 -169
  41. package/dist/src/types.js +0 -1
  42. package/dist/src/user-messages.d.ts +0 -43
  43. package/dist/src/user-messages.js +0 -65
  44. package/dist/src/utils/audio-convert.d.ts +0 -89
  45. package/dist/src/utils/audio-convert.js +0 -704
  46. package/dist/src/utils/file-utils.d.ts +0 -46
  47. package/dist/src/utils/file-utils.js +0 -107
  48. package/dist/src/utils/image-size.d.ts +0 -51
  49. package/dist/src/utils/image-size.js +0 -234
  50. package/dist/src/utils/media-tags.d.ts +0 -14
  51. package/dist/src/utils/media-tags.js +0 -130
  52. package/dist/src/utils/payload.d.ts +0 -112
  53. package/dist/src/utils/payload.js +0 -186
  54. package/dist/src/utils/platform.d.ts +0 -127
  55. package/dist/src/utils/platform.js +0 -374
  56. package/dist/src/utils/upload-cache.d.ts +0 -34
  57. package/dist/src/utils/upload-cache.js +0 -93
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.0-alpha.1",
3
+ "version": "1.6.0-alpha.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -186,6 +186,19 @@ INSTALL_LOG="/tmp/openclaw-install-$(date +%s).log"
186
186
  echo "安装日志文件: $INSTALL_LOG"
187
187
  echo "详细信息将记录到日志文件中..."
188
188
 
189
+ # 安装前先 stop gateway,防止 chokidar 在 plugins install 写入配置的中间状态
190
+ # 触发 restart,导致 "unknown channel id: qqbot" 等错误
191
+ _gw_was_running=0
192
+ if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
193
+ _gw_was_running=1
194
+ echo " 暂停 gateway 服务(避免安装过程中中间状态 restart)..."
195
+ openclaw gateway stop 2>/dev/null || true
196
+ sleep 1
197
+ fi
198
+
199
+ # 清理之前可能残留的 staging 目录
200
+ find "$HOME/.openclaw/extensions/" -maxdepth 1 -name ".openclaw-install-stage-*" -exec rm -rf {} + 2>/dev/null || true
201
+
189
202
  # 尝试安装并捕获详细输出
190
203
  if ! openclaw plugins install . 2>&1 | tee "$INSTALL_LOG"; then
191
204
  echo ""
@@ -243,9 +256,70 @@ if ! openclaw plugins install . 2>&1 | tee "$INSTALL_LOG"; then
243
256
  esac
244
257
  else
245
258
  echo ""
246
- echo "✅ 插件安装成功!"
259
+ echo "✅ 插件安装命令执行完成"
247
260
  echo "安装日志已保存到: $INSTALL_LOG"
248
261
 
262
+ # 验证插件目录是否真正创建(防止 "安装成功" 但目录缺失的情况)
263
+ _plugin_dir_ok=0
264
+ for _candidate_name in openclaw-qqbot qqbot openclaw-qq; do
265
+ if [ -d "$HOME/.openclaw/extensions/$_candidate_name" ] && \
266
+ [ -f "$HOME/.openclaw/extensions/$_candidate_name/package.json" ]; then
267
+ _plugin_dir_ok=1
268
+ echo " ✅ 插件目录验证通过: ~/.openclaw/extensions/$_candidate_name/"
269
+ break
270
+ fi
271
+ done
272
+ if [ "$_plugin_dir_ok" -eq 0 ]; then
273
+ echo ""
274
+ echo "⚠️ 警告: 插件目录不存在!安装命令返回成功但目录未创建"
275
+ echo " 可能原因: staging 目录未正确 rename(参考 openclaw/issues)"
276
+ echo " 检查 staging 残留:"
277
+ ls -la "$HOME/.openclaw/extensions/" 2>/dev/null
278
+ echo ""
279
+ echo " 尝试自动修复: 清理残留并重试安装..."
280
+ find "$HOME/.openclaw/extensions/" -maxdepth 1 -name ".openclaw-install-stage-*" -exec rm -rf {} + 2>/dev/null || true
281
+ if openclaw plugins install . 2>&1 | tee -a "$INSTALL_LOG"; then
282
+ for _candidate_name in openclaw-qqbot qqbot openclaw-qq; do
283
+ if [ -d "$HOME/.openclaw/extensions/$_candidate_name" ]; then
284
+ _plugin_dir_ok=1
285
+ echo " ✅ 重试安装成功: ~/.openclaw/extensions/$_candidate_name/"
286
+ break
287
+ fi
288
+ done
289
+ fi
290
+ if [ "$_plugin_dir_ok" -eq 0 ]; then
291
+ echo " ❌ 重试安装仍失败,插件目录不存在"
292
+ echo " 请手动排查: ls -la ~/.openclaw/extensions/"
293
+ # 清理无效的配置条目,防止 gateway 启动时报错
294
+ echo " 清理无效的配置条目..."
295
+ node -e "
296
+ const fs = require('fs');
297
+ const path = require('path');
298
+ for (const app of ['openclaw', 'clawdbot', 'moltbot']) {
299
+ const f = path.join(process.env.HOME, '.' + app, app + '.json');
300
+ if (!fs.existsSync(f)) continue;
301
+ const cfg = JSON.parse(fs.readFileSync(f, 'utf8'));
302
+ let changed = false;
303
+ // 移除 channels.qqbot(插件未加载时此配置会导致 unknown channel id 错误)
304
+ if (cfg.channels && cfg.channels.qqbot) { delete cfg.channels.qqbot; changed = true; }
305
+ // 清理 plugins.allow 中的 openclaw-qqbot
306
+ if (cfg.plugins && Array.isArray(cfg.plugins.allow)) {
307
+ cfg.plugins.allow = cfg.plugins.allow.filter(x => x !== 'openclaw-qqbot');
308
+ changed = true;
309
+ }
310
+ if (changed) fs.writeFileSync(f, JSON.stringify(cfg, null, 4) + '\n');
311
+ break;
312
+ }
313
+ " 2>/dev/null || true
314
+ echo " 已清理无效配置,gateway 可正常启动(无 qqbot 插件状态)"
315
+ read -t 10 -p "是否继续? (y/N): " _cont || _cont="N"
316
+ case "$_cont" in
317
+ [Yy]* ) echo "继续..." ;;
318
+ * ) exit 1 ;;
319
+ esac
320
+ fi
321
+ fi
322
+
249
323
  # 清理多余的 peerDependencies 传递依赖(兼容旧版 openclaw):
250
324
  # openclaw v2026.3.4 之前的 plugins install 缺少 --omit=peer,会把 peerDeps
251
325
  # (openclaw 平台及其 400+ 传递依赖)也安装到插件 node_modules 中。
@@ -310,31 +384,8 @@ else
310
384
  fi
311
385
  fi
312
386
 
313
- # plugins install 一次性写入 openclaw.json(plugins.allow/entries/installs),
314
- # 如果 gateway 正在运行,chokidar watcher 会检测到变化并自动 restart。
315
- # 如果 gateway 未运行,则无需等待(最终 Step 6 会启动)。
316
- echo ""
317
- if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
318
- echo "等待 gateway 自动重启完成..."
319
- _gw_restarted=0
320
- for _w in $(seq 1 20); do
321
- sleep 1
322
- if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
323
- sleep 2
324
- if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
325
- _gw_restarted=1
326
- break
327
- fi
328
- fi
329
- done
330
- if [ "$_gw_restarted" -eq 1 ]; then
331
- echo " gateway 已自动重启完成"
332
- else
333
- echo " 等待超时,将在最后一步重新启动"
334
- fi
335
- else
336
- echo " gateway 当前未运行,跳过自动重启等待(将在最后一步启动)"
337
- fi
387
+ # gateway 已在安装前 stop,此时不会有自动 restart 的问题
388
+ # 所有配置写入完成后,在 Step 6 统一启动
338
389
 
339
390
  # 记录更新后的 qqbot 插件版本
340
391
  NEW_QQBOT_VERSION=$(node -e '
@@ -528,52 +579,33 @@ start_choice=$(printf '%s' "$start_choice" | tr '[:upper:]' '[:lower:]')
528
579
  case "$start_choice" in
529
580
  y|yes)
530
581
  echo ""
531
- # plugins install 已触发自动 restart(Step 3 已等待完成),
532
- # channels add / config set 只触发 hot reload(无需 restart)。
533
- # 这里仍做一次显式 restart 作为兜底,确保插件正确加载。
534
- # 如果 gateway 当前已在监听端口,先检查是否真需要 restart。
535
- _need_restart=1
536
- if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
537
- # gateway 已在运行,如果 Step 3 的自动 restart 已成功加载新插件,
538
- # 且 Step 4/5 只做了 hot reload,理论上不需要再 restart。
539
- # 但为安全起见:如果版本有变化或配置有变化,仍 restart 一次。
540
- if [ "$OLD_QQBOT_VERSION" = "$NEW_QQBOT_VERSION" ] && [ "${_config_changed:-0}" -eq 0 ]; then
541
- echo "插件版本未变化且配置无变更,跳过冗余重启"
542
- _need_restart=0
543
- fi
544
- fi
545
-
546
- if [ "$_need_restart" -eq 1 ]; then
547
- echo "正在后台重启 openclaw 网关服务..."
548
- _restart_output=$(openclaw gateway restart 2>&1) || true
549
- echo "$_restart_output"
582
+ # gateway Step 3 安装前已 stop,此处统一 restart
583
+ echo "正在后台重启 openclaw 网关服务..."
584
+ _restart_output=$(openclaw gateway restart 2>&1) || true
585
+ echo "$_restart_output"
550
586
 
551
- if echo "$_restart_output" | grep -qi "not loaded\|not found\|not running\|not installed"; then
552
- # gateway 服务未加载(常见于首次安装或 launchd 服务被卸载的情况)
553
- # 正确恢复流程:install 注册 launchd plist → start 启动服务
554
- echo ""
555
- echo "⚠️ gateway 服务未加载,尝试自动恢复..."
556
- echo ""
557
- echo " [1/2] 注册 gateway 服务..."
558
- _install_out=$(openclaw gateway install 2>&1) || true
559
- echo " $_install_out"
587
+ if echo "$_restart_output" | grep -qi "not loaded\|not found\|not running\|not installed"; then
588
+ echo ""
589
+ echo "⚠️ gateway 服务未加载,尝试自动恢复..."
590
+ echo ""
591
+ echo " [1/2] 注册 gateway 服务..."
592
+ _install_out=$(openclaw gateway install 2>&1) || true
593
+ echo " $_install_out"
594
+ echo ""
595
+ echo " [2/2] 启动 gateway 服务..."
596
+ _start_out=$(openclaw gateway start 2>&1) || true
597
+ echo " $_start_out"
598
+ if echo "$_start_out" | grep -qi "restart\|started\|bootstrap"; then
560
599
  echo ""
561
- echo " [2/2] 启动 gateway 服务..."
562
- _start_out=$(openclaw gateway start 2>&1) || true
563
- echo " $_start_out"
564
- # 检查恢复是否成功
565
- if echo "$_start_out" | grep -qi "restart\|started\|bootstrap"; then
566
- echo ""
567
- echo "✅ gateway 服务恢复成功"
568
- else
569
- echo ""
570
- echo "⚠️ 自动恢复可能失败,请手动执行:"
571
- echo " openclaw gateway install && openclaw gateway start"
572
- fi
600
+ echo " gateway 服务恢复成功"
573
601
  else
574
602
  echo ""
575
- echo "✅ openclaw 网关已在后台重启"
603
+ echo "⚠️ 自动恢复可能失败,请手动执行:"
604
+ echo " openclaw gateway install && openclaw gateway start"
576
605
  fi
606
+ else
607
+ echo ""
608
+ echo "✅ openclaw 网关已在后台重启"
577
609
  fi
578
610
  echo ""
579
611
  # 等待 gateway 端口就绪
@@ -591,10 +623,26 @@ case "$start_choice" in
591
623
  echo ""
592
624
 
593
625
  if [ "$_port_ready" -eq 0 ]; then
594
- echo "⚠️ 等待超时,gateway 可能仍在启动中"
595
- echo "请手动检查: openclaw doctor"
596
- echo "或查看日志: tail -f /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log"
597
- else
626
+ echo "⚠️ 等待超时,尝试 openclaw doctor --fix 自动修复..."
627
+ _doctor_output=$(openclaw doctor --fix 2>&1) || true
628
+ echo "$_doctor_output"
629
+ # doctor --fix 后再尝试 restart 一次
630
+ echo ""
631
+ echo "doctor 修复后重试 gateway restart..."
632
+ openclaw gateway restart 2>&1 || true
633
+ sleep 5
634
+ if lsof -i :18789 -sTCP:LISTEN >/dev/null 2>&1; then
635
+ echo "✅ doctor --fix 后 gateway 启动成功"
636
+ _port_ready=1
637
+ else
638
+ echo "❌ 仍然无法启动,请手动排查:"
639
+ echo " openclaw doctor"
640
+ echo " tail -f /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log"
641
+ fi
642
+ fi
643
+
644
+ # 端口就绪后:检查 qqbot 连接 + 跟踪日志
645
+ if [ "$_port_ready" -eq 1 ]; then
598
646
  echo "✅ Gateway 端口已就绪"
599
647
  echo ""
600
648
  # 检查 qqbot WS 是否连接成功(最多等 20 秒)
@@ -603,7 +651,6 @@ case "$start_choice" in
603
651
  _restart_ts=$(date +%s)
604
652
  _qqbot_ready=0
605
653
  for _j in $(seq 1 10); do
606
- # 只检查在本次重启之后出现的 "Gateway ready" 日志
607
654
  if [ -f "$_LOG_FILE" ]; then
608
655
  _last_line=$(grep "Gateway ready" "$_LOG_FILE" 2>/dev/null | tail -1 || true)
609
656
  if [ -n "$_last_line" ]; then
@@ -27,7 +27,7 @@ metadata: {"openclaw":{"emoji":"📸","requires":{"config":["channels.qqbot"]}}}
27
27
  ## 规则
28
28
 
29
29
  1. **路径必须是绝对路径**(以 `/` 或 `http` 开头)
30
- 2. **标签必须闭合**:`<qqmedia>...</qqmedia>`
30
+ 2. **标签必须用开闭标签包裹路径**:`<qqmedia>路径</qqmedia>`
31
31
  3. **文件大小上限 10MB**
32
32
  4. **你有能力发送本地图片/文件**——直接用标签包裹路径即可,**不要说"无法发送"**
33
33
  5. 发送语音时不要重复语音中已朗读的文字
package/src/gateway.ts CHANGED
@@ -7,7 +7,8 @@ import { loadSession, saveSession, clearSession, type SessionState } from "./ses
7
7
  import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.js";
8
8
  import { getQQBotRuntime } from "./runtime.js";
9
9
  import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
10
- import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type QueueSnapshot } from "./slash-commands.js";
10
+ import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
11
+ import { triggerUpdateCheck } from "./update-checker.js";
11
12
  import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
12
13
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
13
14
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
@@ -343,6 +344,49 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
343
344
  }
344
345
  }
345
346
 
347
+ // ============ 启动问候语(首次安装/版本更新 vs 普通重启) ============
348
+
349
+ // 模块级变量:进程生命周期内只有首次为 true
350
+ // 区分 gateway restart(进程重启)和 health-monitor 断线重连
351
+ let isFirstReadyGlobal = true;
352
+
353
+ const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
354
+
355
+ /**
356
+ * 判断是否为首次安装或版本更新,返回对应的问候语。
357
+ * - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
358
+ * - 普通重启 → "我重新登上了,有事随时找我。"
359
+ */
360
+ function getStartupGreeting(): string {
361
+ const currentVersion = getPluginVersion();
362
+ let isFirstOrUpdated = true;
363
+
364
+ try {
365
+ if (fs.existsSync(STARTUP_MARKER_FILE)) {
366
+ const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
367
+ if (data.version === currentVersion) {
368
+ isFirstOrUpdated = false;
369
+ }
370
+ }
371
+ } catch {
372
+ // 文件损坏或不存在,视为首次
373
+ }
374
+
375
+ // 更新 marker 文件
376
+ try {
377
+ fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify({
378
+ version: currentVersion,
379
+ startedAt: new Date().toISOString(),
380
+ }) + "\n");
381
+ } catch {
382
+ // ignore
383
+ }
384
+
385
+ return isFirstOrUpdated
386
+ ? `Haha,我的'灵魂'已上线,随时等你吩咐。`
387
+ : `我重新登上了,有事随时找我。`;
388
+ }
389
+
346
390
  /**
347
391
  * 启动 Gateway WebSocket 连接(带自动重连)
348
392
  * 支持流式消息发送
@@ -362,6 +406,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
362
406
  }
363
407
  }
364
408
 
409
+ // 后台版本检查(detached 子进程,零阻塞)
410
+ triggerUpdateCheck(log);
411
+
365
412
  // 初始化 API 配置(markdown 支持)
366
413
  initApiConfig({
367
414
  markdownSupport: account.markdownSupport,
@@ -436,6 +483,46 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
436
483
  let isConnecting = false; // 防止并发连接
437
484
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
438
485
  let shouldRefreshToken = false; // 下次连接是否需要刷新 token
486
+ // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
487
+ // health-monitor 重连不会重新初始化为 true
488
+
489
+ /** 异步发送启动问候语(READY 或 RESUMED 时调用) */
490
+ const sendStartupGreetings = (trigger: "READY" | "RESUMED") => {
491
+ (async () => {
492
+ try {
493
+ const greeting = getStartupGreeting();
494
+ log?.info(`[qqbot:${account.accountId}] Sending startup greeting (trigger=${trigger}): "${greeting}"`);
495
+ const token = await getAccessToken(account.appId, account.clientSecret);
496
+ const users = listKnownUsers({ accountId: account.accountId, type: "c2c" });
497
+ for (const user of users) {
498
+ try {
499
+ await sendProactiveC2CMessage(token, user.openid, greeting);
500
+ log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
501
+ } catch (err) {
502
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
503
+ }
504
+ await new Promise(r => setTimeout(r, 500));
505
+ }
506
+ const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
507
+ const sentGroups = new Set<string>();
508
+ for (const user of groups) {
509
+ const gid = user.groupOpenid;
510
+ if (!gid || sentGroups.has(gid)) continue;
511
+ sentGroups.add(gid);
512
+ try {
513
+ await sendProactiveGroupMessage(token, gid, greeting);
514
+ log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
515
+ } catch (err) {
516
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
517
+ }
518
+ await new Promise(r => setTimeout(r, 500));
519
+ }
520
+ log?.info(`[qqbot:${account.accountId}] Startup greetings sent (${users.length} c2c, ${sentGroups.size} groups)`);
521
+ } catch (err) {
522
+ log?.error(`[qqbot:${account.accountId}] Failed to send startup greetings: ${err}`);
523
+ }
524
+ })();
525
+ };
439
526
 
440
527
  // ============ P1-2: 尝试从持久化存储恢复 Session ============
441
528
  // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
@@ -589,15 +676,40 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
589
676
  // 命中插件级指令,直接回复
590
677
  log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
591
678
  const token = await getAccessToken(account.appId, account.clientSecret);
679
+
680
+ // 解析回复:纯文本 or 带文件的结果
681
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
682
+ const replyText = isFileResult ? (reply as SlashCommandFileResult).text : reply as string;
683
+ const replyFile = isFileResult ? (reply as SlashCommandFileResult).filePath : null;
684
+
685
+ // 先发送文本回复
592
686
  if (msg.type === "c2c") {
593
- await sendC2CMessage(token, msg.senderId, reply, msg.messageId);
687
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
594
688
  } else if (msg.type === "group" && msg.groupOpenid) {
595
- await sendGroupMessage(token, msg.groupOpenid, reply, msg.messageId);
689
+ await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
596
690
  } else if (msg.channelId) {
597
- await sendChannelMessage(token, msg.channelId, reply, msg.messageId);
691
+ await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
598
692
  } else if (msg.type === "dm") {
599
- // 频道私信走 C2C
600
- await sendC2CMessage(token, msg.senderId, reply, msg.messageId);
693
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
694
+ }
695
+
696
+ // 如果有文件需要发送
697
+ if (replyFile) {
698
+ try {
699
+ const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
700
+ const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
701
+ const mediaCtx: MediaTargetContext = {
702
+ targetType,
703
+ targetId,
704
+ account,
705
+ replyToId: msg.messageId,
706
+ logPrefix: `[qqbot:${account.accountId}]`,
707
+ };
708
+ await sendDocument(mediaCtx, replyFile);
709
+ log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
710
+ } catch (fileErr) {
711
+ log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
712
+ }
601
713
  }
602
714
  } catch (err) {
603
715
  log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
@@ -1061,7 +1173,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1061
1173
  ];
1062
1174
  // TTS 能力声明:仅在启用时告知 AI 可以发语音(与 qqbot-media SKILL.md 互补)
1063
1175
  // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
1064
- if (hasTTS) staticParts.push("语音合成已启用,可用<qqmedia>发送语音");
1176
+ if (hasTTS) staticParts.push("语音合成已启用,发送媒体格式:<qqmedia>路径</qqmedia>");
1065
1177
  const staticInstruction = staticParts.join(" | ");
1066
1178
 
1067
1179
  // 静态指引作为 systemPrompts 的首项注入
@@ -2348,44 +2460,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2348
2460
  });
2349
2461
  onReady?.(d);
2350
2462
 
2351
- // 启动成功后向已知用户发送上线通知(异步,不阻塞主流程)
2352
- (async () => {
2353
- try {
2354
- const greeting = `Haha,我的'灵魂'已上线,随时等你吩咐。`;
2355
- const token = await getAccessToken(account.appId, account.clientSecret);
2356
- const users = listKnownUsers({ accountId: account.accountId, type: "c2c" });
2357
- for (const user of users) {
2358
- try {
2359
- await sendProactiveC2CMessage(token, user.openid, greeting);
2360
- log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
2361
- } catch (err) {
2362
- log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
2363
- }
2364
- // 避免频率限制
2365
- await new Promise(r => setTimeout(r, 500));
2366
- }
2367
- const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
2368
- // 群组去重(同一群只发一次)
2369
- const sentGroups = new Set<string>();
2370
- for (const user of groups) {
2371
- const gid = user.groupOpenid;
2372
- if (!gid || sentGroups.has(gid)) continue;
2373
- sentGroups.add(gid);
2374
- try {
2375
- await sendProactiveGroupMessage(token, gid, greeting);
2376
- log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
2377
- } catch (err) {
2378
- log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
2379
- }
2380
- await new Promise(r => setTimeout(r, 500));
2381
- }
2382
- log?.info(`[qqbot:${account.accountId}] Startup greetings sent (${users.length} c2c, ${sentGroups.size} groups)`);
2383
- } catch (err) {
2384
- log?.error(`[qqbot:${account.accountId}] Failed to send startup greetings: ${err}`);
2385
- }
2386
- })();
2463
+ // 仅 startGateway 后的首次 READY 才发送上线通知
2464
+ // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
2465
+ if (!isFirstReadyGlobal) {
2466
+ log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
2467
+ } else {
2468
+ isFirstReadyGlobal = false;
2469
+ sendStartupGreetings("READY");
2470
+ } // end isFirstReady
2387
2471
  } else if (t === "RESUMED") {
2388
2472
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
2473
+ // RESUMED 也属于首次启动(gateway restart 通常走 resume)
2474
+ if (isFirstReadyGlobal) {
2475
+ isFirstReadyGlobal = false;
2476
+ sendStartupGreetings("RESUMED");
2477
+ }
2389
2478
  // P1-2: 更新 Session 连接时间
2390
2479
  if (sessionId) {
2391
2480
  saveSession({