botmux 2.9.0 → 2.9.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 (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 +601 -309
  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 +36 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +119 -13
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +92 -8
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +410 -89
  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 +2220 -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, listChatBotMembers, 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,40 +84,143 @@ export async function probeBotOpenId(larkAppId) {
82
84
  throw new Error('No open_id in bot info response');
83
85
  }
84
86
  }
85
- // ─── Group user count cache ───────────────────────────────────────────────
86
- const chatUserCountCache = new Map();
87
- export const CHAT_CACHE_TTL = 5 * 60_000; // 5 minutes
88
- export async function getGroupUserCount(larkAppId, chatId) {
89
- const cacheKey = `${larkAppId}:${chatId}`;
90
- const cached = chatUserCountCache.get(cacheKey);
91
- if (cached && Date.now() - cached.fetchedAt < CHAT_CACHE_TTL) {
92
- return cached.count;
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}`);
93
117
  }
118
+ }
119
+ export async function checkRequiredScopes(larkAppId) {
120
+ const bot = getBot(larkAppId);
94
121
  try {
95
- const info = await getChatInfo(larkAppId, chatId);
96
- chatUserCountCache.set(cacheKey, { count: info.userCount, fetchedAt: Date.now() });
97
- return info.userCount;
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');
98
193
  }
99
194
  catch (err) {
100
- logger.debug(`Failed to get chat user count for ${chatId}: ${err}`);
101
- return cached?.count ?? 999; // fallback: assume multi-person
195
+ logger.debug(`[${larkAppId}] scope check errored: ${err?.message ?? err}`);
102
196
  }
103
197
  }
104
- const chatBotCountCache = new Map();
105
- export async function getGroupBotCount(larkAppId, chatId) {
198
+ // ─── Group chat stats cache ───────────────────────────────────────────────
199
+ //
200
+ // chat.get returns both user_count (real users only) and bot_count (bots).
201
+ // One API call, one cache — used to gate auto-replies in multi-bot/multi-user
202
+ // groups (oncall chats often have 3rd-party oncall/form/AI-search bots).
203
+ export const CHAT_CACHE_TTL = 5 * 60_000; // 5 minutes
204
+ const chatStatsCache = new Map();
205
+ export async function getGroupStats(larkAppId, chatId) {
106
206
  const cacheKey = `${larkAppId}:${chatId}`;
107
- const cached = chatBotCountCache.get(cacheKey);
207
+ const cached = chatStatsCache.get(cacheKey);
108
208
  if (cached && Date.now() - cached.fetchedAt < CHAT_CACHE_TTL) {
109
- return cached.count;
209
+ return { userCount: cached.userCount, botCount: cached.botCount };
110
210
  }
111
211
  try {
112
- const bots = await listChatBotMembers(larkAppId, chatId);
113
- chatBotCountCache.set(cacheKey, { count: bots.length, fetchedAt: Date.now() });
114
- return bots.length;
212
+ const info = await getChatInfo(larkAppId, chatId);
213
+ chatStatsCache.set(cacheKey, { userCount: info.userCount, botCount: info.botCount, fetchedAt: Date.now() });
214
+ return info;
115
215
  }
116
216
  catch (err) {
117
- logger.warn(`Failed to get chat bot count for ${chatId}: ${err}`);
118
- return cached?.count ?? 999; // fallback: assume multiple bots → require @mention
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}`);
220
+ if (cached)
221
+ return { userCount: cached.userCount, botCount: cached.botCount };
222
+ // Fallback: assume multi-person, multi-bot → require @mention to be safe.
223
+ return { userCount: 999, botCount: 999 };
119
224
  }
120
225
  }
121
226
  // ─── Cross-bot open_id mapping ──────────────────────────────────────────
@@ -143,6 +248,17 @@ export function readBotOpenIdCrossRef(dataDir, larkAppId) {
143
248
  catch { /* ignore */ }
144
249
  return map;
145
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
+ }
146
262
  /** Update the per-bot cross-reference from @mention data in an event.
147
263
  * mentionsList comes from Lark event message.mentions array. */
148
264
  export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
@@ -202,7 +318,10 @@ export function updateBotOpenIdCrossRef(dataDir, larkAppId, mentionsList) {
202
318
  export function isBotMentioned(larkAppId, message, _senderOpenId) {
203
319
  const botOpenId = getBot(larkAppId).botOpenId;
204
320
  if (!botOpenId) {
205
- 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`);
206
325
  return false;
207
326
  }
208
327
  // 1. Check message.mentions array (populated for user-sent text messages)
@@ -231,27 +350,30 @@ export function isBotMentioned(larkAppId, message, _senderOpenId) {
231
350
  }
232
351
  // ─── Permission gates ────────────────────────────────────────────────────
233
352
  //
234
- // Two separate gates for oncall support:
353
+ // Two gates:
235
354
  // canTalk — may address the bot in this chat (prompts, thread replies)
236
355
  // canOperate — may trigger state-changing actions (card buttons, daemon
237
356
  // slash commands like /cd /restart /close /oncall)
238
357
  //
239
- // Non-oncall chats: both fall back to the bot's allowedUsers. Oncall-bound
240
- // chats: talking is open to everyone; operating is restricted to the entry's
241
- // `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.
242
366
  export function canTalk(larkAppId, chatId, senderOpenId) {
243
- const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
244
- if (oncall)
367
+ if (chatId && isChatOncallBoundForAnyBot(chatId))
368
+ return true;
369
+ if (isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId))
245
370
  return true;
246
371
  const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
247
372
  if (allowedUsers.length === 0)
248
373
  return true;
249
374
  return !!senderOpenId && allowedUsers.includes(senderOpenId);
250
375
  }
251
- export function canOperate(larkAppId, chatId, senderOpenId) {
252
- const oncall = chatId ? findOncallChat(larkAppId, chatId) : undefined;
253
- if (oncall)
254
- return !!senderOpenId && oncall.owners.includes(senderOpenId);
376
+ export function canOperate(larkAppId, _chatId, senderOpenId) {
255
377
  const allowedUsers = getBot(larkAppId).resolvedAllowedUsers;
256
378
  if (allowedUsers.length === 0)
257
379
  return true;
@@ -274,13 +396,8 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
274
396
  // No @mention — only allow if sender is the sole human in the group
275
397
  // AND this is the only bot in the chat. With multiple bots, require @mention
276
398
  // to disambiguate.
277
- // Note: each daemon registers only 1 bot, so getAllBots().length is always 1.
278
- // Use getGroupBotCount (API query) to get the real count of bots in the chat.
279
399
  if (isAllowed) {
280
- const [userCount, botCount] = await Promise.all([
281
- getGroupUserCount(larkAppId, chatId),
282
- getGroupBotCount(larkAppId, chatId),
283
- ]);
400
+ const { userCount, botCount } = await getGroupStats(larkAppId, chatId);
284
401
  logger.debug(`Group user count: ${userCount}, bot count: ${botCount}`);
285
402
  if (userCount <= 1 && botCount <= 1) {
286
403
  return 'allowed';
@@ -288,6 +405,123 @@ export async function checkGroupMessageAccess(larkAppId, message, chatId, sender
288
405
  }
289
406
  return 'ignore';
290
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
+ }
291
525
  /**
292
526
  * Create and start the Lark WSClient with event dispatching.
293
527
  * Returns the WSClient instance for lifecycle management.
@@ -318,15 +552,26 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
318
552
  if (message.mentions?.length > 0) {
319
553
  updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
320
554
  }
321
- // Bot-originated messages
322
- 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) {
323
571
  const senderOpenId = sender.sender_id?.open_id;
324
- const rootId = message.root_id;
325
- if (!rootId)
326
- return; // ignore bot messages outside threads
327
572
  const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
573
+ // Self messages: only echoed `/close` commands matter.
328
574
  if (isSelfMessage) {
329
- // Own messages: only process /close commands
330
575
  try {
331
576
  const body = JSON.parse(message.content ?? '{}');
332
577
  if (body.text?.trim() !== '/close')
@@ -335,69 +580,142 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
335
580
  catch {
336
581
  return;
337
582
  }
338
- 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}`));
339
586
  return;
340
587
  }
341
- // Message from another bot: check if it @mentions this bot
342
- if (isBotMentioned(larkAppId, message, undefined)) {
343
- logger.info(`Bot-to-bot @mention detected: routing to handleThreadReply`);
344
- 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
+ }
345
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}`));
346
606
  return;
347
607
  }
348
- const rootId = message.root_id;
349
- const chatId = message.chat_id;
350
- const chatType = message.chat_type; // 'group' or 'p2p'
351
- const messageId = message.message_id;
352
608
  const senderOpenId = sender?.sender_id?.open_id;
353
609
  const isAllowed = canTalk(larkAppId, chatId, senderOpenId);
354
610
  logger.debug('Received message:', message);
355
- // Group new topics (no rootId): check @mention + permissions
356
- if (chatType === 'group' && !rootId) {
357
- const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
358
- logger.debug('Group message access check:', access);
359
- if (access === 'not_allowed') {
360
- replyMessage(larkAppId, messageId, JSON.stringify({ text: '⚠️ 无操作权限' }))
361
- .catch(err => logger.debug(`Failed to send permission denied: ${err}`));
362
- return;
363
- }
364
- if (access === 'ignore') {
365
- logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
366
- 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;
367
646
  }
368
647
  }
369
- else if (chatType === 'group' && rootId) {
370
- // Group thread replies:
371
- // - Sole bot in chat + owns session → respond without @mention
372
- // - Multiple bots in chat → always require @mention, even for session owners
373
- // - Non-owner botsrequire @mention to join/take over
374
- const ownsSession = handlers.isSessionOwner?.(rootId, larkAppId) ?? false;
375
- const botCount = ownsSession ? await getGroupBotCount(larkAppId, chatId) : 0;
376
- if (ownsSession && isAllowed && botCount <= 1) {
377
- // Sole bot in chat + 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;
378
681
  }
379
- 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) {
380
696
  const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId);
381
697
  if (access === 'not_allowed') {
382
- 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}`);
383
703
  return;
384
704
  }
385
705
  if (access === 'ignore') {
386
- logger.debug(`Ignoring group thread reply not addressed to bot: ${messageId}`);
706
+ logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
387
707
  return;
388
708
  }
389
709
  }
390
710
  }
391
711
  else if (!isAllowed) {
392
- // P2P thread replies and DMs: still check allowlist
393
- logger.debug(`Ignoring message from non-allowed user: ${senderOpenId}`);
712
+ logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
394
713
  return;
395
714
  }
396
- // p2p messages without rootId -> create session directly in the DM chat
397
- // group messages -> normal flow
398
- const promise = !rootId
399
- ? handlers.handleNewTopic(data, chatId, messageId, chatType, larkAppId)
400
- : 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);
401
719
  promise.catch(err => logger.error(`Error handling message event: ${err}`));
402
720
  }
403
721
  catch (err) {
@@ -409,7 +727,10 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers) {
409
727
  const wsClient = new Lark.WSClient({
410
728
  appId: larkAppId,
411
729
  appSecret: larkAppSecret,
412
- 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,
413
734
  });
414
735
  wsClient.start({ eventDispatcher });
415
736
  logger.info('Daemon WSClient started');