@geminixiang/mikan 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/LICENSE +22 -0
  3. package/README.md +297 -0
  4. package/dist/adapter.d.ts +134 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +2 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/adapters/discord/bot.d.ts +63 -0
  9. package/dist/adapters/discord/bot.d.ts.map +1 -0
  10. package/dist/adapters/discord/bot.js +577 -0
  11. package/dist/adapters/discord/bot.js.map +1 -0
  12. package/dist/adapters/discord/context.d.ts +9 -0
  13. package/dist/adapters/discord/context.d.ts.map +1 -0
  14. package/dist/adapters/discord/context.js +245 -0
  15. package/dist/adapters/discord/context.js.map +1 -0
  16. package/dist/adapters/discord/index.d.ts +3 -0
  17. package/dist/adapters/discord/index.d.ts.map +1 -0
  18. package/dist/adapters/discord/index.js +3 -0
  19. package/dist/adapters/discord/index.js.map +1 -0
  20. package/dist/adapters/shared.d.ts +91 -0
  21. package/dist/adapters/shared.d.ts.map +1 -0
  22. package/dist/adapters/shared.js +191 -0
  23. package/dist/adapters/shared.js.map +1 -0
  24. package/dist/adapters/slack/bot.d.ts +139 -0
  25. package/dist/adapters/slack/bot.d.ts.map +1 -0
  26. package/dist/adapters/slack/bot.js +1272 -0
  27. package/dist/adapters/slack/bot.js.map +1 -0
  28. package/dist/adapters/slack/branch-manager.d.ts +28 -0
  29. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  30. package/dist/adapters/slack/branch-manager.js +117 -0
  31. package/dist/adapters/slack/branch-manager.js.map +1 -0
  32. package/dist/adapters/slack/context.d.ts +12 -0
  33. package/dist/adapters/slack/context.d.ts.map +1 -0
  34. package/dist/adapters/slack/context.js +327 -0
  35. package/dist/adapters/slack/context.js.map +1 -0
  36. package/dist/adapters/slack/index.d.ts +3 -0
  37. package/dist/adapters/slack/index.d.ts.map +1 -0
  38. package/dist/adapters/slack/index.js +3 -0
  39. package/dist/adapters/slack/index.js.map +1 -0
  40. package/dist/adapters/slack/session.d.ts +38 -0
  41. package/dist/adapters/slack/session.d.ts.map +1 -0
  42. package/dist/adapters/slack/session.js +66 -0
  43. package/dist/adapters/slack/session.js.map +1 -0
  44. package/dist/adapters/slack/tools/attach.d.ts +12 -0
  45. package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
  46. package/dist/adapters/slack/tools/attach.js +40 -0
  47. package/dist/adapters/slack/tools/attach.js.map +1 -0
  48. package/dist/adapters/telegram/bot.d.ts +51 -0
  49. package/dist/adapters/telegram/bot.d.ts.map +1 -0
  50. package/dist/adapters/telegram/bot.js +430 -0
  51. package/dist/adapters/telegram/bot.js.map +1 -0
  52. package/dist/adapters/telegram/context.d.ts +9 -0
  53. package/dist/adapters/telegram/context.d.ts.map +1 -0
  54. package/dist/adapters/telegram/context.js +190 -0
  55. package/dist/adapters/telegram/context.js.map +1 -0
  56. package/dist/adapters/telegram/html.d.ts +3 -0
  57. package/dist/adapters/telegram/html.d.ts.map +1 -0
  58. package/dist/adapters/telegram/html.js +98 -0
  59. package/dist/adapters/telegram/html.js.map +1 -0
  60. package/dist/adapters/telegram/index.d.ts +3 -0
  61. package/dist/adapters/telegram/index.d.ts.map +1 -0
  62. package/dist/adapters/telegram/index.js +3 -0
  63. package/dist/adapters/telegram/index.js.map +1 -0
  64. package/dist/agent.d.ts +36 -0
  65. package/dist/agent.d.ts.map +1 -0
  66. package/dist/agent.js +1147 -0
  67. package/dist/agent.js.map +1 -0
  68. package/dist/commands/auto-reply.d.ts +5 -0
  69. package/dist/commands/auto-reply.d.ts.map +1 -0
  70. package/dist/commands/auto-reply.js +79 -0
  71. package/dist/commands/auto-reply.js.map +1 -0
  72. package/dist/commands/index.d.ts +5 -0
  73. package/dist/commands/index.d.ts.map +1 -0
  74. package/dist/commands/index.js +18 -0
  75. package/dist/commands/index.js.map +1 -0
  76. package/dist/commands/login.d.ts +5 -0
  77. package/dist/commands/login.d.ts.map +1 -0
  78. package/dist/commands/login.js +91 -0
  79. package/dist/commands/login.js.map +1 -0
  80. package/dist/commands/model.d.ts +14 -0
  81. package/dist/commands/model.d.ts.map +1 -0
  82. package/dist/commands/model.js +110 -0
  83. package/dist/commands/model.js.map +1 -0
  84. package/dist/commands/new.d.ts +5 -0
  85. package/dist/commands/new.d.ts.map +1 -0
  86. package/dist/commands/new.js +24 -0
  87. package/dist/commands/new.js.map +1 -0
  88. package/dist/commands/parse.d.ts +7 -0
  89. package/dist/commands/parse.d.ts.map +1 -0
  90. package/dist/commands/parse.js +17 -0
  91. package/dist/commands/parse.js.map +1 -0
  92. package/dist/commands/registry.d.ts +4 -0
  93. package/dist/commands/registry.d.ts.map +1 -0
  94. package/dist/commands/registry.js +9 -0
  95. package/dist/commands/registry.js.map +1 -0
  96. package/dist/commands/sandbox.d.ts +10 -0
  97. package/dist/commands/sandbox.d.ts.map +1 -0
  98. package/dist/commands/sandbox.js +83 -0
  99. package/dist/commands/sandbox.js.map +1 -0
  100. package/dist/commands/session-view.d.ts +5 -0
  101. package/dist/commands/session-view.d.ts.map +1 -0
  102. package/dist/commands/session-view.js +62 -0
  103. package/dist/commands/session-view.js.map +1 -0
  104. package/dist/commands/types.d.ts +41 -0
  105. package/dist/commands/types.d.ts.map +1 -0
  106. package/dist/commands/types.js +2 -0
  107. package/dist/commands/types.js.map +1 -0
  108. package/dist/commands/utils.d.ts +8 -0
  109. package/dist/commands/utils.d.ts.map +1 -0
  110. package/dist/commands/utils.js +14 -0
  111. package/dist/commands/utils.js.map +1 -0
  112. package/dist/config.d.ts +59 -0
  113. package/dist/config.d.ts.map +1 -0
  114. package/dist/config.js +370 -0
  115. package/dist/config.js.map +1 -0
  116. package/dist/context.d.ts +17 -0
  117. package/dist/context.d.ts.map +1 -0
  118. package/dist/context.js +24 -0
  119. package/dist/context.js.map +1 -0
  120. package/dist/conversation-history.d.ts +16 -0
  121. package/dist/conversation-history.d.ts.map +1 -0
  122. package/dist/conversation-history.js +144 -0
  123. package/dist/conversation-history.js.map +1 -0
  124. package/dist/download.d.ts +2 -0
  125. package/dist/download.d.ts.map +1 -0
  126. package/dist/download.js +89 -0
  127. package/dist/download.js.map +1 -0
  128. package/dist/env.d.ts +3 -0
  129. package/dist/env.d.ts.map +1 -0
  130. package/dist/env.js +12 -0
  131. package/dist/env.js.map +1 -0
  132. package/dist/events.d.ts +85 -0
  133. package/dist/events.d.ts.map +1 -0
  134. package/dist/events.js +483 -0
  135. package/dist/events.js.map +1 -0
  136. package/dist/execution-resolver.d.ts +25 -0
  137. package/dist/execution-resolver.d.ts.map +1 -0
  138. package/dist/execution-resolver.js +167 -0
  139. package/dist/execution-resolver.js.map +1 -0
  140. package/dist/file-guards.d.ts +9 -0
  141. package/dist/file-guards.d.ts.map +1 -0
  142. package/dist/file-guards.js +56 -0
  143. package/dist/file-guards.js.map +1 -0
  144. package/dist/fs-atomic.d.ts +10 -0
  145. package/dist/fs-atomic.d.ts.map +1 -0
  146. package/dist/fs-atomic.js +45 -0
  147. package/dist/fs-atomic.js.map +1 -0
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.d.ts.map +1 -0
  150. package/dist/index.js +7 -0
  151. package/dist/index.js.map +1 -0
  152. package/dist/instrument.d.ts +2 -0
  153. package/dist/instrument.d.ts.map +1 -0
  154. package/dist/instrument.js +10 -0
  155. package/dist/instrument.js.map +1 -0
  156. package/dist/log.d.ts +36 -0
  157. package/dist/log.d.ts.map +1 -0
  158. package/dist/log.js +206 -0
  159. package/dist/log.js.map +1 -0
  160. package/dist/login/index.d.ts +42 -0
  161. package/dist/login/index.d.ts.map +1 -0
  162. package/dist/login/index.js +239 -0
  163. package/dist/login/index.js.map +1 -0
  164. package/dist/login/portal.d.ts +19 -0
  165. package/dist/login/portal.d.ts.map +1 -0
  166. package/dist/login/portal.js +1544 -0
  167. package/dist/login/portal.js.map +1 -0
  168. package/dist/login/session.d.ts +26 -0
  169. package/dist/login/session.d.ts.map +1 -0
  170. package/dist/login/session.js +56 -0
  171. package/dist/login/session.js.map +1 -0
  172. package/dist/main.d.ts +3 -0
  173. package/dist/main.d.ts.map +1 -0
  174. package/dist/main.js +366 -0
  175. package/dist/main.js.map +1 -0
  176. package/dist/provisioner.d.ts +83 -0
  177. package/dist/provisioner.d.ts.map +1 -0
  178. package/dist/provisioner.js +500 -0
  179. package/dist/provisioner.js.map +1 -0
  180. package/dist/runtime/conversation-orchestrator.d.ts +40 -0
  181. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  182. package/dist/runtime/conversation-orchestrator.js +183 -0
  183. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  184. package/dist/runtime/index.d.ts +2 -0
  185. package/dist/runtime/index.d.ts.map +1 -0
  186. package/dist/runtime/index.js +2 -0
  187. package/dist/runtime/index.js.map +1 -0
  188. package/dist/runtime/session-runtime.d.ts +26 -0
  189. package/dist/runtime/session-runtime.d.ts.map +1 -0
  190. package/dist/runtime/session-runtime.js +221 -0
  191. package/dist/runtime/session-runtime.js.map +1 -0
  192. package/dist/sandbox/cloudflare.d.ts +15 -0
  193. package/dist/sandbox/cloudflare.d.ts.map +1 -0
  194. package/dist/sandbox/cloudflare.js +138 -0
  195. package/dist/sandbox/cloudflare.js.map +1 -0
  196. package/dist/sandbox/container.d.ts +16 -0
  197. package/dist/sandbox/container.d.ts.map +1 -0
  198. package/dist/sandbox/container.js +138 -0
  199. package/dist/sandbox/container.js.map +1 -0
  200. package/dist/sandbox/errors.d.ts +6 -0
  201. package/dist/sandbox/errors.d.ts.map +1 -0
  202. package/dist/sandbox/errors.js +11 -0
  203. package/dist/sandbox/errors.js.map +1 -0
  204. package/dist/sandbox/firecracker.d.ts +17 -0
  205. package/dist/sandbox/firecracker.d.ts.map +1 -0
  206. package/dist/sandbox/firecracker.js +212 -0
  207. package/dist/sandbox/firecracker.js.map +1 -0
  208. package/dist/sandbox/host.d.ts +11 -0
  209. package/dist/sandbox/host.d.ts.map +1 -0
  210. package/dist/sandbox/host.js +89 -0
  211. package/dist/sandbox/host.js.map +1 -0
  212. package/dist/sandbox/image.d.ts +5 -0
  213. package/dist/sandbox/image.d.ts.map +1 -0
  214. package/dist/sandbox/image.js +30 -0
  215. package/dist/sandbox/image.js.map +1 -0
  216. package/dist/sandbox/index.d.ts +22 -0
  217. package/dist/sandbox/index.d.ts.map +1 -0
  218. package/dist/sandbox/index.js +54 -0
  219. package/dist/sandbox/index.js.map +1 -0
  220. package/dist/sandbox/path-context.d.ts +4 -0
  221. package/dist/sandbox/path-context.d.ts.map +1 -0
  222. package/dist/sandbox/path-context.js +20 -0
  223. package/dist/sandbox/path-context.js.map +1 -0
  224. package/dist/sandbox/types.d.ts +67 -0
  225. package/dist/sandbox/types.d.ts.map +1 -0
  226. package/dist/sandbox/types.js +2 -0
  227. package/dist/sandbox/types.js.map +1 -0
  228. package/dist/sandbox/utils.d.ts +4 -0
  229. package/dist/sandbox/utils.d.ts.map +1 -0
  230. package/dist/sandbox/utils.js +51 -0
  231. package/dist/sandbox/utils.js.map +1 -0
  232. package/dist/sentry.d.ts +50 -0
  233. package/dist/sentry.d.ts.map +1 -0
  234. package/dist/sentry.js +257 -0
  235. package/dist/sentry.js.map +1 -0
  236. package/dist/session-view/command.d.ts +5 -0
  237. package/dist/session-view/command.d.ts.map +1 -0
  238. package/dist/session-view/command.js +7 -0
  239. package/dist/session-view/command.js.map +1 -0
  240. package/dist/session-view/portal.d.ts +16 -0
  241. package/dist/session-view/portal.d.ts.map +1 -0
  242. package/dist/session-view/portal.js +1822 -0
  243. package/dist/session-view/portal.js.map +1 -0
  244. package/dist/session-view/service.d.ts +34 -0
  245. package/dist/session-view/service.d.ts.map +1 -0
  246. package/dist/session-view/service.js +434 -0
  247. package/dist/session-view/service.js.map +1 -0
  248. package/dist/session-view/store.d.ts +18 -0
  249. package/dist/session-view/store.d.ts.map +1 -0
  250. package/dist/session-view/store.js +36 -0
  251. package/dist/session-view/store.js.map +1 -0
  252. package/dist/sessions/metadata.d.ts +15 -0
  253. package/dist/sessions/metadata.d.ts.map +1 -0
  254. package/dist/sessions/metadata.js +11 -0
  255. package/dist/sessions/metadata.js.map +1 -0
  256. package/dist/sessions/policy.d.ts +13 -0
  257. package/dist/sessions/policy.d.ts.map +1 -0
  258. package/dist/sessions/policy.js +23 -0
  259. package/dist/sessions/policy.js.map +1 -0
  260. package/dist/sessions/store.d.ts +103 -0
  261. package/dist/sessions/store.d.ts.map +1 -0
  262. package/dist/sessions/store.js +349 -0
  263. package/dist/sessions/store.js.map +1 -0
  264. package/dist/store.d.ts +58 -0
  265. package/dist/store.d.ts.map +1 -0
  266. package/dist/store.js +152 -0
  267. package/dist/store.js.map +1 -0
  268. package/dist/tool-diagnostics.d.ts +2 -0
  269. package/dist/tool-diagnostics.d.ts.map +1 -0
  270. package/dist/tool-diagnostics.js +7 -0
  271. package/dist/tool-diagnostics.js.map +1 -0
  272. package/dist/tools/bash.d.ts +10 -0
  273. package/dist/tools/bash.d.ts.map +1 -0
  274. package/dist/tools/bash.js +80 -0
  275. package/dist/tools/bash.js.map +1 -0
  276. package/dist/tools/edit.d.ts +11 -0
  277. package/dist/tools/edit.d.ts.map +1 -0
  278. package/dist/tools/edit.js +133 -0
  279. package/dist/tools/edit.js.map +1 -0
  280. package/dist/tools/event.d.ts +62 -0
  281. package/dist/tools/event.d.ts.map +1 -0
  282. package/dist/tools/event.js +138 -0
  283. package/dist/tools/event.js.map +1 -0
  284. package/dist/tools/index.d.ts +14 -0
  285. package/dist/tools/index.d.ts.map +1 -0
  286. package/dist/tools/index.js +23 -0
  287. package/dist/tools/index.js.map +1 -0
  288. package/dist/tools/read.d.ts +11 -0
  289. package/dist/tools/read.d.ts.map +1 -0
  290. package/dist/tools/read.js +136 -0
  291. package/dist/tools/read.js.map +1 -0
  292. package/dist/tools/truncate.d.ts +57 -0
  293. package/dist/tools/truncate.d.ts.map +1 -0
  294. package/dist/tools/truncate.js +184 -0
  295. package/dist/tools/truncate.js.map +1 -0
  296. package/dist/tools/write.d.ts +10 -0
  297. package/dist/tools/write.d.ts.map +1 -0
  298. package/dist/tools/write.js +33 -0
  299. package/dist/tools/write.js.map +1 -0
  300. package/dist/trigger.d.ts +31 -0
  301. package/dist/trigger.d.ts.map +1 -0
  302. package/dist/trigger.js +98 -0
  303. package/dist/trigger.js.map +1 -0
  304. package/dist/ui-copy.d.ts +12 -0
  305. package/dist/ui-copy.d.ts.map +1 -0
  306. package/dist/ui-copy.js +36 -0
  307. package/dist/ui-copy.js.map +1 -0
  308. package/dist/vault-routing.d.ts +4 -0
  309. package/dist/vault-routing.d.ts.map +1 -0
  310. package/dist/vault-routing.js +16 -0
  311. package/dist/vault-routing.js.map +1 -0
  312. package/dist/vault.d.ts +72 -0
  313. package/dist/vault.d.ts.map +1 -0
  314. package/dist/vault.js +281 -0
  315. package/dist/vault.js.map +1 -0
  316. package/package.json +83 -0
@@ -0,0 +1,1272 @@
1
+ import { SocketModeClient } from "@slack/socket-mode";
2
+ import { WebClient } from "@slack/web-api";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { readFile } from "fs/promises";
5
+ import { basename, join } from "path";
6
+ import * as log from "../../log.js";
7
+ import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
8
+ import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
9
+ import { evaluateAutoReplyPolicy } from "../../trigger.js";
10
+ import { createSlackAdapters } from "./context.js";
11
+ import { hasMaterializedSlackBranchSession, registerSlackForkSession } from "./branch-manager.js";
12
+ import { isSlackForkSessionKey, planSlackAdapterSession, planSlackEventForkRun, resolveSlackSessionKey, } from "./session.js";
13
+ import { reportUserFacingError } from "../../sentry.js";
14
+ const SLACK_EVENT_ANCHOR_TEXT = "Working on it...";
15
+ // Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
16
+ // the legacy `data.error === "rate_limited"` / 429 status shape.
17
+ function slackIsRateLimited(err) {
18
+ if (err.code === "rate_limited")
19
+ return true;
20
+ const data = err.data;
21
+ return data?.error === "rate_limited" || data?.response?.status === 429;
22
+ }
23
+ const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
24
+ function collectSlackText(value, parts) {
25
+ if (value === null || value === undefined)
26
+ return;
27
+ if (typeof value === "string") {
28
+ const trimmed = value.trim();
29
+ if (trimmed)
30
+ parts.push(trimmed);
31
+ return;
32
+ }
33
+ if (Array.isArray(value)) {
34
+ for (const item of value)
35
+ collectSlackText(item, parts);
36
+ return;
37
+ }
38
+ if (typeof value !== "object")
39
+ return;
40
+ const obj = value;
41
+ for (const key of ["text", "fallback", "title", "value"]) {
42
+ collectSlackText(obj[key], parts);
43
+ }
44
+ collectSlackText(obj.fields, parts);
45
+ collectSlackText(obj.elements, parts);
46
+ collectSlackText(obj.blocks, parts);
47
+ }
48
+ function buildSlackAppMessageText(event) {
49
+ const parts = [];
50
+ collectSlackText(event.text, parts);
51
+ collectSlackText(event.blocks, parts);
52
+ collectSlackText(event.attachments, parts);
53
+ const deduped = parts.filter((part, index) => parts.indexOf(part) === index);
54
+ return deduped.join("\n");
55
+ }
56
+ // ============================================================================
57
+ // SlackBot
58
+ // ============================================================================
59
+ export class SlackBot {
60
+ constructor(handler, config) {
61
+ this.botUserId = null;
62
+ this.botId = null;
63
+ this.ownMentionRegex = null;
64
+ this.startupTs = null; // Messages older than this are just logged, not processed
65
+ this.users = new Map();
66
+ this.channels = new Map();
67
+ this.queues = new Map();
68
+ this.eventsWatcher = null;
69
+ this.handler = handler;
70
+ this.workingDir = config.workingDir;
71
+ this.store = config.store;
72
+ this.socketClient = new SocketModeClient({
73
+ appToken: config.appToken,
74
+ // Default 5s is too tight: brief event-loop stalls (e.g. backfill, sync fs)
75
+ // cause false pong timeouts; 4 in a row makes Slack drop the socket.
76
+ clientPingTimeout: 12_000,
77
+ });
78
+ this.webClient = new WebClient(config.botToken);
79
+ }
80
+ setEventsWatcher(watcher) {
81
+ this.eventsWatcher = watcher;
82
+ }
83
+ // ==========================================================================
84
+ // Public API
85
+ // ==========================================================================
86
+ async start() {
87
+ const auth = await this.webClient.auth.test();
88
+ this.botUserId = auth.user_id;
89
+ this.botId = typeof auth.bot_id === "string" ? auth.bot_id : null;
90
+ await Promise.all([this.fetchUsers(), this.fetchChannels()]);
91
+ log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
92
+ // Record startup time before opening the socket. Slack may replay older events;
93
+ // those should be logged but not processed. Backfill runs in the background up
94
+ // to this timestamp so startup is not blocked by one history call per channel.
95
+ this.startupTs = (Date.now() / 1000).toFixed(6);
96
+ this.setupEventHandlers();
97
+ await this.socketClient.start();
98
+ log.logConnected("Slack");
99
+ void this.backfillAllChannels(this.startupTs).catch((error) => {
100
+ log.logWarning("Slack backfill failed", String(error));
101
+ });
102
+ }
103
+ getUser(userId) {
104
+ return this.users.get(userId);
105
+ }
106
+ getChannel(channelId) {
107
+ return this.channels.get(channelId);
108
+ }
109
+ getAllUsers() {
110
+ return Array.from(this.users.values());
111
+ }
112
+ getAllChannels() {
113
+ return Array.from(this.channels.values());
114
+ }
115
+ stripOwnMention(text) {
116
+ const source = text ?? "";
117
+ if (!this.botUserId)
118
+ return source.trim();
119
+ if (!this.ownMentionRegex || !this.ownMentionRegex.source.includes(this.botUserId)) {
120
+ this.ownMentionRegex = new RegExp(`<@${this.botUserId}>`, "gi");
121
+ }
122
+ return source.replace(this.ownMentionRegex, "").trim();
123
+ }
124
+ async postMessage(channel, text) {
125
+ return slackRetry(async () => {
126
+ const result = await this.webClient.chat.postMessage({ channel, text });
127
+ return result.ts;
128
+ });
129
+ }
130
+ async postEphemeral(channel, user, text, threadTs) {
131
+ return slackRetry(async () => {
132
+ await this.webClient.chat.postEphemeral({
133
+ channel,
134
+ user,
135
+ text,
136
+ ...(threadTs ? { thread_ts: threadTs } : {}),
137
+ });
138
+ });
139
+ }
140
+ async postEphemeralBlocks(channel, user, text, blocks, threadTs) {
141
+ return slackRetry(async () => {
142
+ await this.webClient.chat.postEphemeral({
143
+ channel,
144
+ user,
145
+ text,
146
+ blocks: blocks,
147
+ ...(threadTs ? { thread_ts: threadTs } : {}),
148
+ });
149
+ });
150
+ }
151
+ async postMessageBlocks(channel, text, blocks) {
152
+ return slackRetry(async () => {
153
+ const result = await this.webClient.chat.postMessage({
154
+ channel,
155
+ text,
156
+ blocks: blocks,
157
+ });
158
+ return result.ts;
159
+ });
160
+ }
161
+ async postPrivate(conversationId, userId, text) {
162
+ await this.postEphemeral(conversationId, userId, text);
163
+ }
164
+ async postPrivateDiagnostic(conversationId, userId, text, options) {
165
+ if (options?.style !== "muted") {
166
+ await this.postPrivate(conversationId, userId, options?.style === "error" ? `_${text}_` : text);
167
+ return;
168
+ }
169
+ const CONTEXT_TEXT_LIMIT = 3000;
170
+ const blockText = text.length > CONTEXT_TEXT_LIMIT
171
+ ? text.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
172
+ : text;
173
+ await this.postEphemeralBlocks(conversationId, userId, text, [
174
+ { type: "context", elements: [{ type: "mrkdwn", text: blockText }] },
175
+ ]);
176
+ }
177
+ async openDirectMessage(userId) {
178
+ return slackRetry(async () => {
179
+ const result = await this.webClient.conversations.open({ users: userId });
180
+ const channelId = result.channel?.id;
181
+ if (!channelId) {
182
+ throw new Error(`Failed to open DM for user ${userId}`);
183
+ }
184
+ return channelId;
185
+ });
186
+ }
187
+ async updateMessage(channel, ts, text) {
188
+ return slackRetry(async () => {
189
+ await this.webClient.chat.update({ channel, ts, text });
190
+ });
191
+ }
192
+ async deleteMessage(channel, ts) {
193
+ return slackRetry(async () => {
194
+ await this.webClient.chat.delete({ channel, ts });
195
+ });
196
+ }
197
+ // ==========================================================================
198
+ // Slack Assistant API (AI assistant experience)
199
+ // ==========================================================================
200
+ /** Set the status for an assistant thread (shows "thinking" state) */
201
+ async setAssistantStatus(channel, threadTs, status) {
202
+ return slackRetry(async () => {
203
+ await this.webClient.assistant.threads.setStatus({
204
+ channel_id: channel,
205
+ thread_ts: threadTs,
206
+ status,
207
+ });
208
+ });
209
+ }
210
+ async postInThread(channel, threadTs, text) {
211
+ return slackRetry(async () => {
212
+ // Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
213
+ const SECTION_TEXT_LIMIT = 3000;
214
+ if (text.length > 500) {
215
+ const blockText = text.length > SECTION_TEXT_LIMIT
216
+ ? text.substring(0, SECTION_TEXT_LIMIT - 20) + "\n_(truncated)_"
217
+ : text;
218
+ const result = await this.webClient.chat.postMessage({
219
+ channel,
220
+ thread_ts: threadTs,
221
+ text, // full text as notification fallback
222
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: blockText } }],
223
+ });
224
+ return result.ts;
225
+ }
226
+ const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
227
+ return result.ts;
228
+ });
229
+ }
230
+ async postInThreadBlocks(channel, threadTs, text, blocks) {
231
+ return slackRetry(async () => {
232
+ const result = await this.webClient.chat.postMessage({
233
+ channel,
234
+ thread_ts: threadTs,
235
+ text, // fallback for notifications
236
+ blocks: blocks,
237
+ });
238
+ return result.ts;
239
+ });
240
+ }
241
+ async uploadFile(channel, filePath, title, threadTs) {
242
+ return slackRetry(async () => {
243
+ const fileName = title || basename(filePath);
244
+ const fileContent = readFileSync(filePath);
245
+ await this.webClient.files.uploadV2({
246
+ channel_id: channel,
247
+ file: fileContent,
248
+ filename: fileName,
249
+ title: fileName,
250
+ ...(threadTs ? { thread_ts: threadTs } : {}),
251
+ });
252
+ });
253
+ }
254
+ logToFile(channel, entry) {
255
+ appendChannelLog(this.workingDir, channel, entry);
256
+ }
257
+ logBotResponse(channel, text, ts, threadTs) {
258
+ appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
259
+ }
260
+ getPlatformInfo() {
261
+ return {
262
+ name: "slack",
263
+ formattingGuide: "## Slack Formatting (mrkdwn, NOT Markdown)\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\nDo NOT use **double asterisks** or [markdown](links).",
264
+ channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),
265
+ users: this.getAllUsers().map((u) => ({
266
+ id: u.id,
267
+ userName: u.userName,
268
+ displayName: u.displayName,
269
+ })),
270
+ diagnostics: {
271
+ showUsageSummary: true,
272
+ },
273
+ };
274
+ }
275
+ // ==========================================================================
276
+ // Events Integration
277
+ // ==========================================================================
278
+ /**
279
+ * Enqueue an event for processing. Always queues (no "already working" rejection).
280
+ * Returns true if enqueued, false if queue is full (max 5).
281
+ */
282
+ enqueueEvent(event) {
283
+ const conversationId = event.conversationId;
284
+ const queue = this.getQueue(conversationId);
285
+ if (queue.size() >= 5) {
286
+ log.logWarning(`Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`);
287
+ return false;
288
+ }
289
+ log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);
290
+ queue.enqueue(async () => {
291
+ let anchorTs;
292
+ if (!event.thread_ts) {
293
+ try {
294
+ anchorTs = await this.postMessage(conversationId, SLACK_EVENT_ANCHOR_TEXT);
295
+ }
296
+ catch (err) {
297
+ log.logWarning(`Failed to post Slack event anchor for ${conversationId}`, err instanceof Error ? err.message : String(err));
298
+ reportUserFacingError(err, {
299
+ domain: "events",
300
+ surface: "event_delivery",
301
+ operation: "slack_anchor_post",
302
+ severity: "error",
303
+ platform: "slack",
304
+ context: {
305
+ conversationId,
306
+ conversationKind: event.conversationKind,
307
+ eventTs: event.ts,
308
+ textLength: event.text.length,
309
+ },
310
+ });
311
+ throw err;
312
+ }
313
+ }
314
+ const eventPlan = planSlackEventForkRun(event, anchorTs);
315
+ const eventForRun = eventPlan.event;
316
+ if (eventPlan.initialMessageTs && eventForRun.sessionKey) {
317
+ registerSlackForkSession({
318
+ conversationDir: join(this.workingDir, conversationId),
319
+ sessionKey: eventForRun.sessionKey,
320
+ });
321
+ }
322
+ const runQueueKey = planSlackAdapterSession(eventForRun, {
323
+ initialMessageTs: eventPlan.initialMessageTs,
324
+ }).sessionKey;
325
+ this.getQueue(runQueueKey).enqueue(async () => {
326
+ const slackEvent = {
327
+ type: eventForRun.type,
328
+ conversationId,
329
+ conversationKind: eventForRun.conversationKind,
330
+ channel: conversationId,
331
+ ts: eventForRun.ts,
332
+ thread_ts: eventForRun.thread_ts,
333
+ user: eventForRun.user,
334
+ text: eventForRun.text,
335
+ attachments: eventForRun.attachments?.map((attachment) => ({
336
+ original: attachment.name,
337
+ localPath: attachment.localPath,
338
+ })),
339
+ sessionKey: eventForRun.sessionKey,
340
+ };
341
+ const adapters = createSlackAdapters(slackEvent, this, {
342
+ initialMessageTs: eventPlan.initialMessageTs,
343
+ });
344
+ return this.handler.handleEvent(eventForRun, this, adapters);
345
+ });
346
+ });
347
+ return true;
348
+ }
349
+ // ==========================================================================
350
+ // Private - Event Handlers
351
+ // ==========================================================================
352
+ getQueue(channelId) {
353
+ let queue = this.queues.get(channelId);
354
+ if (!queue) {
355
+ queue = new ChannelQueue("Slack");
356
+ this.queues.set(channelId, queue);
357
+ }
358
+ return queue;
359
+ }
360
+ resolveQueueKey(conversationId, sessionKey) {
361
+ if (!isSlackForkSessionKey(sessionKey))
362
+ return sessionKey;
363
+ if (this.handler.isRunning(sessionKey))
364
+ return sessionKey;
365
+ return this.hasKnownForkSession(conversationId, sessionKey) ? sessionKey : conversationId;
366
+ }
367
+ hasKnownForkSession(conversationId, sessionKey) {
368
+ return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey);
369
+ }
370
+ shouldTriggerSharedThreadReply(channelId, threadTs) {
371
+ if (!threadTs)
372
+ return false;
373
+ const sessionKey = resolveSlackSessionKey(channelId, threadTs);
374
+ if (this.handler.isRunning(sessionKey))
375
+ return true;
376
+ return this.hasKnownForkSession(channelId, sessionKey);
377
+ }
378
+ buildHomeView() {
379
+ const blocks = [
380
+ {
381
+ type: "section",
382
+ text: {
383
+ type: "mrkdwn",
384
+ text: `*${PRODUCT_NAME}*\nStart a new task or check on running work.`,
385
+ },
386
+ accessory: {
387
+ type: "image",
388
+ image_url: "https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif",
389
+ alt_text: PRODUCT_NAME,
390
+ },
391
+ },
392
+ ];
393
+ // --- Running tasks ---
394
+ const runningSessions = this.handler.getRunningSessions();
395
+ blocks.push({ type: "divider" }, {
396
+ type: "header",
397
+ text: {
398
+ type: "plain_text",
399
+ text: `Running Tasks (${runningSessions.length})`,
400
+ emoji: true,
401
+ },
402
+ });
403
+ if (runningSessions.length === 0) {
404
+ blocks.push({
405
+ type: "context",
406
+ elements: [{ type: "mrkdwn", text: "_No tasks running right now._" }],
407
+ });
408
+ }
409
+ else {
410
+ // Threshold for "stuck" detection (10 minutes)
411
+ const STUCK_THRESHOLD_MS = 10 * 60 * 1000;
412
+ for (const session of runningSessions) {
413
+ const channelId = session.sessionKey.split(":")[0];
414
+ const channel = this.channels.get(channelId);
415
+ const channelName = channel ? `#${channel.name}` : channelId;
416
+ const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);
417
+ const elapsedStr = elapsed < 1 ? "<1 min" : `${elapsed} min`;
418
+ // Check if task might be stuck
419
+ const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;
420
+ const isStuck = lastActivity > STUCK_THRESHOLD_MS;
421
+ const statusText = isStuck ? "_stuck_" : "_running_";
422
+ // Build status line: channel · status · time · step
423
+ let statusLine = `${statusText} · ${elapsedStr}`;
424
+ if (session.currentTool) {
425
+ statusLine += ` · ${session.currentTool}`;
426
+ }
427
+ if (isStuck && lastActivity > 0) {
428
+ const inactiveMin = Math.floor(lastActivity / 60000);
429
+ statusLine += ` · idle ${inactiveMin}m`;
430
+ }
431
+ // Use context block for gray small text (like "No scheduled jobs.")
432
+ blocks.push({
433
+ type: "context",
434
+ elements: [
435
+ {
436
+ type: "mrkdwn",
437
+ text: `*${channelName}* · ${statusLine}`,
438
+ },
439
+ ],
440
+ });
441
+ // Add Force Stop button as separate element if stuck
442
+ if (isStuck) {
443
+ blocks.push({
444
+ type: "context",
445
+ elements: [
446
+ {
447
+ type: "mrkdwn",
448
+ text: " ",
449
+ },
450
+ {
451
+ type: "button",
452
+ text: { type: "plain_text", text: "Force Stop", emoji: true },
453
+ action_id: `force_stop_${session.sessionKey.replace(/:/g, "_")}`,
454
+ style: "danger",
455
+ },
456
+ ],
457
+ });
458
+ }
459
+ }
460
+ }
461
+ // --- Cron jobs ---
462
+ const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];
463
+ blocks.push({ type: "divider" }, {
464
+ type: "header",
465
+ text: {
466
+ type: "plain_text",
467
+ text: `Scheduled Jobs (${periodicEvents.length})`,
468
+ emoji: true,
469
+ },
470
+ });
471
+ if (periodicEvents.length === 0) {
472
+ blocks.push({
473
+ type: "context",
474
+ elements: [{ type: "mrkdwn", text: "_No scheduled jobs._" }],
475
+ });
476
+ }
477
+ else {
478
+ for (const ev of periodicEvents) {
479
+ const channelLabel = ev.platform === "slack"
480
+ ? (() => {
481
+ const channel = this.channels.get(ev.conversationId);
482
+ const channelName = channel ? `#${channel.name}` : ev.conversationId;
483
+ return `${ev.platform}:${channelName}`;
484
+ })()
485
+ : `${ev.platform}:${ev.conversationId}`;
486
+ const nextStr = ev.nextRun
487
+ ? new Date(ev.nextRun).toLocaleString("en-US", {
488
+ month: "short",
489
+ day: "numeric",
490
+ hour: "2-digit",
491
+ minute: "2-digit",
492
+ })
493
+ : "—";
494
+ blocks.push({
495
+ type: "section",
496
+ text: {
497
+ type: "mrkdwn",
498
+ text: `*${ev.text}*\n└ \`${ev.schedule}\` · ${channelLabel} · Next: ${nextStr}`,
499
+ },
500
+ });
501
+ }
502
+ }
503
+ // --- Footer ---
504
+ blocks.push({ type: "divider" }, {
505
+ type: "context",
506
+ elements: [
507
+ { type: "mrkdwn", text: "💡 @mention in a channel or send a DM to start a new task" },
508
+ ],
509
+ });
510
+ return { type: "home", blocks: blocks };
511
+ }
512
+ resolveStopTarget(channelId, threadTs) {
513
+ const directTarget = resolveStopTarget({
514
+ handler: this.handler,
515
+ conversationId: channelId,
516
+ sessionKey: resolveSlackSessionKey(channelId, threadTs),
517
+ });
518
+ if (directTarget)
519
+ return directTarget;
520
+ if (threadTs)
521
+ return null;
522
+ return resolveOnlyScopedStopTarget(this.handler, channelId);
523
+ }
524
+ isStopText(text) {
525
+ const normalized = text.trim().toLowerCase();
526
+ return normalized === "stop" || normalized === "/stop";
527
+ }
528
+ createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
529
+ const message = {
530
+ id: ts,
531
+ sessionKey: conversationId,
532
+ conversationKind: options.ephemeralChannelId ? "shared" : "direct",
533
+ userId,
534
+ userName,
535
+ text,
536
+ attachments: [],
537
+ };
538
+ const respond = async (responseText) => {
539
+ if (options.ephemeralChannelId) {
540
+ await this.postEphemeral(options.ephemeralChannelId, userId, responseText, options.threadTs);
541
+ return;
542
+ }
543
+ const messageTs = await this.postMessage(conversationId, responseText);
544
+ this.logBotResponse(conversationId, responseText, messageTs);
545
+ };
546
+ const respondMuted = async (responseText) => {
547
+ const CONTEXT_TEXT_LIMIT = 3000;
548
+ const blockText = responseText.length > CONTEXT_TEXT_LIMIT
549
+ ? responseText.substring(0, CONTEXT_TEXT_LIMIT - 20) + "\n_(truncated)_"
550
+ : responseText;
551
+ const blocks = [{ type: "context", elements: [{ type: "mrkdwn", text: blockText }] }];
552
+ if (options.ephemeralChannelId) {
553
+ await this.postEphemeralBlocks(options.ephemeralChannelId, userId, responseText, blocks, options.threadTs);
554
+ return;
555
+ }
556
+ const messageTs = await this.postMessageBlocks(conversationId, responseText, blocks);
557
+ this.logBotResponse(conversationId, responseText, messageTs);
558
+ };
559
+ const responseCtx = {
560
+ respond,
561
+ replaceResponse: respond,
562
+ respondDiagnostic: async (responseText, responseOptions) => {
563
+ if (responseOptions?.style === "muted") {
564
+ await respondMuted(responseText);
565
+ return;
566
+ }
567
+ await respond(responseOptions?.style === "error" ? `_${responseText}_` : responseText);
568
+ },
569
+ respondToolResult: async (result) => {
570
+ const duration = (result.durationMs / 1000).toFixed(1);
571
+ await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
572
+ },
573
+ setTyping: async () => { },
574
+ setWorking: async () => { },
575
+ uploadFile: async (filePath, title) => {
576
+ await this.uploadFile(conversationId, filePath, title);
577
+ },
578
+ deleteResponse: async () => { },
579
+ };
580
+ return {
581
+ message,
582
+ responseCtx,
583
+ platform: this.getPlatformInfo(),
584
+ };
585
+ }
586
+ createSlashCommandBot(conversationId, threadTs) {
587
+ return {
588
+ start: async () => { },
589
+ postMessage: async (_channel, text) => {
590
+ if (threadTs) {
591
+ return this.postInThread(conversationId, threadTs, text);
592
+ }
593
+ return this.postMessage(conversationId, text);
594
+ },
595
+ updateMessage: async (channel, ts, text) => {
596
+ await this.updateMessage(channel, ts, text);
597
+ },
598
+ enqueueEvent: (event) => this.enqueueEvent(event),
599
+ getPlatformInfo: () => this.getPlatformInfo(),
600
+ };
601
+ }
602
+ async routeSlashLoginCommand(payload) {
603
+ const commandSuffix = payload.text?.trim();
604
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
605
+ const createdAt = new Date();
606
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
607
+ const sourceChannelId = payload.channel_id;
608
+ const isDirectMessage = sourceChannelId.startsWith("D");
609
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
610
+ this.logToFile(sourceChannelId, {
611
+ date: createdAt.toISOString(),
612
+ ts: eventTs,
613
+ user: payload.user_id,
614
+ userName,
615
+ text: commandText,
616
+ attachments: [],
617
+ isBot: false,
618
+ });
619
+ const event = {
620
+ type: isDirectMessage ? "dm" : "private_command",
621
+ conversationId: sourceChannelId,
622
+ conversationKind: isDirectMessage ? "direct" : "shared",
623
+ ts: eventTs,
624
+ user: payload.user_id,
625
+ text: commandText,
626
+ attachments: [],
627
+ sessionKey: sourceChannelId,
628
+ };
629
+ const adapters = this.createCommandAdapters(sourceChannelId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: sourceChannelId });
630
+ await this.handler.handleEvent(event, this, adapters);
631
+ }
632
+ async routeSlashNewCommand(payload) {
633
+ const conversationId = payload.channel_id;
634
+ if (!conversationId.startsWith("D")) {
635
+ await this.postEphemeral(conversationId, payload.user_id, `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`);
636
+ return;
637
+ }
638
+ const createdAt = new Date();
639
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
640
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
641
+ this.logToFile(conversationId, {
642
+ date: createdAt.toISOString(),
643
+ ts: eventTs,
644
+ user: payload.user_id,
645
+ userName,
646
+ text: payload.command,
647
+ attachments: [],
648
+ isBot: false,
649
+ });
650
+ const commandBot = this.createSlashCommandBot(conversationId);
651
+ await this.handler.handleNewCommand(conversationId, conversationId, commandBot);
652
+ }
653
+ async routeSlashModelCommand(payload) {
654
+ const conversationId = payload.channel_id;
655
+ const isDirectMessage = conversationId.startsWith("D");
656
+ const createdAt = new Date();
657
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
658
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
659
+ const commandSuffix = payload.text?.trim();
660
+ const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;
661
+ this.logToFile(conversationId, {
662
+ date: createdAt.toISOString(),
663
+ ts: eventTs,
664
+ user: payload.user_id,
665
+ userName,
666
+ text: commandText,
667
+ attachments: [],
668
+ isBot: false,
669
+ });
670
+ const sessionKey = conversationId;
671
+ const event = {
672
+ type: isDirectMessage ? "dm" : "mention",
673
+ conversationId,
674
+ conversationKind: isDirectMessage ? "direct" : "shared",
675
+ ts: eventTs,
676
+ user: payload.user_id,
677
+ text: commandText,
678
+ attachments: [],
679
+ sessionKey,
680
+ };
681
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
682
+ await this.handler.handleEvent(event, this, adapters);
683
+ }
684
+ async routeSlashSandboxCommand(payload) {
685
+ await this.routeSlashModelCommand(payload);
686
+ }
687
+ async routeSlashAutoReplyCommand(payload) {
688
+ await this.routeSlashModelCommand(payload);
689
+ }
690
+ async routeSlashSessionCommand(payload) {
691
+ const conversationId = payload.channel_id;
692
+ const isDirectMessage = conversationId.startsWith("D");
693
+ const createdAt = new Date();
694
+ const eventTs = (createdAt.getTime() / 1000).toFixed(6);
695
+ const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;
696
+ const commandText = payload.command;
697
+ this.logToFile(conversationId, {
698
+ date: createdAt.toISOString(),
699
+ ts: eventTs,
700
+ user: payload.user_id,
701
+ userName,
702
+ text: commandText,
703
+ attachments: [],
704
+ isBot: false,
705
+ threadTs: payload.thread_ts,
706
+ });
707
+ const sessionKey = resolveSlackSessionKey(conversationId, payload.thread_ts);
708
+ const event = {
709
+ type: isDirectMessage ? "dm" : "mention",
710
+ conversationId,
711
+ conversationKind: isDirectMessage ? "direct" : "shared",
712
+ ts: eventTs,
713
+ user: payload.user_id,
714
+ text: commandText,
715
+ attachments: [],
716
+ thread_ts: payload.thread_ts,
717
+ sessionKey,
718
+ };
719
+ const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage
720
+ ? { threadTs: payload.thread_ts }
721
+ : { ephemeralChannelId: conversationId, threadTs: payload.thread_ts });
722
+ await this.handler.handleEvent(event, this, adapters);
723
+ }
724
+ setupEventHandlers() {
725
+ this.socketClient.on("disconnect", (err) => {
726
+ log.logWarning("Slack socket disconnect", err ? String(err) : "");
727
+ });
728
+ this.socketClient.on("error", (err) => {
729
+ log.logWarning("Slack socket error", err ? String(err) : "");
730
+ });
731
+ this.socketClient.on("unable_to_socket_mode_start", (err) => {
732
+ log.logWarning("Slack socket unable_to_start", err ? String(err) : "");
733
+ });
734
+ // Channel @mentions
735
+ this.socketClient.on("app_mention", ({ event, ack }) => {
736
+ const e = event;
737
+ // Skip DMs (handled by message event)
738
+ if (e.channel.startsWith("D")) {
739
+ ack();
740
+ return;
741
+ }
742
+ // Top-level mentions use a persistent channel session.
743
+ // Thread replies get their own isolated session (channelId:thread_ts).
744
+ const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);
745
+ const slackEvent = {
746
+ type: "mention",
747
+ conversationId: e.channel,
748
+ conversationKind: "shared",
749
+ channel: e.channel,
750
+ ts: e.ts,
751
+ thread_ts: e.thread_ts,
752
+ user: e.user,
753
+ text: this.stripOwnMention(e.text),
754
+ files: e.files,
755
+ sessionKey,
756
+ };
757
+ const attachmentsPromise = this.logUserMessage(slackEvent);
758
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
759
+ if (this.startupTs && e.ts < this.startupTs) {
760
+ log.logInfo(`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`);
761
+ void attachmentsPromise.catch((err) => {
762
+ log.logWarning("Failed to log Slack message", String(err));
763
+ });
764
+ ack();
765
+ return;
766
+ }
767
+ // Check for stop command - execute immediately, don't queue!
768
+ if (this.isStopText(slackEvent.text)) {
769
+ const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
770
+ if (stopTarget) {
771
+ this.handler.handleStop(stopTarget, e.channel, this);
772
+ }
773
+ else {
774
+ this.postMessage(e.channel, formatNothingRunning("slack"));
775
+ }
776
+ void attachmentsPromise.catch((err) => {
777
+ log.logWarning("Failed to log Slack message", String(err));
778
+ });
779
+ ack();
780
+ return;
781
+ }
782
+ this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {
783
+ slackEvent.attachments = await attachmentsPromise;
784
+ const adapters = createSlackAdapters(slackEvent, this);
785
+ return this.handler.handleEvent(slackEvent, this, adapters);
786
+ });
787
+ ack();
788
+ });
789
+ // All messages (for logging) + DMs (for triggering)
790
+ this.socketClient.on("message", ({ event, ack }) => {
791
+ const e = event;
792
+ const hasFiles = !!e.files && e.files.length > 0;
793
+ const hasSlackContent = !!e.text || hasFiles || !!e.blocks?.length || !!e.attachments?.length;
794
+ const isOwnBotMessage = (!!e.user && e.user === this.botUserId) || (!!this.botId && e.bot_id === this.botId);
795
+ if (isOwnBotMessage) {
796
+ ack();
797
+ return;
798
+ }
799
+ const isExternalBotMessage = !!e.bot_id || e.subtype === "bot_message";
800
+ if (isExternalBotMessage) {
801
+ if (e.subtype !== undefined && e.subtype !== "bot_message" && e.subtype !== "file_share") {
802
+ ack();
803
+ return;
804
+ }
805
+ if (!hasSlackContent) {
806
+ ack();
807
+ return;
808
+ }
809
+ void this.logExternalBotMessage(e).catch((err) => {
810
+ log.logWarning("Failed to log Slack bot message", String(err));
811
+ });
812
+ ack();
813
+ return;
814
+ }
815
+ if (!e.user) {
816
+ ack();
817
+ return;
818
+ }
819
+ if (e.subtype !== undefined && e.subtype !== "file_share") {
820
+ ack();
821
+ return;
822
+ }
823
+ if (!hasSlackContent) {
824
+ ack();
825
+ return;
826
+ }
827
+ const isDM = e.channel_type === "im";
828
+ const conversationKind = isDM ? "direct" : "shared";
829
+ const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
830
+ // Skip channel @mentions - already handled by app_mention event
831
+ if (!isDM && isBotMention) {
832
+ ack();
833
+ return;
834
+ }
835
+ const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
836
+ const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
837
+ const slackEvent = {
838
+ type: isDM ? "dm" : "mention",
839
+ conversationId: e.channel,
840
+ conversationKind,
841
+ channel: e.channel,
842
+ ts: e.ts,
843
+ thread_ts: e.thread_ts,
844
+ user: e.user,
845
+ text: this.stripOwnMention(e.text),
846
+ files: e.files,
847
+ sessionKey,
848
+ };
849
+ const attachmentsPromise = this.logUserMessage(slackEvent);
850
+ // Only trigger processing for messages AFTER startup (not replayed old messages)
851
+ if (this.startupTs && e.ts < this.startupTs) {
852
+ log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
853
+ void attachmentsPromise.catch((err) => {
854
+ log.logWarning("Failed to log Slack message", String(err));
855
+ });
856
+ ack();
857
+ return;
858
+ }
859
+ // Stop command for DM or shared-channel thread reply (app_mention handles "@mikan stop").
860
+ if ((isDM || (!isDM && e.thread_ts)) && this.isStopText(slackEvent.text)) {
861
+ const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);
862
+ if (stopTarget) {
863
+ this.handler.handleStop(stopTarget, e.channel, this);
864
+ }
865
+ else {
866
+ this.postMessage(e.channel, formatNothingRunning("slack"));
867
+ }
868
+ void attachmentsPromise.catch((err) => {
869
+ log.logWarning("Failed to log Slack message", String(err));
870
+ });
871
+ ack();
872
+ return;
873
+ }
874
+ const enqueueTriggered = () => {
875
+ const activeSessionKey = slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);
876
+ // Auto-reply top-level channel messages start with no sessionKey because
877
+ // they are only candidates until the policy allows them. Once triggered,
878
+ // persist the resolved key on the event; otherwise the runtime fallback
879
+ // treats the message ts as a branch session (`channel:ts`) instead of the
880
+ // persistent top-level channel session.
881
+ slackEvent.sessionKey = activeSessionKey;
882
+ this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {
883
+ slackEvent.attachments = await attachmentsPromise;
884
+ const adapters = createSlackAdapters(slackEvent, this);
885
+ return this.handler.handleEvent(slackEvent, this, adapters);
886
+ });
887
+ };
888
+ const logOnly = () => {
889
+ void attachmentsPromise.catch((err) => {
890
+ log.logWarning("Failed to log Slack message", String(err));
891
+ });
892
+ };
893
+ if (isDM || isSharedThreadReply) {
894
+ enqueueTriggered();
895
+ ack();
896
+ return;
897
+ }
898
+ // Shared-channel non-mention, non-thread: gate via auto-reply policy.
899
+ // evaluateAutoReplyPolicy never throws — judge errors/timeouts surface as
900
+ // trigger:false with a distinct reason, and the user message has already
901
+ // been queued for logging via logUserMessage above.
902
+ evaluateAutoReplyPolicy({
903
+ event: slackEvent,
904
+ workingDir: this.workingDir,
905
+ }).then((triggerResult) => {
906
+ if (triggerResult.trigger)
907
+ enqueueTriggered();
908
+ else
909
+ logOnly();
910
+ });
911
+ ack();
912
+ });
913
+ this.socketClient.on("slash_commands", async ({ body, ack }) => {
914
+ const payload = body;
915
+ await ack();
916
+ if (!payload.command || !payload.channel_id || !payload.user_id) {
917
+ return;
918
+ }
919
+ const handlerPromise = payload.command === "/pi-login"
920
+ ? this.routeSlashLoginCommand({
921
+ command: payload.command,
922
+ text: payload.text,
923
+ channel_id: payload.channel_id,
924
+ user_id: payload.user_id,
925
+ user_name: payload.user_name,
926
+ })
927
+ : payload.command === "/pi-new"
928
+ ? this.routeSlashNewCommand({
929
+ command: payload.command,
930
+ channel_id: payload.channel_id,
931
+ user_id: payload.user_id,
932
+ user_name: payload.user_name,
933
+ })
934
+ : payload.command === "/pi-session"
935
+ ? this.routeSlashSessionCommand({
936
+ command: payload.command,
937
+ channel_id: payload.channel_id,
938
+ user_id: payload.user_id,
939
+ user_name: payload.user_name,
940
+ thread_ts: payload.thread_ts,
941
+ })
942
+ : payload.command === "/pi-model"
943
+ ? this.routeSlashModelCommand({
944
+ command: payload.command,
945
+ text: payload.text,
946
+ channel_id: payload.channel_id,
947
+ user_id: payload.user_id,
948
+ user_name: payload.user_name,
949
+ })
950
+ : payload.command === "/pi-sandbox"
951
+ ? this.routeSlashSandboxCommand({
952
+ command: payload.command,
953
+ text: payload.text,
954
+ channel_id: payload.channel_id,
955
+ user_id: payload.user_id,
956
+ user_name: payload.user_name,
957
+ })
958
+ : payload.command === "/pi-auto-reply"
959
+ ? this.routeSlashAutoReplyCommand({
960
+ command: payload.command,
961
+ text: payload.text,
962
+ channel_id: payload.channel_id,
963
+ user_id: payload.user_id,
964
+ user_name: payload.user_name,
965
+ })
966
+ : null;
967
+ if (!handlerPromise) {
968
+ return;
969
+ }
970
+ handlerPromise.catch((err) => {
971
+ log.logWarning("Slack slash command error", err instanceof Error ? err.message : String(err));
972
+ });
973
+ });
974
+ // App Home tab
975
+ this.socketClient.on("app_home_opened", ({ event, ack }) => {
976
+ const e = event;
977
+ ack();
978
+ if (e.tab !== "home")
979
+ return;
980
+ this.webClient.views
981
+ .publish({
982
+ user_id: e.user,
983
+ view: this.buildHomeView(),
984
+ })
985
+ .catch((err) => {
986
+ log.logWarning(`Failed to publish App Home view`, String(err));
987
+ });
988
+ });
989
+ // Handle button clicks (Force Stop)
990
+ this.socketClient.on("block_actions", async ({ body, ack }) => {
991
+ const action = body.actions?.[0];
992
+ if (!action || !action.action_id?.startsWith("force_stop_")) {
993
+ ack();
994
+ return;
995
+ }
996
+ ack();
997
+ const sessionKey = action.action_id.replace("force_stop_", "").replace(/_/g, ":");
998
+ const userId = body.user?.id;
999
+ const channelId = body.container?.channel_id || sessionKey.split(":")[0];
1000
+ log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);
1001
+ // Use handler's forceStop method
1002
+ this.handler.forceStop(sessionKey);
1003
+ // Notify in channel
1004
+ await this.postMessage(channelId, formatForceStopped("slack", userId ?? "unknown"));
1005
+ // Refresh home tab
1006
+ if (userId) {
1007
+ this.webClient.views
1008
+ .publish({
1009
+ user_id: userId,
1010
+ view: this.buildHomeView(),
1011
+ })
1012
+ .catch((err) => {
1013
+ log.logWarning(`Failed to refresh App Home view`, String(err));
1014
+ });
1015
+ }
1016
+ });
1017
+ }
1018
+ /**
1019
+ * Log a user message to log.jsonl after attachments are ready.
1020
+ */
1021
+ async logUserMessage(event) {
1022
+ const user = this.users.get(event.user);
1023
+ let attachments = [];
1024
+ let attachmentError;
1025
+ if (event.files) {
1026
+ try {
1027
+ attachments = await this.store.processAttachments(event.channel, event.files, event.ts);
1028
+ }
1029
+ catch (err) {
1030
+ attachmentError = err;
1031
+ }
1032
+ }
1033
+ // Always write the text log, even if attachment processing failed — we want
1034
+ // a record of the user message regardless of file-handling errors.
1035
+ this.logToFile(event.channel, {
1036
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
1037
+ ts: event.ts,
1038
+ threadTs: event.thread_ts,
1039
+ user: event.user,
1040
+ userName: user?.userName,
1041
+ displayName: user?.displayName,
1042
+ text: event.text,
1043
+ attachments,
1044
+ isBot: false,
1045
+ });
1046
+ if (attachmentError)
1047
+ throw attachmentError;
1048
+ return attachments;
1049
+ }
1050
+ async logExternalBotMessage(event) {
1051
+ const attachments = event.files
1052
+ ? await this.store.processAttachments(event.channel, event.files, event.ts)
1053
+ : [];
1054
+ const botName = event.username ?? event.bot_profile?.name ?? event.bot_profile?.real_name ?? event.bot_id;
1055
+ this.logToFile(event.channel, {
1056
+ date: new Date(parseFloat(event.ts) * 1000).toISOString(),
1057
+ ts: event.ts,
1058
+ threadTs: event.thread_ts,
1059
+ user: event.bot_id ? `bot:${event.bot_id}` : "external-bot",
1060
+ userName: botName,
1061
+ displayName: botName,
1062
+ text: buildSlackAppMessageText(event),
1063
+ attachments,
1064
+ isBot: true,
1065
+ botId: event.bot_id,
1066
+ appId: event.app_id ?? event.bot_profile?.app_id,
1067
+ subtype: event.subtype,
1068
+ });
1069
+ return attachments;
1070
+ }
1071
+ // ==========================================================================
1072
+ // Private - Backfill
1073
+ // ==========================================================================
1074
+ async getExistingTimestamps(channelId) {
1075
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
1076
+ const timestamps = new Set();
1077
+ if (!existsSync(logPath))
1078
+ return timestamps;
1079
+ const content = await readFile(logPath, "utf-8");
1080
+ const lines = content.trim().split("\n").filter(Boolean);
1081
+ for (let i = 0; i < lines.length; i++) {
1082
+ try {
1083
+ const entry = JSON.parse(lines[i]);
1084
+ if (entry.ts)
1085
+ timestamps.add(entry.ts);
1086
+ }
1087
+ catch (err) {
1088
+ log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
1089
+ }
1090
+ }
1091
+ return timestamps;
1092
+ }
1093
+ async backfillChannel(channelId, upperBoundTs) {
1094
+ const existingTs = await this.getExistingTimestamps(channelId);
1095
+ // Find the biggest ts in log.jsonl
1096
+ let lastLoggedTs;
1097
+ for (const ts of existingTs) {
1098
+ if (!lastLoggedTs || parseFloat(ts) > parseFloat(lastLoggedTs))
1099
+ lastLoggedTs = ts;
1100
+ }
1101
+ const allMessages = [];
1102
+ let cursor;
1103
+ let pageCount = 0;
1104
+ const maxPages = 3;
1105
+ do {
1106
+ const result = await this.webClient.conversations.history({
1107
+ channel: channelId,
1108
+ oldest: lastLoggedTs, // Only fetch messages newer than what we have
1109
+ latest: upperBoundTs, // Do not race live socket events after startup
1110
+ inclusive: false,
1111
+ limit: 1000,
1112
+ cursor,
1113
+ });
1114
+ if (result.messages) {
1115
+ allMessages.push(...result.messages);
1116
+ }
1117
+ cursor = result.response_metadata?.next_cursor;
1118
+ pageCount++;
1119
+ } while (cursor && pageCount < maxPages);
1120
+ // Filter: include mikan's messages, external app/bot messages, and user messages.
1121
+ const relevantMessages = allMessages.filter((msg) => {
1122
+ if (!msg.ts || existingTs.has(msg.ts))
1123
+ return false; // Skip duplicates
1124
+ if (msg.user === this.botUserId)
1125
+ return true;
1126
+ const isExternalBotMessage = !!msg.bot_id || msg.subtype === "bot_message";
1127
+ if (isExternalBotMessage) {
1128
+ if (this.botId && msg.bot_id === this.botId)
1129
+ return false;
1130
+ if (msg.subtype !== undefined &&
1131
+ msg.subtype !== "bot_message" &&
1132
+ msg.subtype !== "file_share") {
1133
+ return false;
1134
+ }
1135
+ return (!!msg.text ||
1136
+ !!(msg.files && msg.files.length > 0) ||
1137
+ !!msg.blocks?.length ||
1138
+ !!msg.attachments?.length);
1139
+ }
1140
+ if (msg.subtype !== undefined && msg.subtype !== "file_share")
1141
+ return false;
1142
+ if (!msg.user)
1143
+ return false;
1144
+ if (!msg.text && (!msg.files || msg.files.length === 0))
1145
+ return false;
1146
+ return true;
1147
+ });
1148
+ // Reverse to chronological order
1149
+ relevantMessages.reverse();
1150
+ // Log each message to log.jsonl
1151
+ for (const msg of relevantMessages) {
1152
+ const isMikanMessage = msg.user === this.botUserId;
1153
+ const isExternalBotMessage = !isMikanMessage && (!!msg.bot_id || msg.subtype === "bot_message");
1154
+ if (isExternalBotMessage) {
1155
+ await this.logExternalBotMessage({ ...msg, channel: channelId, ts: msg.ts });
1156
+ continue;
1157
+ }
1158
+ const user = this.users.get(msg.user);
1159
+ const text = this.stripOwnMention(msg.text);
1160
+ const attachments = msg.files
1161
+ ? await this.store.processAttachments(channelId, msg.files, msg.ts)
1162
+ : [];
1163
+ this.logToFile(channelId, {
1164
+ date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
1165
+ ts: msg.ts,
1166
+ threadTs: msg.thread_ts,
1167
+ user: isMikanMessage ? "bot" : msg.user,
1168
+ userName: isMikanMessage ? undefined : user?.userName,
1169
+ displayName: isMikanMessage ? undefined : user?.displayName,
1170
+ text,
1171
+ attachments,
1172
+ isBot: isMikanMessage,
1173
+ });
1174
+ }
1175
+ return relevantMessages.length;
1176
+ }
1177
+ async backfillAllChannels(upperBoundTs) {
1178
+ const startTime = Date.now();
1179
+ // Only backfill channels that already have a log.jsonl (mikan has interacted with them before)
1180
+ const channelsToBackfill = [];
1181
+ for (const [channelId, channel] of this.channels) {
1182
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
1183
+ if (existsSync(logPath)) {
1184
+ channelsToBackfill.push([channelId, channel]);
1185
+ }
1186
+ }
1187
+ log.logBackfillStart(channelsToBackfill.length);
1188
+ let totalMessages = 0;
1189
+ for (const [channelId, channel] of channelsToBackfill) {
1190
+ try {
1191
+ const count = await this.backfillChannel(channelId, upperBoundTs);
1192
+ if (count > 0)
1193
+ log.logBackfillChannel(channel.name, count);
1194
+ totalMessages += count;
1195
+ }
1196
+ catch (error) {
1197
+ log.logWarning(`Failed to backfill #${channel.name}`, String(error));
1198
+ }
1199
+ // Add delay between channels to avoid hitting Slack rate limits
1200
+ if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {
1201
+ await new Promise((resolve) => setTimeout(resolve, 500));
1202
+ }
1203
+ }
1204
+ const durationMs = Date.now() - startTime;
1205
+ log.logBackfillComplete(totalMessages, durationMs);
1206
+ }
1207
+ // ==========================================================================
1208
+ // Private - Fetch Users/Channels
1209
+ // ==========================================================================
1210
+ async fetchUsers() {
1211
+ let cursor;
1212
+ do {
1213
+ const result = await this.webClient.users.list({ limit: 200, cursor });
1214
+ const members = result.members;
1215
+ if (members) {
1216
+ for (const u of members) {
1217
+ if (u.id && u.name && !u.deleted) {
1218
+ this.users.set(u.id, {
1219
+ id: u.id,
1220
+ userName: u.name,
1221
+ displayName: u.real_name || u.name,
1222
+ });
1223
+ }
1224
+ }
1225
+ }
1226
+ cursor = result.response_metadata?.next_cursor;
1227
+ } while (cursor);
1228
+ }
1229
+ async fetchChannels() {
1230
+ // Fetch public/private channels
1231
+ let cursor;
1232
+ do {
1233
+ const result = await this.webClient.conversations.list({
1234
+ types: "public_channel,private_channel",
1235
+ exclude_archived: true,
1236
+ limit: 200,
1237
+ cursor,
1238
+ });
1239
+ const channels = result.channels;
1240
+ if (channels) {
1241
+ for (const c of channels) {
1242
+ if (c.id && c.name && c.is_member) {
1243
+ this.channels.set(c.id, { id: c.id, name: c.name });
1244
+ }
1245
+ }
1246
+ }
1247
+ cursor = result.response_metadata?.next_cursor;
1248
+ } while (cursor);
1249
+ // Also fetch DM channels (IMs)
1250
+ cursor = undefined;
1251
+ do {
1252
+ const result = await this.webClient.conversations.list({
1253
+ types: "im",
1254
+ limit: 200,
1255
+ cursor,
1256
+ });
1257
+ const ims = result.channels;
1258
+ if (ims) {
1259
+ for (const im of ims) {
1260
+ if (im.id) {
1261
+ // Use user's name as channel name for DMs
1262
+ const user = im.user ? this.users.get(im.user) : undefined;
1263
+ const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
1264
+ this.channels.set(im.id, { id: im.id, name });
1265
+ }
1266
+ }
1267
+ }
1268
+ cursor = result.response_metadata?.next_cursor;
1269
+ } while (cursor);
1270
+ }
1271
+ }
1272
+ //# sourceMappingURL=bot.js.map