botmux 2.9.1 → 2.9.3

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 (330) hide show
  1. package/README.en.md +140 -76
  2. package/README.md +134 -75
  3. package/dist/adapters/backend/pty-backend.d.ts +6 -0
  4. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/pty-backend.js +10 -0
  6. package/dist/adapters/backend/pty-backend.js.map +1 -1
  7. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  8. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  9. package/dist/adapters/backend/session-backend-selector.js +26 -0
  10. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  11. package/dist/adapters/backend/tmux-backend.d.ts +80 -3
  12. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.js +301 -49
  14. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  15. package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
  16. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
  17. package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
  19. package/dist/adapters/cli/aiden.d.ts.map +1 -1
  20. package/dist/adapters/cli/aiden.js +5 -0
  21. package/dist/adapters/cli/aiden.js.map +1 -1
  22. package/dist/adapters/cli/claude-code.d.ts +40 -1
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +470 -49
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/coco.d.ts.map +1 -1
  27. package/dist/adapters/cli/coco.js +191 -9
  28. package/dist/adapters/cli/coco.js.map +1 -1
  29. package/dist/adapters/cli/codex.d.ts.map +1 -1
  30. package/dist/adapters/cli/codex.js +94 -17
  31. package/dist/adapters/cli/codex.js.map +1 -1
  32. package/dist/adapters/cli/shared-hints.d.ts +2 -2
  33. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  34. package/dist/adapters/cli/shared-hints.js +7 -5
  35. package/dist/adapters/cli/shared-hints.js.map +1 -1
  36. package/dist/adapters/cli/types.d.ts +38 -1
  37. package/dist/adapters/cli/types.d.ts.map +1 -1
  38. package/dist/autostart.d.ts +14 -0
  39. package/dist/autostart.d.ts.map +1 -0
  40. package/dist/autostart.js +357 -0
  41. package/dist/autostart.js.map +1 -0
  42. package/dist/bot-registry.d.ts +29 -3
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +91 -12
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli/arg-utils.d.ts +11 -0
  47. package/dist/cli/arg-utils.d.ts.map +1 -0
  48. package/dist/cli/arg-utils.js +25 -0
  49. package/dist/cli/arg-utils.js.map +1 -0
  50. package/dist/cli/create-group-resolver.d.ts +32 -0
  51. package/dist/cli/create-group-resolver.d.ts.map +1 -0
  52. package/dist/cli/create-group-resolver.js +70 -0
  53. package/dist/cli/create-group-resolver.js.map +1 -0
  54. package/dist/cli/quoted-render.d.ts +30 -0
  55. package/dist/cli/quoted-render.d.ts.map +1 -0
  56. package/dist/cli/quoted-render.js +29 -0
  57. package/dist/cli/quoted-render.js.map +1 -0
  58. package/dist/cli.js +916 -272
  59. package/dist/cli.js.map +1 -1
  60. package/dist/config.d.ts +6 -0
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +18 -8
  63. package/dist/config.js.map +1 -1
  64. package/dist/core/command-handler.d.ts +43 -0
  65. package/dist/core/command-handler.d.ts.map +1 -1
  66. package/dist/core/command-handler.js +167 -64
  67. package/dist/core/command-handler.js.map +1 -1
  68. package/dist/core/dashboard-events.d.ts +57 -0
  69. package/dist/core/dashboard-events.d.ts.map +1 -0
  70. package/dist/core/dashboard-events.js +23 -0
  71. package/dist/core/dashboard-events.js.map +1 -0
  72. package/dist/core/dashboard-ipc-server.d.ts +43 -0
  73. package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
  74. package/dist/core/dashboard-ipc-server.js +481 -0
  75. package/dist/core/dashboard-ipc-server.js.map +1 -0
  76. package/dist/core/dashboard-locate.d.ts +20 -0
  77. package/dist/core/dashboard-locate.d.ts.map +1 -0
  78. package/dist/core/dashboard-locate.js +26 -0
  79. package/dist/core/dashboard-locate.js.map +1 -0
  80. package/dist/core/dashboard-rows.d.ts +31 -0
  81. package/dist/core/dashboard-rows.d.ts.map +1 -0
  82. package/dist/core/dashboard-rows.js +65 -0
  83. package/dist/core/dashboard-rows.js.map +1 -0
  84. package/dist/core/inherit-peer.d.ts +14 -0
  85. package/dist/core/inherit-peer.d.ts.map +1 -0
  86. package/dist/core/inherit-peer.js +32 -0
  87. package/dist/core/inherit-peer.js.map +1 -0
  88. package/dist/core/scheduler.d.ts +24 -0
  89. package/dist/core/scheduler.d.ts.map +1 -1
  90. package/dist/core/scheduler.js +93 -2
  91. package/dist/core/scheduler.js.map +1 -1
  92. package/dist/core/session-activity.d.ts +3 -0
  93. package/dist/core/session-activity.d.ts.map +1 -0
  94. package/dist/core/session-activity.js +20 -0
  95. package/dist/core/session-activity.js.map +1 -0
  96. package/dist/core/session-discovery.d.ts +39 -0
  97. package/dist/core/session-discovery.d.ts.map +1 -1
  98. package/dist/core/session-discovery.js +114 -21
  99. package/dist/core/session-discovery.js.map +1 -1
  100. package/dist/core/session-manager.d.ts +72 -0
  101. package/dist/core/session-manager.d.ts.map +1 -1
  102. package/dist/core/session-manager.js +396 -106
  103. package/dist/core/session-manager.js.map +1 -1
  104. package/dist/core/types.d.ts +27 -2
  105. package/dist/core/types.d.ts.map +1 -1
  106. package/dist/core/types.js +14 -3
  107. package/dist/core/types.js.map +1 -1
  108. package/dist/core/worker-pool.d.ts +72 -3
  109. package/dist/core/worker-pool.d.ts.map +1 -1
  110. package/dist/core/worker-pool.js +459 -38
  111. package/dist/core/worker-pool.js.map +1 -1
  112. package/dist/daemon.d.ts.map +1 -1
  113. package/dist/daemon.js +645 -314
  114. package/dist/daemon.js.map +1 -1
  115. package/dist/dashboard/aggregator.d.ts +41 -0
  116. package/dist/dashboard/aggregator.d.ts.map +1 -0
  117. package/dist/dashboard/aggregator.js +125 -0
  118. package/dist/dashboard/aggregator.js.map +1 -0
  119. package/dist/dashboard/auth.d.ts +23 -0
  120. package/dist/dashboard/auth.d.ts.map +1 -0
  121. package/dist/dashboard/auth.js +66 -0
  122. package/dist/dashboard/auth.js.map +1 -0
  123. package/dist/dashboard/operator-selector.d.ts +20 -0
  124. package/dist/dashboard/operator-selector.d.ts.map +1 -0
  125. package/dist/dashboard/operator-selector.js +39 -0
  126. package/dist/dashboard/operator-selector.js.map +1 -0
  127. package/dist/dashboard/registry.d.ts +35 -0
  128. package/dist/dashboard/registry.d.ts.map +1 -0
  129. package/dist/dashboard/registry.js +74 -0
  130. package/dist/dashboard/registry.js.map +1 -0
  131. package/dist/dashboard/web/app.d.ts +2 -0
  132. package/dist/dashboard/web/app.d.ts.map +1 -0
  133. package/dist/dashboard/web/app.js +45 -0
  134. package/dist/dashboard/web/app.js.map +1 -0
  135. package/dist/dashboard/web/bot-defaults.d.ts +2 -0
  136. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
  137. package/dist/dashboard/web/bot-defaults.js +201 -0
  138. package/dist/dashboard/web/bot-defaults.js.map +1 -0
  139. package/dist/dashboard/web/groups.d.ts +16 -0
  140. package/dist/dashboard/web/groups.d.ts.map +1 -0
  141. package/dist/dashboard/web/groups.js +584 -0
  142. package/dist/dashboard/web/groups.js.map +1 -0
  143. package/dist/dashboard/web/schedules.d.ts +2 -0
  144. package/dist/dashboard/web/schedules.d.ts.map +1 -0
  145. package/dist/dashboard/web/schedules.js +105 -0
  146. package/dist/dashboard/web/schedules.js.map +1 -0
  147. package/dist/dashboard/web/sessions.d.ts +2 -0
  148. package/dist/dashboard/web/sessions.d.ts.map +1 -0
  149. package/dist/dashboard/web/sessions.js +374 -0
  150. package/dist/dashboard/web/sessions.js.map +1 -0
  151. package/dist/dashboard/web/store.d.ts +23 -0
  152. package/dist/dashboard/web/store.d.ts.map +1 -0
  153. package/dist/dashboard/web/store.js +82 -0
  154. package/dist/dashboard/web/store.js.map +1 -0
  155. package/dist/dashboard-web/app.js +263 -0
  156. package/dist/dashboard-web/index.html +23 -0
  157. package/dist/dashboard-web/style.css +93 -0
  158. package/dist/dashboard.d.ts +2 -0
  159. package/dist/dashboard.d.ts.map +1 -0
  160. package/dist/dashboard.js +639 -0
  161. package/dist/dashboard.js.map +1 -0
  162. package/dist/im/lark/card-builder.d.ts +18 -1
  163. package/dist/im/lark/card-builder.d.ts.map +1 -1
  164. package/dist/im/lark/card-builder.js +70 -9
  165. package/dist/im/lark/card-builder.js.map +1 -1
  166. package/dist/im/lark/card-handler.d.ts.map +1 -1
  167. package/dist/im/lark/card-handler.js +123 -109
  168. package/dist/im/lark/card-handler.js.map +1 -1
  169. package/dist/im/lark/client.d.ts +35 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +114 -11
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +88 -6
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +398 -62
  176. package/dist/im/lark/event-dispatcher.js.map +1 -1
  177. package/dist/im/lark/forwarded-renderer.d.ts +23 -0
  178. package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
  179. package/dist/im/lark/forwarded-renderer.js +105 -0
  180. package/dist/im/lark/forwarded-renderer.js.map +1 -0
  181. package/dist/im/lark/md-card.d.ts +73 -0
  182. package/dist/im/lark/md-card.d.ts.map +1 -0
  183. package/dist/im/lark/md-card.js +332 -0
  184. package/dist/im/lark/md-card.js.map +1 -0
  185. package/dist/im/lark/merge-forward.d.ts +32 -0
  186. package/dist/im/lark/merge-forward.d.ts.map +1 -0
  187. package/dist/im/lark/merge-forward.js +110 -0
  188. package/dist/im/lark/merge-forward.js.map +1 -0
  189. package/dist/im/lark/message-parser.d.ts +9 -3
  190. package/dist/im/lark/message-parser.d.ts.map +1 -1
  191. package/dist/im/lark/message-parser.js +48 -13
  192. package/dist/im/lark/message-parser.js.map +1 -1
  193. package/dist/im/lark/quote-hint.d.ts +18 -0
  194. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  195. package/dist/im/lark/quote-hint.js +23 -0
  196. package/dist/im/lark/quote-hint.js.map +1 -0
  197. package/dist/services/bridge-fallback-gate.d.ts +42 -0
  198. package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
  199. package/dist/services/bridge-fallback-gate.js +12 -0
  200. package/dist/services/bridge-fallback-gate.js.map +1 -0
  201. package/dist/services/bridge-rotation-policy.d.ts +139 -0
  202. package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
  203. package/dist/services/bridge-rotation-policy.js +125 -0
  204. package/dist/services/bridge-rotation-policy.js.map +1 -0
  205. package/dist/services/bridge-turn-queue.d.ts +154 -0
  206. package/dist/services/bridge-turn-queue.d.ts.map +1 -0
  207. package/dist/services/bridge-turn-queue.js +316 -0
  208. package/dist/services/bridge-turn-queue.js.map +1 -0
  209. package/dist/services/chat-first-seen-store.d.ts +27 -0
  210. package/dist/services/chat-first-seen-store.d.ts.map +1 -0
  211. package/dist/services/chat-first-seen-store.js +114 -0
  212. package/dist/services/chat-first-seen-store.js.map +1 -0
  213. package/dist/services/claude-transcript.d.ts +268 -0
  214. package/dist/services/claude-transcript.d.ts.map +1 -0
  215. package/dist/services/claude-transcript.js +798 -0
  216. package/dist/services/claude-transcript.js.map +1 -0
  217. package/dist/services/coco-transcript.d.ts +35 -0
  218. package/dist/services/coco-transcript.d.ts.map +1 -0
  219. package/dist/services/coco-transcript.js +192 -0
  220. package/dist/services/coco-transcript.js.map +1 -0
  221. package/dist/services/codex-bridge-queue.d.ts +56 -0
  222. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  223. package/dist/services/codex-bridge-queue.js +150 -0
  224. package/dist/services/codex-bridge-queue.js.map +1 -0
  225. package/dist/services/codex-transcript.d.ts +84 -0
  226. package/dist/services/codex-transcript.d.ts.map +1 -0
  227. package/dist/services/codex-transcript.js +298 -0
  228. package/dist/services/codex-transcript.js.map +1 -0
  229. package/dist/services/group-creator.d.ts +23 -0
  230. package/dist/services/group-creator.d.ts.map +1 -0
  231. package/dist/services/group-creator.js +75 -0
  232. package/dist/services/group-creator.js.map +1 -0
  233. package/dist/services/groups-store.d.ts +98 -0
  234. package/dist/services/groups-store.d.ts.map +1 -0
  235. package/dist/services/groups-store.js +213 -0
  236. package/dist/services/groups-store.js.map +1 -0
  237. package/dist/services/oncall-store.d.ts +80 -8
  238. package/dist/services/oncall-store.d.ts.map +1 -1
  239. package/dist/services/oncall-store.js +265 -55
  240. package/dist/services/oncall-store.js.map +1 -1
  241. package/dist/services/project-scanner.d.ts +1 -2
  242. package/dist/services/project-scanner.d.ts.map +1 -1
  243. package/dist/services/project-scanner.js +118 -68
  244. package/dist/services/project-scanner.js.map +1 -1
  245. package/dist/services/schedule-store.d.ts +5 -0
  246. package/dist/services/schedule-store.d.ts.map +1 -1
  247. package/dist/services/schedule-store.js +77 -1
  248. package/dist/services/schedule-store.js.map +1 -1
  249. package/dist/services/session-store.d.ts +22 -0
  250. package/dist/services/session-store.d.ts.map +1 -1
  251. package/dist/services/session-store.js +62 -4
  252. package/dist/services/session-store.js.map +1 -1
  253. package/dist/setup/bots-store.d.ts +3 -0
  254. package/dist/setup/bots-store.d.ts.map +1 -0
  255. package/dist/setup/bots-store.js +24 -0
  256. package/dist/setup/bots-store.js.map +1 -0
  257. package/dist/setup/detect-platform.d.ts +14 -0
  258. package/dist/setup/detect-platform.d.ts.map +1 -0
  259. package/dist/setup/detect-platform.js +139 -0
  260. package/dist/setup/detect-platform.js.map +1 -0
  261. package/dist/setup/ensure-fonts.d.ts +13 -0
  262. package/dist/setup/ensure-fonts.d.ts.map +1 -0
  263. package/dist/setup/ensure-fonts.js +225 -0
  264. package/dist/setup/ensure-fonts.js.map +1 -0
  265. package/dist/setup/ensure-tmux.d.ts +60 -0
  266. package/dist/setup/ensure-tmux.d.ts.map +1 -0
  267. package/dist/setup/ensure-tmux.js +236 -0
  268. package/dist/setup/ensure-tmux.js.map +1 -0
  269. package/dist/setup/index.d.ts +9 -0
  270. package/dist/setup/index.d.ts.map +1 -0
  271. package/dist/setup/index.js +46 -0
  272. package/dist/setup/index.js.map +1 -0
  273. package/dist/setup/lark-scopes.json +301 -0
  274. package/dist/setup/register-app.d.ts +52 -0
  275. package/dist/setup/register-app.d.ts.map +1 -0
  276. package/dist/setup/register-app.js +91 -0
  277. package/dist/setup/register-app.js.map +1 -0
  278. package/dist/setup/verify-permissions.d.ts +115 -0
  279. package/dist/setup/verify-permissions.d.ts.map +1 -0
  280. package/dist/setup/verify-permissions.js +207 -0
  281. package/dist/setup/verify-permissions.js.map +1 -0
  282. package/dist/skills/definitions.d.ts +4 -0
  283. package/dist/skills/definitions.d.ts.map +1 -1
  284. package/dist/skills/definitions.js +133 -19
  285. package/dist/skills/definitions.js.map +1 -1
  286. package/dist/skills/installer.d.ts +3 -1
  287. package/dist/skills/installer.d.ts.map +1 -1
  288. package/dist/skills/installer.js +18 -3
  289. package/dist/skills/installer.js.map +1 -1
  290. package/dist/types.d.ts +44 -0
  291. package/dist/types.d.ts.map +1 -1
  292. package/dist/utils/bot-routing.d.ts +6 -0
  293. package/dist/utils/bot-routing.d.ts.map +1 -0
  294. package/dist/utils/bot-routing.js +50 -0
  295. package/dist/utils/bot-routing.js.map +1 -0
  296. package/dist/utils/file-lock.d.ts +2 -0
  297. package/dist/utils/file-lock.d.ts.map +1 -0
  298. package/dist/utils/file-lock.js +114 -0
  299. package/dist/utils/file-lock.js.map +1 -0
  300. package/dist/utils/font-installer.js +1 -1
  301. package/dist/utils/font-installer.js.map +1 -1
  302. package/dist/utils/idle-detector.d.ts +6 -0
  303. package/dist/utils/idle-detector.d.ts.map +1 -1
  304. package/dist/utils/idle-detector.js +25 -4
  305. package/dist/utils/idle-detector.js.map +1 -1
  306. package/dist/utils/logger.d.ts +10 -0
  307. package/dist/utils/logger.d.ts.map +1 -1
  308. package/dist/utils/logger.js +60 -8
  309. package/dist/utils/logger.js.map +1 -1
  310. package/dist/utils/render-dimensions.d.ts +48 -0
  311. package/dist/utils/render-dimensions.d.ts.map +1 -0
  312. package/dist/utils/render-dimensions.js +55 -0
  313. package/dist/utils/render-dimensions.js.map +1 -0
  314. package/dist/utils/screen-analyzer.d.ts.map +1 -1
  315. package/dist/utils/screen-analyzer.js +24 -0
  316. package/dist/utils/screen-analyzer.js.map +1 -1
  317. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  318. package/dist/utils/screenshot-renderer.js +67 -23
  319. package/dist/utils/screenshot-renderer.js.map +1 -1
  320. package/dist/utils/terminal-renderer.d.ts +16 -0
  321. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  322. package/dist/utils/terminal-renderer.js +40 -23
  323. package/dist/utils/terminal-renderer.js.map +1 -1
  324. package/dist/utils/transient-snapshot.d.ts +28 -0
  325. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  326. package/dist/utils/transient-snapshot.js +96 -0
  327. package/dist/utils/transient-snapshot.js.map +1 -0
  328. package/dist/worker.js +2248 -83
  329. package/dist/worker.js.map +1 -1
  330. package/package.json +12 -5
@@ -6,16 +6,18 @@
6
6
  import * as Lark from '@larksuiteoapi/node-sdk';
7
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { getBot, getAllBots, findOncallChat } from '../../bot-registry.js';
9
+ import { getBot, getAllBots, isChatOncallBoundForAnyBot } from '../../bot-registry.js';
10
10
  import { config } from '../../config.js';
11
- import { getChatInfo, replyMessage } from './client.js';
11
+ import { getChatInfo, getChatMode, replyMessage, sendUserMessage } from './client.js';
12
12
  import { logger } from '../../utils/logger.js';
13
+ import { parseForceTopicInvocation } from '../../core/command-handler.js';
14
+ import { stripLeadingMentions } from './message-parser.js';
13
15
  // ─── Bot identity ─────────────────────────────────────────────────────────
14
16
  /** Set the bot's open_id. Callers should also call writeBotInfoFile() to persist. */
15
17
  export function setBotOpenId(larkAppId, id) {
16
18
  getBot(larkAppId).botOpenId = id;
17
19
  }
18
- /** Persist bot registry info to disk for MCP subprocesses to read.
20
+ /** Persist bot registry info to disk for agent-facing CLI subcommands to read.
19
21
  * Merges current process's bot(s) into the existing file so that
20
22
  * multiple daemon processes (one per bot) don't overwrite each other. */
21
23
  export function writeBotInfoFile(dataDir) {
@@ -82,6 +84,117 @@ export async function probeBotOpenId(larkAppId) {
82
84
  throw new Error('No open_id in bot info response');
83
85
  }
84
86
  }
87
+ // ─── Required-scope check ───────────────────────────────────────────────────
88
+ //
89
+ // Bot-to-bot @mention 投递依赖 "获取群组中其他机器人和用户@当前机器人的消息"
90
+ // 权限(scope: im:message.group_at_msg.include_bot:readonly)。该权限关闭
91
+ // 后飞书不会把跨 bot 的事件推到 WSClient,botmux 的 handleThreadReply 收
92
+ // 不到,看上去就是"另一个 bot @ 我没反应"——而 botmux 已经把本地 signal-file
93
+ // 转发删了,不再有兜底。启动时主动校验一下,缺了就向 allowedUsers[0] 私信
94
+ // 提示。
95
+ //
96
+ // 校验通过飞书 "Get application info" API(应用身份):
97
+ // GET /open-apis/application/v6/applications/{app_id}?lang=zh_cn
98
+ // 返回的 data.app.scopes 是个 {scope, description, ...} 数组,遍历找
99
+ // scope 字段是否包含目标 key。
100
+ //
101
+ // 鸡生蛋约束:调这个 API 自身需要 admin:app.info:readonly 或
102
+ // application:application:self_manage 中任一权限。后者免审批,是
103
+ // 推荐路径——拿不到 app info 时(飞书返回 99991672)我们就主动私信
104
+ // admin 提示开通 self_manage,下次重启就能自检。
105
+ const REQUIRED_BOT_AT_SCOPE = 'im:message.group_at_msg.include_bot:readonly';
106
+ const SELF_MANAGE_SCOPE = 'application:application:self_manage';
107
+ function getAdminOpenId(bot) {
108
+ return bot.resolvedAllowedUsers.find(u => u.startsWith('ou_'));
109
+ }
110
+ async function dmAdmin(larkAppId, adminOpenId, content, contextTag) {
111
+ try {
112
+ await sendUserMessage(larkAppId, adminOpenId, content, 'text');
113
+ logger.info(`[${larkAppId}] notified admin ${adminOpenId.substring(0, 12)} about ${contextTag}`);
114
+ }
115
+ catch (err) {
116
+ logger.warn(`[${larkAppId}] failed to DM admin about ${contextTag}: ${err?.message ?? err}`);
117
+ }
118
+ }
119
+ export async function checkRequiredScopes(larkAppId) {
120
+ const bot = getBot(larkAppId);
121
+ try {
122
+ const tokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ app_id: bot.config.larkAppId, app_secret: bot.config.larkAppSecret }),
126
+ });
127
+ const tokenData = await tokenRes.json();
128
+ if (tokenData.code !== 0) {
129
+ logger.debug(`[${larkAppId}] scope check skipped: tenant_access_token failed (${tokenData.msg})`);
130
+ return;
131
+ }
132
+ const infoRes = await fetch(`https://open.feishu.cn/open-apis/application/v6/applications/${bot.config.larkAppId}?lang=zh_cn`, { headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` } });
133
+ const infoData = await infoRes.json();
134
+ // 99991672 = 应用身份缺权限。最常见就是 admin:app.info:readonly /
135
+ // application:application:self_manage 都没拿到,导致根本查不到自己的
136
+ // scope 列表。这种"鸡生蛋"情况单独提示:让 admin 开通免审批的
137
+ // self_manage 后下次重启就能自检了。
138
+ if (infoData.code === 99991672) {
139
+ const selfManageAuthUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(SELF_MANAGE_SCOPE)}&op_from=openapi&token_type=tenant`;
140
+ const targetAuthUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(REQUIRED_BOT_AT_SCOPE)}&op_from=openapi&token_type=tenant`;
141
+ logger.warn(`[${larkAppId}] scope 自检 API 被拒(99991672):应用缺少 ${SELF_MANAGE_SCOPE}(免审批)。` +
142
+ `开通后下次 daemon 重启即可自动核验跨 bot @ 必需权限 ${REQUIRED_BOT_AT_SCOPE}。申请链接:${selfManageAuthUrl}`);
143
+ const adminOpenId = getAdminOpenId(bot);
144
+ if (!adminOpenId) {
145
+ logger.warn(`[${larkAppId}] 没有 resolved 的 admin open_id,self_manage 提示仅出现在 daemon 日志`);
146
+ return;
147
+ }
148
+ const dm = `⚠️ botmux 想自动核验机器人 "${bot.botName ?? larkAppId}" 是否开通了跨 bot @ 必需权限,但发现应用自身缺少一个**免审批**的辅助权限,因此查不到 scope 列表。\n\n` +
149
+ `**操作步骤(点链接 → 申请开通 → 重启 daemon)**:\n` +
150
+ `1. 开通 ${SELF_MANAGE_SCOPE}(免审批,自动通过):\n ${selfManageAuthUrl}\n\n` +
151
+ `2. 顺便确认/开通真正的目标权限 ${REQUIRED_BOT_AT_SCOPE}("获取群组中其他机器人和用户@当前机器人的消息",免审批,自动通过):\n ${targetAuthUrl}\n\n` +
152
+ `3. \`botmux restart\`,启动后 botmux 会自动复核,结果会再次发到这里。\n\n` +
153
+ `**为什么需要**:botmux 多机器人协作(A 机器人 @ B 机器人)依赖目标权限把跨 bot 事件推送过来;不开通则跨 bot @ 完全失效。`;
154
+ await dmAdmin(larkAppId, adminOpenId, dm, 'self_manage scope (auto-approved) missing');
155
+ return;
156
+ }
157
+ if (infoData.code !== 0) {
158
+ logger.debug(`[${larkAppId}] scope check skipped: app info failed (code=${infoData.code} msg=${infoData.msg ?? ''})`);
159
+ return;
160
+ }
161
+ // Lark 文档示例把 scopes 放在 data.app.scopes;为防响应结构变化,
162
+ // 同时兜底 data.scopes / data.application.scopes,取到的第一个非空数组为准。
163
+ const scopesRaw = infoData.data?.app?.scopes
164
+ ?? infoData.data?.application?.scopes
165
+ ?? infoData.data?.scopes
166
+ ?? [];
167
+ if (!Array.isArray(scopesRaw) || scopesRaw.length === 0) {
168
+ logger.debug(`[${larkAppId}] scope check inconclusive: scopes array empty or shape unexpected — skipping`);
169
+ return;
170
+ }
171
+ const grantedScopes = scopesRaw.map(s => typeof s === 'string' ? s : s?.scope).filter(Boolean);
172
+ if (grantedScopes.includes(REQUIRED_BOT_AT_SCOPE)) {
173
+ logger.info(`[${larkAppId}] required scope present: ${REQUIRED_BOT_AT_SCOPE}`);
174
+ return;
175
+ }
176
+ // Missing — log + DM
177
+ logger.error(`[${larkAppId}] 缺少必需权限 ${REQUIRED_BOT_AT_SCOPE}("获取群组中其他机器人和用户@当前机器人的消息")。` +
178
+ `跨 bot @ 消息无法到达本 bot,多 bot 协作会失效。请到飞书开放平台 → 应用 → 权限管理里申请该权限,开通后 \`botmux restart\`。`);
179
+ const adminOpenId = getAdminOpenId(bot);
180
+ if (!adminOpenId) {
181
+ logger.warn(`[${larkAppId}] no resolved admin open_id in allowedUsers; missing-scope warning visible only in daemon log`);
182
+ return;
183
+ }
184
+ const authUrl = `https://open.feishu.cn/app/${bot.config.larkAppId}/auth?q=${encodeURIComponent(REQUIRED_BOT_AT_SCOPE)}&op_from=openapi&token_type=tenant`;
185
+ const dm = `⚠️ botmux 启动检查发现机器人 "${bot.botName ?? larkAppId}" 缺少必需权限\n\n` +
186
+ `权限名:获取群组中其他机器人和用户@当前机器人的消息\n` +
187
+ `scope: ${REQUIRED_BOT_AT_SCOPE}\n\n` +
188
+ `没开通的话,跨机器人 @ 收不到事件,botmux 多机器人协作的整套场景都失效。\n\n` +
189
+ `**操作步骤**:\n` +
190
+ `1. 点链接申请权限(免审批,自动通过):${authUrl}\n` +
191
+ `2. \`botmux restart\``;
192
+ await dmAdmin(larkAppId, adminOpenId, dm, 'required scope missing');
193
+ }
194
+ catch (err) {
195
+ logger.debug(`[${larkAppId}] scope check errored: ${err?.message ?? err}`);
196
+ }
197
+ }
85
198
  // ─── Group chat stats cache ───────────────────────────────────────────────
86
199
  //
87
200
  // chat.get returns both user_count (real users only) and bot_count (bots).
@@ -101,7 +214,9 @@ export async function getGroupStats(larkAppId, chatId) {
101
214
  return info;
102
215
  }
103
216
  catch (err) {
104
- logger.warn(`Failed to get chat stats for ${chatId}: ${err}`);
217
+ // Soft failure the fallback below assumes worst case (multi-user,
218
+ // multi-bot → require @mention). No user-visible regression, so debug.
219
+ logger.debug(`Failed to get chat stats for ${chatId}, using safe fallback: ${err}`);
105
220
  if (cached)
106
221
  return { userCount: cached.userCount, botCount: cached.botCount };
107
222
  // Fallback: assume multi-person, multi-bot → require @mention to be safe.
@@ -133,6 +248,17 @@ export function readBotOpenIdCrossRef(dataDir, larkAppId) {
133
248
  catch { /* ignore */ }
134
249
  return map;
135
250
  }
251
+ /** Is `senderOpenId` a registered botmux peer (from larkAppId's cross-ref)?
252
+ * Used to gate chat-scope foreign-bot @mention spawning to vetted peers. */
253
+ export function isKnownPeerBot(dataDir, larkAppId, senderOpenId) {
254
+ if (!senderOpenId)
255
+ return false;
256
+ for (const openId of readBotOpenIdCrossRef(dataDir, larkAppId).values()) {
257
+ if (openId === senderOpenId)
258
+ return true;
259
+ }
260
+ return false;
261
+ }
136
262
  /** Update the per-bot cross-reference from @mention data in an event.
137
263
  * mentionsList comes from Lark event message.mentions array. */
138
264
  export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
@@ -192,7 +318,10 @@ export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
192
318
  export function isBotMentioned(larkAppId, message, _senderOpenId) {
193
319
  const botOpenId = getBot(larkAppId).botOpenId;
194
320
  if (!botOpenId) {
195
- logger.warn('Bot open_id unknown, cannot check @mentions');
321
+ // Startup race: events can arrive before probeBotOpenId() resolves the
322
+ // per-bot open_id. Subsequent events succeed once the probe completes,
323
+ // so this is not a real warning — drop to debug to keep error.log clean.
324
+ logger.debug(`[${larkAppId}] Bot open_id not yet known, skipping @mention check`);
196
325
  return false;
197
326
  }
198
327
  // 1. Check message.mentions array (populated for user-sent text messages)
@@ -221,27 +350,30 @@ export function isBotMentioned(larkAppId, message, _senderOpenId) {
221
350
  }
222
351
  // ─── Permission gates ────────────────────────────────────────────────────
223
352
  //
224
- // Two separate gates for oncall support:
353
+ // Two gates:
225
354
  // canTalk — may address the bot in this chat (prompts, thread replies)
226
355
  // canOperate — may trigger state-changing actions (card buttons, daemon
227
356
  // slash commands like /cd /restart /close /oncall)
228
357
  //
229
- // Non-oncall chats: both fall back to the bot's allowedUsers. Oncall-bound
230
- // chats: talking is open to everyone; operating is restricted to the entry's
231
- // `owners` list (initial binder + anyone they later add).
358
+ // Non-oncall chats: both fall back to the bot's allowedUsers.
359
+ // Oncall-bound chats: talking is open to everyone in the group; operating
360
+ // still requires allowedUsers (single source of truth no per-chat owners).
361
+ //
362
+ // Oncall is a chat-level concept: `isChatOncallBoundForAnyBot` returns true
363
+ // when ANY bot (this one or a sibling in another daemon) has the chat bound,
364
+ // so an unbound sibling doesn't fall back to allowedUsers and reply
365
+ // "⚠️ 无操作权限" when @-mentioned in a shared oncall workspace.
232
366
  export function canTalk(larkAppId, chatId, senderOpenId) {
233
- const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
234
- if (oncall)
367
+ if (chatId && isChatOncallBoundForAnyBot(chatId))
368
+ return true;
369
+ if (isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId))
235
370
  return true;
236
371
  const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
237
372
  if (allowedUsers.length === 0)
238
373
  return true;
239
374
  return !!senderOpenId && allowedUsers.includes(senderOpenId);
240
375
  }
241
- export function canOperate(larkAppId, chatId, senderOpenId) {
242
- const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
243
- if (oncall)
244
- return !!senderOpenId && oncall.owners.includes(senderOpenId);
376
+ export function canOperate(larkAppId, _chatId, senderOpenId) {
245
377
  const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
246
378
  if (allowedUsers.length === 0)
247
379
  return true;
@@ -273,6 +405,123 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
273
405
  }
274
406
  return 'ignore';
275
407
  }
408
+ /**
409
+ * Best-effort plain-text extraction from a Lark message for routing-level
410
+ * decisions (currently: `/t` / `/topic` detection). Handles the two common
411
+ * shapes — `text` (`{"text": "..."}`) and `post` (zh_cn/en_us nested
412
+ * paragraphs of `text` / `at` nodes). Other types (image, file, sticker,
413
+ * interactive, …) return null so the caller falls through to the default
414
+ * routing path.
415
+ *
416
+ * Kept deliberately tiny rather than reusing parseEventMessage: the dispatcher
417
+ * runs on every inbound event and we only need a quick text peek before the
418
+ * permission gates / scope override; full parseEventMessage still runs once
419
+ * inside the chosen handler.
420
+ */
421
+ export function extractMessageTextForRouting(message) {
422
+ if (!message?.content)
423
+ return null;
424
+ try {
425
+ const obj = JSON.parse(message.content);
426
+ // text shape: {"text":"..."}. Lark stuffs placeholder keys like "@_user_1"
427
+ // into obj.text; the human name only lives in message.mentions[].name. We
428
+ // must resolve keys → @${name} so stripLeadingMentions can strip them
429
+ // before parseForceTopicInvocation sees the content. Mirrors the
430
+ // resolveMentions logic in parseEventMessage.
431
+ if (typeof obj?.text === 'string') {
432
+ let text = obj.text;
433
+ const mentions = message?.mentions;
434
+ if (Array.isArray(mentions)) {
435
+ for (const m of mentions) {
436
+ if (m?.key && m?.name) {
437
+ text = text.split(m.key).join(`@${m.name}`);
438
+ }
439
+ }
440
+ }
441
+ return text;
442
+ }
443
+ // post shape: {"zh_cn":{"content":[[{tag:"text",text:"..."},{tag:"at",...}]]}}
444
+ // Post messages keep @mentions as separate `at` nodes (not embedded in
445
+ // text), so the joined text-node content is already clean of placeholders.
446
+ const inner = obj?.zh_cn ?? obj?.en_us ?? obj;
447
+ if (Array.isArray(inner?.content)) {
448
+ const parts = [];
449
+ for (const para of inner.content) {
450
+ if (!Array.isArray(para))
451
+ continue;
452
+ for (const node of para) {
453
+ if (node?.tag === 'text' && typeof node.text === 'string') {
454
+ parts.push(node.text);
455
+ }
456
+ }
457
+ }
458
+ return parts.length > 0 ? parts.join('') : null;
459
+ }
460
+ }
461
+ catch { /* malformed content — skip */ }
462
+ return null;
463
+ }
464
+ /**
465
+ * If the inbound message starts with `/t` / `/topic` AND the routing
466
+ * currently lands on chat-scope, override to thread-scope anchored at
467
+ * the inbound message_id. This makes "force topic mode" work even when
468
+ * the bot already owns a chat-scope session in the chat — the dispatcher
469
+ * routes to handleNewTopic at a fresh anchor instead of falling into
470
+ * handleThreadReply on the chat-scope owner.
471
+ *
472
+ * Already-thread messages (real Lark 话题, p2p, 话题群) are left alone:
473
+ * the prefix is still stripped downstream by handleNewTopic.
474
+ */
475
+ export function maybeApplyForceTopicOverride(routing, message, messageId) {
476
+ if (routing.scope !== 'chat')
477
+ return false;
478
+ const rawText = extractMessageTextForRouting(message);
479
+ if (!rawText)
480
+ return false;
481
+ const stripped = stripLeadingMentions(rawText.trim(), message?.mentions ?? []);
482
+ if (!parseForceTopicInvocation(stripped))
483
+ return false;
484
+ routing.scope = 'thread';
485
+ routing.anchor = messageId;
486
+ return true;
487
+ }
488
+ /** Compute the scope + anchor for an inbound message:
489
+ * - root_id + thread_id → thread-scope, anchor = root_id (real Lark 话题)
490
+ * - 话题群 + no real thread → thread-scope, anchor = message_id (thread seed)
491
+ * - p2p + no real thread → thread-scope, anchor = message_id (each DM
492
+ * top-level message starts a fresh topic; a
493
+ * reply inside an existing thread carries
494
+ * root_id+thread_id and threads into its session)
495
+ * - 普通群 + no real thread → chat-scope, anchor = chat_id (entire group
496
+ * is one session)
497
+ *
498
+ * Why we gate on thread_id (not root_id alone): Lark 客户端的引用气泡 / 快速
499
+ * 回复 UI 有时会给"用户视角的顶层消息"塞 root_id 但**不会**塞 thread_id。
500
+ * 飞书官方文档:root_id/parent_id "仅在回复消息场景会有返回值";thread_id
501
+ * "不返回说明该消息非话题消息"。所以 thread_id 才是"是否真的处于话题里"的
502
+ * 权威信号。只看 root_id 会把 quote-bubble 错认为话题回复,把用户从 chat-scope
503
+ * 会话里拽走、又起一个孤立的 thread session。
504
+ * Exported for unit tests. */
505
+ export async function decideRouting(larkAppId, message) {
506
+ const rootId = message.root_id;
507
+ const threadId = message.thread_id;
508
+ if (rootId && threadId)
509
+ return { scope: 'thread', anchor: rootId };
510
+ const chatType = message.chat_type ?? 'group';
511
+ const messageId = message.message_id;
512
+ const chatId = message.chat_id;
513
+ // 私聊:每条 top-level DM 都视为新话题 — 跟话题群同款,匹配 Lark DM 的话题
514
+ // 化默认行为,避免无限把 1:1 对话塞进同一个 CLI 进程里。
515
+ if (chatType === 'p2p') {
516
+ return { scope: 'thread', anchor: messageId };
517
+ }
518
+ // Group chat — fetch chat_mode (cached) to disambiguate 话题群 from 普通群.
519
+ const mode = await getChatMode(larkAppId, chatId);
520
+ if (mode === 'topic') {
521
+ return { scope: 'thread', anchor: messageId };
522
+ }
523
+ return { scope: 'chat', anchor: chatId };
524
+ }
276
525
  /**
277
526
  * Create and start the Lark WSClient with event dispatching.
278
527
  * Returns the WSClient instance for lifecycle management.
@@ -303,15 +552,26 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
303
552
  if (message.mentions?.length > 0) {
304
553
  updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
305
554
  }
306
- // Bot-originated messages
307
- if (sender?.sender_type === 'app') {
555
+ const chatId = message.chat_id;
556
+ const chatType = (message.chat_type === 'p2p' ? 'p2p' : 'group');
557
+ const messageId = message.message_id;
558
+ // Bot-originated messages — bots historically only post inside threads
559
+ // (their own thread replies). With chat-scope sessions a bot can also
560
+ // post top-level (its first reply in a chat-scope group), so we still
561
+ // route them through `decideRouting` rather than gating on root_id.
562
+ //
563
+ // 飞书在跨 bot 卡片消息场景实测会把发送方标成 sender_type='bot'(不是
564
+ // 文档里写的 'app'),所以这里两个值都接受,否则那条路径会落到下面的
565
+ // user-message 通用分支,绕开 /close self-message 特判、foreign-bot
566
+ // chat-scope gate(isKnownPeerBot)和"Bot-to-bot @mention detected"
567
+ // 日志。
568
+ const senderType = sender?.sender_type;
569
+ const isBotSenderType = senderType === 'app' || senderType === 'bot';
570
+ if (isBotSenderType) {
308
571
  const senderOpenId = sender.sender_id?.open_id;
309
- const rootId = message.root_id;
310
- if (!rootId)
311
- return; // ignore bot messages outside threads
312
572
  const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
573
+ // Self messages: only echoed `/close` commands matter.
313
574
  if (isSelfMessage) {
314
- // Own messages: only process /close commands
315
575
  try {
316
576
  const body = JSON.parse(message.content ?? '{}');
317
577
  if (body.text?.trim() !== '/close')
@@ -320,69 +580,142 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
320
580
  catch {
321
581
  return;
322
582
  }
323
- handlers.handleThreadReply(data, rootId, larkAppId).catch(err => logger.error(`Error handling message event: ${err}`));
583
+ const ctx = await decideRouting(larkAppId, message);
584
+ handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId })
585
+ .catch(err => logger.error(`Error handling message event: ${err}`));
324
586
  return;
325
587
  }
326
- // Message from another bot: check if it @mentions this bot
327
- if (isBotMentioned(larkAppId, message, undefined)) {
328
- logger.info(`Bot-to-bot @mention detected: routing to handleThreadReply`);
329
- handlers.handleThreadReply(data, rootId, larkAppId).catch(err => logger.error(`Error handling bot @mention: ${err}`));
588
+ // Foreign bot: only route on @mention of us.
589
+ if (!isBotMentioned(larkAppId, message, undefined))
590
+ return;
591
+ const ctx = await decideRouting(larkAppId, message);
592
+ // Chat-scope foreign-bot @mention without an existing session: gate to
593
+ // vetted botmux peers (registered in our bot-openids cross-ref). This
594
+ // keeps random Lark bots from silently spawning chat-scope sessions
595
+ // in 普通群/p2p, while letting Bot A → Bot B handoffs in 普通群 work
596
+ // (handleThreadReply auto-create + chat-scope inheritance below).
597
+ if (ctx.scope === 'chat') {
598
+ const ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false;
599
+ if (!ownsSession && !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)) {
600
+ return;
601
+ }
330
602
  }
603
+ logger.info(`Bot-to-bot @mention detected (scope=${ctx.scope}): routing to handleThreadReply`);
604
+ handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId })
605
+ .catch(err => logger.error(`Error handling bot @mention: ${err}`));
331
606
  return;
332
607
  }
333
- const rootId = message.root_id;
334
- const chatId = message.chat_id;
335
- const chatType = message.chat_type; // 'group' or 'p2p'
336
- const messageId = message.message_id;
337
608
  const senderOpenId = sender?.sender_id?.open_id;
338
609
  const isAllowed = canTalk(larkAppId, chatId, senderOpenId);
339
610
  logger.debug('Received message:', message);
340
- // Group new topics (no rootId): check @mention + permissions
341
- if (chatType === 'group' && !rootId) {
342
- const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
343
- logger.debug('Group message access check:', access);
344
- if (access === 'not_allowed') {
345
- replyMessage(larkAppId, messageId, JSON.stringify({ text: '⚠️ 无操作权限' }))
346
- .catch(err => logger.debug(`Failed to send permission denied: ${err}`));
347
- return;
348
- }
349
- if (access === 'ignore') {
350
- logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
351
- return;
611
+ // Diagnostic: record the Lark quote-bubble UI quirk where root_id
612
+ // appears without thread_id. decideRouting now treats this as
613
+ // "no thread" (chat-scope / topic / new-topic depending on context),
614
+ // which is the authoritative behavior. Logging it here so we can spot
615
+ // any future surprise in the wild.
616
+ if (message.root_id && !message.thread_id) {
617
+ logger.info(`[routing] root_id w/o thread_id (Lark UI quirk, treating as top-level): ` +
618
+ `msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} ` +
619
+ `type=${chatType} root=${String(message.root_id).substring(0, 12)} ` +
620
+ `parent=${String(message.parent_id ?? '').substring(0, 12)}`);
621
+ }
622
+ const routing = await decideRouting(larkAppId, message);
623
+ // 话题群 → 普通群 (reverse conversion). Symmetric to the forward check
624
+ // below: when decideRouting lands on thread-scope purely because the
625
+ // *cached* chat_mode said 'topic' (no real thread_id on the message
626
+ // either — i.e. this would seed a brand-new thread), our 5-min cache
627
+ // may be stale from before a flip-back to 普通群. Re-verify with
628
+ // forceRefresh; if Lark now reports 'group', flatten to chat-scope so
629
+ // the bot doesn't keep wrapping every top-level reply in a fresh
630
+ // Lark topic via reply_in_thread.
631
+ //
632
+ // Skip when there's a real thread_id (authoritative thread signal,
633
+ // can't be cache-stale) or when chatType is p2p (DMs always thread).
634
+ // Runs BEFORE /t override so a `@bot /t …` in a now-flat 普通群 still
635
+ // gets the explicit topic seed it asked for.
636
+ if (routing.scope === 'thread' &&
637
+ routing.anchor === messageId &&
638
+ !message.thread_id &&
639
+ chatType === 'group') {
640
+ const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
641
+ if (freshMode === 'group') {
642
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'topic' → 'group'; ` +
643
+ `rerouting msg=${messageId.substring(0, 12)} as chat-scope`);
644
+ routing.scope = 'chat';
645
+ routing.anchor = chatId;
352
646
  }
353
647
  }
354
- else if (chatType === 'group' && rootId) {
355
- // Group thread replies:
356
- // - 1v1-style chat (sole real user + sole bot) + owns session → respond without @mention
357
- // - Multi-user OR multi-bot group → always require @mention, even for session owners
358
- // - Non-owner botsrequire @mention to join/take over
359
- const ownsSession = handlers.isSessionOwner?.(rootId, larkAppId) ?? false;
360
- const stats = ownsSession ? await getGroupStats(larkAppId, chatId) : null;
361
- if (ownsSession && isAllowed && stats && stats.userCount <= 1 && stats.botCount <= 1) {
362
- // 1v1-style group + owns session process without @mention
648
+ // /t / /topic in 普通群: flip routing to thread-scope so the bot's
649
+ // first reply seeds a fresh Lark thread, even if a chat-scope session
650
+ // is currently active in this chat.
651
+ if (maybeApplyForceTopicOverride(routing, message, messageId)) {
652
+ logger.info(`[/t] Force-topic override: msg=${messageId.substring(0, 12)} thread-scope, anchor=msg`);
653
+ }
654
+ let ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
655
+ // 普通群 话题群 conversion detection. Lark group admins can flip
656
+ // chat_mode at any time; our 30/5-min cache lags. If routing landed on
657
+ // chat-scope AND we own a session at this chat, the chat-scope session
658
+ // may be stale from before a conversion. Re-fetch chat_mode with
659
+ // forceRefresh to confirm. If it's now 'topic', the session is dead:
660
+ // sendMessage(chatId) at dispatch time would wrap each reply in a new
661
+ // Lark topic (the user-reported bug). Evict the stale session, then
662
+ // route this message as if it were a brand-new thread seed so
663
+ // handleNewTopic spawns a thread-scope session anchored at messageId.
664
+ // Gate on ownsSession to avoid an API roundtrip on every fresh inbound.
665
+ if (routing.scope === 'chat' && ownsSession) {
666
+ const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
667
+ if (freshMode === 'topic') {
668
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'group' → 'topic'; ` +
669
+ `evicting stale chat-scope session and rerouting msg=${messageId.substring(0, 12)} as thread seed`);
670
+ try {
671
+ handlers.onChatModeConverted?.(chatId, larkAppId);
672
+ }
673
+ catch (err) {
674
+ logger.warn(`onChatModeConverted handler threw: ${err}`);
675
+ }
676
+ routing.scope = 'thread';
677
+ routing.anchor = messageId;
678
+ // ownsSession was true on the stale chatId anchor; the new anchor
679
+ // (messageId) is brand-new, so no current session owns it.
680
+ ownsSession = false;
363
681
  }
364
- else {
682
+ }
683
+ // Permission gating — same shape as before, just keyed on
684
+ // `ownsSession` (anchor-aware) instead of "rootId presence":
685
+ //
686
+ // ownsSession + 1v1 group → relax (no @mention required)
687
+ // ownsSession + multi → require @mention
688
+ // !ownsSession (group) → require @mention + allowlist
689
+ // p2p → allowlist only
690
+ if (chatType === 'group') {
691
+ let stats = null;
692
+ if (ownsSession)
693
+ stats = await getGroupStats(larkAppId, chatId);
694
+ const relax = ownsSession && isAllowed && !!stats && stats.userCount <= 1 && stats.botCount <= 1;
695
+ if (!relax) {
365
696
  const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
366
697
  if (access === 'not_allowed') {
367
- logger.debug(`Ignoring thread reply from non-allowed user: ${senderOpenId}`);
698
+ if (!ownsSession) {
699
+ replyMessage(larkAppId, messageId, JSON.stringify({ text: '⚠️ 无操作权限' }))
700
+ .catch(err => logger.debug(`Failed to send permission denied: ${err}`));
701
+ }
702
+ logger.debug(`Ignoring group message from non-allowed user: ${senderOpenId}`);
368
703
  return;
369
704
  }
370
705
  if (access === 'ignore') {
371
- logger.debug(`Ignoring group thread reply not addressed to bot: ${messageId}`);
706
+ logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
372
707
  return;
373
708
  }
374
709
  }
375
710
  }
376
711
  else if (!isAllowed) {
377
- // P2P thread replies and DMs: still check allowlist
378
- logger.debug(`Ignoring message from non-allowed user: ${senderOpenId}`);
712
+ logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
379
713
  return;
380
714
  }
381
- // p2p messages without rootId -> create session directly in the DM chat
382
- // group messages -> normal flow
383
- const promise = !rootId
384
- ? handlers.handleNewTopic(data, chatId, messageId, chatType, larkAppId)
385
- : handlers.handleThreadReply(data, rootId, larkAppId);
715
+ const ctx = { chatId, messageId, chatType, larkAppId, ...routing };
716
+ const promise = ownsSession
717
+ ? handlers.handleThreadReply(data, ctx)
718
+ : handlers.handleNewTopic(data, ctx);
386
719
  promise.catch(err => logger.error(`Error handling message event: ${err}`));
387
720
  }
388
721
  catch (err) {
@@ -394,7 +727,10 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
394
727
  const wsClient = new Lark.WSClient({
395
728
  appId: larkAppId,
396
729
  appSecret: larkAppSecret,
397
- loggerLevel: Lark.LoggerLevel.info,
730
+ // Default to warn — the SDK is chatty at info ("client ready", reconnect
731
+ // heartbeats, etc.) and floods pm2 error.log when stderr is the only sink.
732
+ // DEBUG=1 widens the level back to info for troubleshooting.
733
+ loggerLevel: process.env.DEBUG ? Lark.LoggerLevel.info : Lark.LoggerLevel.warn,
398
734
  });
399
735
  wsClient.start({ eventDispatcher });
400
736
  logger.info('Daemon WSClient started');