@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.1

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 (148) hide show
  1. package/README.md +59 -19
  2. package/dist/adapter.d.ts +9 -7
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +2 -2
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +33 -21
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +20 -13
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/slack/bot.d.ts +4 -3
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +68 -30
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/context.d.ts.map +1 -1
  17. package/dist/adapters/slack/context.js +22 -12
  18. package/dist/adapters/slack/context.js.map +1 -1
  19. package/dist/adapters/telegram/bot.d.ts +2 -2
  20. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  21. package/dist/adapters/telegram/bot.js +54 -33
  22. package/dist/adapters/telegram/bot.js.map +1 -1
  23. package/dist/adapters/telegram/context.d.ts.map +1 -1
  24. package/dist/adapters/telegram/context.js +61 -10
  25. package/dist/adapters/telegram/context.js.map +1 -1
  26. package/dist/agent.d.ts +7 -4
  27. package/dist/agent.d.ts.map +1 -1
  28. package/dist/agent.js +152 -96
  29. package/dist/agent.js.map +1 -1
  30. package/dist/bindings.d.ts +63 -0
  31. package/dist/bindings.d.ts.map +1 -0
  32. package/dist/bindings.js +94 -0
  33. package/dist/bindings.js.map +1 -0
  34. package/dist/config.d.ts +32 -5
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +71 -44
  37. package/dist/config.js.map +1 -1
  38. package/dist/context.d.ts +6 -6
  39. package/dist/context.d.ts.map +1 -1
  40. package/dist/context.js +8 -8
  41. package/dist/context.js.map +1 -1
  42. package/dist/events.d.ts +4 -0
  43. package/dist/events.d.ts.map +1 -1
  44. package/dist/events.js +20 -5
  45. package/dist/events.js.map +1 -1
  46. package/dist/execution-resolver.d.ts +20 -0
  47. package/dist/execution-resolver.d.ts.map +1 -0
  48. package/dist/execution-resolver.js +51 -0
  49. package/dist/execution-resolver.js.map +1 -0
  50. package/dist/instrument.d.ts.map +1 -1
  51. package/dist/instrument.js +11 -4
  52. package/dist/instrument.js.map +1 -1
  53. package/dist/link-server.d.ts +16 -0
  54. package/dist/link-server.d.ts.map +1 -0
  55. package/dist/link-server.js +839 -0
  56. package/dist/link-server.js.map +1 -0
  57. package/dist/link-token.d.ts +32 -0
  58. package/dist/link-token.d.ts.map +1 -0
  59. package/dist/link-token.js +68 -0
  60. package/dist/link-token.js.map +1 -0
  61. package/dist/log.d.ts +2 -2
  62. package/dist/log.d.ts.map +1 -1
  63. package/dist/log.js +7 -7
  64. package/dist/log.js.map +1 -1
  65. package/dist/login.d.ts +29 -0
  66. package/dist/login.d.ts.map +1 -0
  67. package/dist/login.js +164 -0
  68. package/dist/login.js.map +1 -0
  69. package/dist/main.d.ts.map +1 -1
  70. package/dist/main.js +243 -56
  71. package/dist/main.js.map +1 -1
  72. package/dist/provisioner.d.ts +93 -0
  73. package/dist/provisioner.d.ts.map +1 -0
  74. package/dist/provisioner.js +336 -0
  75. package/dist/provisioner.js.map +1 -0
  76. package/dist/sandbox/container.d.ts +15 -0
  77. package/dist/sandbox/container.d.ts.map +1 -0
  78. package/dist/sandbox/container.js +122 -0
  79. package/dist/sandbox/container.js.map +1 -0
  80. package/dist/sandbox/errors.d.ts +6 -0
  81. package/dist/sandbox/errors.d.ts.map +1 -0
  82. package/dist/sandbox/errors.js +11 -0
  83. package/dist/sandbox/errors.js.map +1 -0
  84. package/dist/sandbox/firecracker.d.ts +16 -0
  85. package/dist/sandbox/firecracker.d.ts.map +1 -0
  86. package/dist/sandbox/firecracker.js +206 -0
  87. package/dist/sandbox/firecracker.js.map +1 -0
  88. package/dist/sandbox/host.d.ts +12 -0
  89. package/dist/sandbox/host.d.ts.map +1 -0
  90. package/dist/sandbox/host.js +89 -0
  91. package/dist/sandbox/host.js.map +1 -0
  92. package/dist/sandbox/image.d.ts +5 -0
  93. package/dist/sandbox/image.d.ts.map +1 -0
  94. package/dist/sandbox/image.js +30 -0
  95. package/dist/sandbox/image.js.map +1 -0
  96. package/dist/sandbox/index.d.ts +20 -0
  97. package/dist/sandbox/index.d.ts.map +1 -0
  98. package/dist/sandbox/index.js +51 -0
  99. package/dist/sandbox/index.js.map +1 -0
  100. package/dist/sandbox/types.d.ts +51 -0
  101. package/dist/sandbox/types.d.ts.map +1 -0
  102. package/dist/sandbox/types.js +2 -0
  103. package/dist/sandbox/types.js.map +1 -0
  104. package/dist/sandbox/utils.d.ts +4 -0
  105. package/dist/sandbox/utils.d.ts.map +1 -0
  106. package/dist/sandbox/utils.js +51 -0
  107. package/dist/sandbox/utils.js.map +1 -0
  108. package/dist/sandbox.d.ts +1 -39
  109. package/dist/sandbox.d.ts.map +1 -1
  110. package/dist/sandbox.js +1 -286
  111. package/dist/sandbox.js.map +1 -1
  112. package/dist/sentry.d.ts +1 -1
  113. package/dist/sentry.d.ts.map +1 -1
  114. package/dist/sentry.js +2 -2
  115. package/dist/sentry.js.map +1 -1
  116. package/dist/session-store.d.ts +1 -5
  117. package/dist/session-store.d.ts.map +1 -1
  118. package/dist/session-store.js +7 -10
  119. package/dist/session-store.js.map +1 -1
  120. package/dist/store.d.ts +1 -1
  121. package/dist/store.d.ts.map +1 -1
  122. package/dist/store.js +8 -8
  123. package/dist/store.js.map +1 -1
  124. package/dist/tools/event.d.ts +21 -0
  125. package/dist/tools/event.d.ts.map +1 -0
  126. package/dist/tools/event.js +103 -0
  127. package/dist/tools/event.js.map +1 -0
  128. package/dist/tools/index.d.ts +6 -1
  129. package/dist/tools/index.d.ts.map +1 -1
  130. package/dist/tools/index.js +5 -1
  131. package/dist/tools/index.js.map +1 -1
  132. package/dist/ui-copy.d.ts +11 -0
  133. package/dist/ui-copy.d.ts.map +1 -0
  134. package/dist/ui-copy.js +33 -0
  135. package/dist/ui-copy.js.map +1 -0
  136. package/dist/vault-routing.d.ts +10 -0
  137. package/dist/vault-routing.d.ts.map +1 -0
  138. package/dist/vault-routing.js +58 -0
  139. package/dist/vault-routing.js.map +1 -0
  140. package/dist/vault.d.ts +106 -0
  141. package/dist/vault.d.ts.map +1 -0
  142. package/dist/vault.js +389 -0
  143. package/dist/vault.js.map +1 -0
  144. package/dist/vault.test.d.ts +2 -0
  145. package/dist/vault.test.d.ts.map +1 -0
  146. package/dist/vault.test.js +67 -0
  147. package/dist/vault.test.js.map +1 -0
  148. package/package.json +12 -11
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEvF,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAEzD,eAAO,MAAM,wBAAwB,uNAG0B,CAAC;AAEhE,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,UAAU,EACf,OAAO,CAAC,EAAE,OAAO,GAChB;IACD,OAAO,EAAE,WAAW,CAAC;IACrB,WAAW,EAAE,mBAAmB,CAAC;IACjC,QAAQ,EAAE,YAAY,CAAC;CACxB,CA+JA","sourcesContent":["import type { ChatMessage, ChatResponseContext, PlatformInfo } from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport type { DiscordBot, DiscordEvent } from \"./bot.js\";\n\nexport const DISCORD_FORMATTING_GUIDE = `## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`language\\ncode\\`\\`\\`\nLinks: [text](url), Spoiler: ||text||\nKeep messages under 2000 characters. Use code blocks for code.`;\n\nexport function createDiscordAdapters(\n event: DiscordEvent,\n bot: DiscordBot,\n isEvent?: boolean,\n): {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n} {\n let messageId: string | null = null;\n let accumulatedText = \"\";\n let isWorking = true;\n const workingIndicator = \" ...\";\n let updatePromise = Promise.resolve();\n let typingInterval: ReturnType<typeof setInterval> | null = null;\n\n function stopTyping(): void {\n if (typingInterval !== null) {\n clearInterval(typingInterval);\n typingInterval = null;\n }\n }\n\n const _eventFilename = isEvent ? event.text.match(/^\\[EVENT:([^:]+):/)?.[1] : undefined;\n const isThreaded = !!event.thread_ts;\n\n const message: ChatMessage = {\n id: event.ts,\n sessionKey: `${event.channel}:${event.thread_ts ?? event.ts}`,\n userId: event.user,\n userName: event.userName,\n text: event.text,\n attachments: event.attachments,\n threadTs: event.thread_ts,\n };\n\n const platform: PlatformInfo = {\n name: \"discord\",\n formattingGuide: DISCORD_FORMATTING_GUIDE,\n channels: bot.getAllChannels(),\n users: bot.getAllUsers(),\n };\n\n // Discord message limit is 2000 chars; use 1900 for safety\n const MAX_LENGTH = 1900;\n const truncationNote = \"\\n\\n*(message truncated, ask me to elaborate on specific parts)*\";\n\n function truncate(text: string, limit: number, note: string): string {\n if (text.length > limit) {\n return text.substring(0, limit - note.length) + note;\n }\n return text;\n }\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = accumulatedText ? `${accumulatedText}\\n${text}` : text;\n const displayText = truncate(\n isWorking ? accumulatedText + workingIndicator : accumulatedText,\n MAX_LENGTH,\n truncationNote,\n );\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);\n } else {\n messageId = await bot.postReply(event.channel, event.ts, displayText);\n }\n }\n\n if (messageId !== null) {\n bot.logBotResponse(event.channel, text, messageId);\n }\n } catch (err) {\n log.logWarning(\"Discord respond error\", err instanceof Error ? err.message : String(err));\n }\n });\n await updatePromise;\n },\n\n replaceResponse: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = truncate(text, MAX_LENGTH, truncationNote);\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);\n } else {\n messageId = await bot.postReply(event.channel, event.ts, displayText);\n }\n }\n } catch (err) {\n log.logWarning(\n \"Discord replaceResponse error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n // Discord threads not used here — discard thread-only messages (e.g. usage summary)\n respondInThread: async (_text: string) => {},\n\n setTyping: async (isTyping: boolean) => {\n if (isTyping && typingInterval === null) {\n // Send immediately and repeat every 8s (Discord clears indicator after ~10s)\n bot.sendTyping(event.channel).catch(() => {});\n typingInterval = setInterval(() => {\n bot.sendTyping(event.channel).catch(() => {});\n }, 8000);\n } else if (!isTyping) {\n stopTyping();\n }\n },\n\n setWorking: async (working: boolean) => {\n updatePromise = updatePromise.then(async () => {\n try {\n isWorking = working;\n if (!working) stopTyping();\n if (messageId !== null) {\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n }\n } catch (err) {\n log.logWarning(\n \"Discord setWorking error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n uploadFile: async (filePath: string, title?: string) => {\n await bot.uploadFile(event.channel, filePath, title);\n },\n\n deleteResponse: async () => {\n updatePromise = updatePromise.then(async () => {\n stopTyping();\n if (messageId !== null) {\n try {\n await bot.deleteMessageRaw(event.channel, messageId);\n } catch {\n // Ignore errors\n }\n messageId = null;\n }\n });\n await updatePromise;\n },\n };\n\n return { message, responseCtx, platform };\n}\n"]}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEvF,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAEzD,eAAO,MAAM,wBAAwB,uNAG0B,CAAC;AAEhE,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,UAAU,EACf,OAAO,CAAC,EAAE,OAAO,GAChB;IACD,OAAO,EAAE,WAAW,CAAC;IACrB,WAAW,EAAE,mBAAmB,CAAC;IACjC,QAAQ,EAAE,YAAY,CAAC;CACxB,CA4KA","sourcesContent":["import type { ChatMessage, ChatResponseContext, PlatformInfo } from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport type { DiscordBot, DiscordEvent } from \"./bot.js\";\n\nexport const DISCORD_FORMATTING_GUIDE = `## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`language\\ncode\\`\\`\\`\nLinks: [text](url), Spoiler: ||text||\nKeep messages under 2000 characters. Use code blocks for code.`;\n\nexport function createDiscordAdapters(\n event: DiscordEvent,\n bot: DiscordBot,\n isEvent?: boolean,\n): {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n} {\n let messageId: string | null = null;\n let accumulatedText = \"\";\n let isWorking = true;\n const workingIndicator = \" ...\";\n let updatePromise = Promise.resolve();\n let typingInterval: ReturnType<typeof setInterval> | null = null;\n\n function stopTyping(): void {\n if (typingInterval !== null) {\n clearInterval(typingInterval);\n typingInterval = null;\n }\n }\n\n const _eventFilename = isEvent ? event.text.match(/^\\[EVENT:([^:]+):/)?.[1] : undefined;\n const isThreaded = !!event.thread_ts;\n const isRootEvent = !!isEvent && !isThreaded;\n\n const message: ChatMessage = {\n id: event.ts,\n sessionKey: event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`,\n userId: event.user,\n userName: event.userName,\n text: event.text,\n attachments: event.attachments,\n threadTs: event.thread_ts,\n };\n\n const platform: PlatformInfo = {\n name: \"discord\",\n formattingGuide: DISCORD_FORMATTING_GUIDE,\n channels: bot.getAllChannels(),\n users: bot.getAllUsers(),\n };\n\n // Discord message limit is 2000 chars; use 1900 for safety\n const MAX_LENGTH = 1900;\n const truncationNote = \"\\n\\n*(message truncated, ask me to elaborate on specific parts)*\";\n\n function truncate(text: string, limit: number, note: string): string {\n if (text.length > limit) {\n return text.substring(0, limit - note.length) + note;\n }\n return text;\n }\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = accumulatedText ? `${accumulatedText}\\n${text}` : text;\n const displayText = truncate(\n isWorking ? accumulatedText + workingIndicator : accumulatedText,\n MAX_LENGTH,\n truncationNote,\n );\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(\n event.conversationId,\n event.thread_ts,\n displayText,\n );\n } else if (isRootEvent) {\n messageId = await bot.postMessage(event.conversationId, displayText);\n } else {\n messageId = await bot.postReply(event.conversationId, event.ts, displayText);\n }\n }\n\n if (messageId !== null) {\n bot.logBotResponse(event.conversationId, text, messageId);\n }\n } catch (err) {\n log.logWarning(\"Discord respond error\", err instanceof Error ? err.message : String(err));\n }\n });\n await updatePromise;\n },\n\n replaceResponse: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = truncate(text, MAX_LENGTH, truncationNote);\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(\n event.conversationId,\n event.thread_ts,\n displayText,\n );\n } else if (isRootEvent) {\n messageId = await bot.postMessage(event.conversationId, displayText);\n } else {\n messageId = await bot.postReply(event.conversationId, event.ts, displayText);\n }\n }\n } catch (err) {\n log.logWarning(\n \"Discord replaceResponse error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n // Discord threads not used here — discard thread-only messages (e.g. usage summary)\n respondInThread: async (_text: string) => {},\n\n setTyping: async (isTyping: boolean) => {\n if (isTyping && typingInterval === null) {\n // Send immediately and repeat every 8s (Discord clears indicator after ~10s)\n bot.sendTyping(event.conversationId).catch(() => {});\n typingInterval = setInterval(() => {\n bot.sendTyping(event.conversationId).catch(() => {});\n }, 8000);\n } else if (!isTyping) {\n stopTyping();\n }\n },\n\n setWorking: async (working: boolean) => {\n updatePromise = updatePromise.then(async () => {\n try {\n isWorking = working;\n if (!working) stopTyping();\n if (messageId !== null) {\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n }\n } catch (err) {\n log.logWarning(\n \"Discord setWorking error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n uploadFile: async (filePath: string, title?: string) => {\n await bot.uploadFile(event.conversationId, filePath, title);\n },\n\n deleteResponse: async () => {\n updatePromise = updatePromise.then(async () => {\n stopTyping();\n if (messageId !== null) {\n try {\n await bot.deleteMessageRaw(event.conversationId, messageId);\n } catch {\n // Ignore errors\n }\n messageId = null;\n }\n });\n await updatePromise;\n },\n };\n\n return { message, responseCtx, platform };\n}\n"]}
@@ -18,9 +18,10 @@ export function createDiscordAdapters(event, bot, isEvent) {
18
18
  }
19
19
  const _eventFilename = isEvent ? event.text.match(/^\[EVENT:([^:]+):/)?.[1] : undefined;
20
20
  const isThreaded = !!event.thread_ts;
21
+ const isRootEvent = !!isEvent && !isThreaded;
21
22
  const message = {
22
23
  id: event.ts,
23
- sessionKey: `${event.channel}:${event.thread_ts ?? event.ts}`,
24
+ sessionKey: event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`,
24
25
  userId: event.user,
25
26
  userName: event.userName,
26
27
  text: event.text,
@@ -49,19 +50,22 @@ export function createDiscordAdapters(event, bot, isEvent) {
49
50
  accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
50
51
  const displayText = truncate(isWorking ? accumulatedText + workingIndicator : accumulatedText, MAX_LENGTH, truncationNote);
51
52
  if (messageId !== null) {
52
- await bot.updateMessageRaw(event.channel, messageId, displayText);
53
+ await bot.updateMessageRaw(event.conversationId, messageId, displayText);
53
54
  }
54
55
  else {
55
56
  stopTyping();
56
57
  if (isThreaded && event.thread_ts) {
57
- messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);
58
+ messageId = await bot.postInThread(event.conversationId, event.thread_ts, displayText);
59
+ }
60
+ else if (isRootEvent) {
61
+ messageId = await bot.postMessage(event.conversationId, displayText);
58
62
  }
59
63
  else {
60
- messageId = await bot.postReply(event.channel, event.ts, displayText);
64
+ messageId = await bot.postReply(event.conversationId, event.ts, displayText);
61
65
  }
62
66
  }
63
67
  if (messageId !== null) {
64
- bot.logBotResponse(event.channel, text, messageId);
68
+ bot.logBotResponse(event.conversationId, text, messageId);
65
69
  }
66
70
  }
67
71
  catch (err) {
@@ -76,15 +80,18 @@ export function createDiscordAdapters(event, bot, isEvent) {
76
80
  accumulatedText = truncate(text, MAX_LENGTH, truncationNote);
77
81
  const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
78
82
  if (messageId !== null) {
79
- await bot.updateMessageRaw(event.channel, messageId, displayText);
83
+ await bot.updateMessageRaw(event.conversationId, messageId, displayText);
80
84
  }
81
85
  else {
82
86
  stopTyping();
83
87
  if (isThreaded && event.thread_ts) {
84
- messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);
88
+ messageId = await bot.postInThread(event.conversationId, event.thread_ts, displayText);
89
+ }
90
+ else if (isRootEvent) {
91
+ messageId = await bot.postMessage(event.conversationId, displayText);
85
92
  }
86
93
  else {
87
- messageId = await bot.postReply(event.channel, event.ts, displayText);
94
+ messageId = await bot.postReply(event.conversationId, event.ts, displayText);
88
95
  }
89
96
  }
90
97
  }
@@ -99,9 +106,9 @@ export function createDiscordAdapters(event, bot, isEvent) {
99
106
  setTyping: async (isTyping) => {
100
107
  if (isTyping && typingInterval === null) {
101
108
  // Send immediately and repeat every 8s (Discord clears indicator after ~10s)
102
- bot.sendTyping(event.channel).catch(() => { });
109
+ bot.sendTyping(event.conversationId).catch(() => { });
103
110
  typingInterval = setInterval(() => {
104
- bot.sendTyping(event.channel).catch(() => { });
111
+ bot.sendTyping(event.conversationId).catch(() => { });
105
112
  }, 8000);
106
113
  }
107
114
  else if (!isTyping) {
@@ -116,7 +123,7 @@ export function createDiscordAdapters(event, bot, isEvent) {
116
123
  stopTyping();
117
124
  if (messageId !== null) {
118
125
  const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
119
- await bot.updateMessageRaw(event.channel, messageId, displayText);
126
+ await bot.updateMessageRaw(event.conversationId, messageId, displayText);
120
127
  }
121
128
  }
122
129
  catch (err) {
@@ -126,14 +133,14 @@ export function createDiscordAdapters(event, bot, isEvent) {
126
133
  await updatePromise;
127
134
  },
128
135
  uploadFile: async (filePath, title) => {
129
- await bot.uploadFile(event.channel, filePath, title);
136
+ await bot.uploadFile(event.conversationId, filePath, title);
130
137
  },
131
138
  deleteResponse: async () => {
132
139
  updatePromise = updatePromise.then(async () => {
133
140
  stopTyping();
134
141
  if (messageId !== null) {
135
142
  try {
136
- await bot.deleteMessageRaw(event.channel, messageId);
143
+ await bot.deleteMessageRaw(event.conversationId, messageId);
137
144
  }
138
145
  catch {
139
146
  // Ignore errors
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../../../src/adapters/discord/context.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChatMessage, ChatResponseContext, PlatformInfo } from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport type { DiscordBot, DiscordEvent } from \"./bot.js\";\n\nexport const DISCORD_FORMATTING_GUIDE = `## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`language\\ncode\\`\\`\\`\nLinks: [text](url), Spoiler: ||text||\nKeep messages under 2000 characters. Use code blocks for code.`;\n\nexport function createDiscordAdapters(\n event: DiscordEvent,\n bot: DiscordBot,\n isEvent?: boolean,\n): {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n} {\n let messageId: string | null = null;\n let accumulatedText = \"\";\n let isWorking = true;\n const workingIndicator = \" ...\";\n let updatePromise = Promise.resolve();\n let typingInterval: ReturnType<typeof setInterval> | null = null;\n\n function stopTyping(): void {\n if (typingInterval !== null) {\n clearInterval(typingInterval);\n typingInterval = null;\n }\n }\n\n const _eventFilename = isEvent ? event.text.match(/^\\[EVENT:([^:]+):/)?.[1] : undefined;\n const isThreaded = !!event.thread_ts;\n\n const message: ChatMessage = {\n id: event.ts,\n sessionKey: `${event.channel}:${event.thread_ts ?? event.ts}`,\n userId: event.user,\n userName: event.userName,\n text: event.text,\n attachments: event.attachments,\n threadTs: event.thread_ts,\n };\n\n const platform: PlatformInfo = {\n name: \"discord\",\n formattingGuide: DISCORD_FORMATTING_GUIDE,\n channels: bot.getAllChannels(),\n users: bot.getAllUsers(),\n };\n\n // Discord message limit is 2000 chars; use 1900 for safety\n const MAX_LENGTH = 1900;\n const truncationNote = \"\\n\\n*(message truncated, ask me to elaborate on specific parts)*\";\n\n function truncate(text: string, limit: number, note: string): string {\n if (text.length > limit) {\n return text.substring(0, limit - note.length) + note;\n }\n return text;\n }\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = accumulatedText ? `${accumulatedText}\\n${text}` : text;\n const displayText = truncate(\n isWorking ? accumulatedText + workingIndicator : accumulatedText,\n MAX_LENGTH,\n truncationNote,\n );\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);\n } else {\n messageId = await bot.postReply(event.channel, event.ts, displayText);\n }\n }\n\n if (messageId !== null) {\n bot.logBotResponse(event.channel, text, messageId);\n }\n } catch (err) {\n log.logWarning(\"Discord respond error\", err instanceof Error ? err.message : String(err));\n }\n });\n await updatePromise;\n },\n\n replaceResponse: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = truncate(text, MAX_LENGTH, truncationNote);\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);\n } else {\n messageId = await bot.postReply(event.channel, event.ts, displayText);\n }\n }\n } catch (err) {\n log.logWarning(\n \"Discord replaceResponse error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n // Discord threads not used here — discard thread-only messages (e.g. usage summary)\n respondInThread: async (_text: string) => {},\n\n setTyping: async (isTyping: boolean) => {\n if (isTyping && typingInterval === null) {\n // Send immediately and repeat every 8s (Discord clears indicator after ~10s)\n bot.sendTyping(event.channel).catch(() => {});\n typingInterval = setInterval(() => {\n bot.sendTyping(event.channel).catch(() => {});\n }, 8000);\n } else if (!isTyping) {\n stopTyping();\n }\n },\n\n setWorking: async (working: boolean) => {\n updatePromise = updatePromise.then(async () => {\n try {\n isWorking = working;\n if (!working) stopTyping();\n if (messageId !== null) {\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n await bot.updateMessageRaw(event.channel, messageId, displayText);\n }\n } catch (err) {\n log.logWarning(\n \"Discord setWorking error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n uploadFile: async (filePath: string, title?: string) => {\n await bot.uploadFile(event.channel, filePath, title);\n },\n\n deleteResponse: async () => {\n updatePromise = updatePromise.then(async () => {\n stopTyping();\n if (messageId !== null) {\n try {\n await bot.deleteMessageRaw(event.channel, messageId);\n } catch {\n // Ignore errors\n }\n messageId = null;\n }\n });\n await updatePromise;\n },\n };\n\n return { message, responseCtx, platform };\n}\n"]}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../../../src/adapters/discord/context.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChatMessage, ChatResponseContext, PlatformInfo } from \"../../adapter.js\";\nimport * as log from \"../../log.js\";\nimport type { DiscordBot, DiscordEvent } from \"./bot.js\";\n\nexport const DISCORD_FORMATTING_GUIDE = `## Discord Formatting (Markdown)\nBold: **text**, Italic: *text*, Code: \\`code\\`, Block: \\`\\`\\`language\\ncode\\`\\`\\`\nLinks: [text](url), Spoiler: ||text||\nKeep messages under 2000 characters. Use code blocks for code.`;\n\nexport function createDiscordAdapters(\n event: DiscordEvent,\n bot: DiscordBot,\n isEvent?: boolean,\n): {\n message: ChatMessage;\n responseCtx: ChatResponseContext;\n platform: PlatformInfo;\n} {\n let messageId: string | null = null;\n let accumulatedText = \"\";\n let isWorking = true;\n const workingIndicator = \" ...\";\n let updatePromise = Promise.resolve();\n let typingInterval: ReturnType<typeof setInterval> | null = null;\n\n function stopTyping(): void {\n if (typingInterval !== null) {\n clearInterval(typingInterval);\n typingInterval = null;\n }\n }\n\n const _eventFilename = isEvent ? event.text.match(/^\\[EVENT:([^:]+):/)?.[1] : undefined;\n const isThreaded = !!event.thread_ts;\n const isRootEvent = !!isEvent && !isThreaded;\n\n const message: ChatMessage = {\n id: event.ts,\n sessionKey: event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`,\n userId: event.user,\n userName: event.userName,\n text: event.text,\n attachments: event.attachments,\n threadTs: event.thread_ts,\n };\n\n const platform: PlatformInfo = {\n name: \"discord\",\n formattingGuide: DISCORD_FORMATTING_GUIDE,\n channels: bot.getAllChannels(),\n users: bot.getAllUsers(),\n };\n\n // Discord message limit is 2000 chars; use 1900 for safety\n const MAX_LENGTH = 1900;\n const truncationNote = \"\\n\\n*(message truncated, ask me to elaborate on specific parts)*\";\n\n function truncate(text: string, limit: number, note: string): string {\n if (text.length > limit) {\n return text.substring(0, limit - note.length) + note;\n }\n return text;\n }\n\n const responseCtx: ChatResponseContext = {\n respond: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = accumulatedText ? `${accumulatedText}\\n${text}` : text;\n const displayText = truncate(\n isWorking ? accumulatedText + workingIndicator : accumulatedText,\n MAX_LENGTH,\n truncationNote,\n );\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(\n event.conversationId,\n event.thread_ts,\n displayText,\n );\n } else if (isRootEvent) {\n messageId = await bot.postMessage(event.conversationId, displayText);\n } else {\n messageId = await bot.postReply(event.conversationId, event.ts, displayText);\n }\n }\n\n if (messageId !== null) {\n bot.logBotResponse(event.conversationId, text, messageId);\n }\n } catch (err) {\n log.logWarning(\"Discord respond error\", err instanceof Error ? err.message : String(err));\n }\n });\n await updatePromise;\n },\n\n replaceResponse: async (text: string) => {\n updatePromise = updatePromise.then(async () => {\n try {\n accumulatedText = truncate(text, MAX_LENGTH, truncationNote);\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n if (messageId !== null) {\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n } else {\n stopTyping();\n if (isThreaded && event.thread_ts) {\n messageId = await bot.postInThread(\n event.conversationId,\n event.thread_ts,\n displayText,\n );\n } else if (isRootEvent) {\n messageId = await bot.postMessage(event.conversationId, displayText);\n } else {\n messageId = await bot.postReply(event.conversationId, event.ts, displayText);\n }\n }\n } catch (err) {\n log.logWarning(\n \"Discord replaceResponse error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n // Discord threads not used here — discard thread-only messages (e.g. usage summary)\n respondInThread: async (_text: string) => {},\n\n setTyping: async (isTyping: boolean) => {\n if (isTyping && typingInterval === null) {\n // Send immediately and repeat every 8s (Discord clears indicator after ~10s)\n bot.sendTyping(event.conversationId).catch(() => {});\n typingInterval = setInterval(() => {\n bot.sendTyping(event.conversationId).catch(() => {});\n }, 8000);\n } else if (!isTyping) {\n stopTyping();\n }\n },\n\n setWorking: async (working: boolean) => {\n updatePromise = updatePromise.then(async () => {\n try {\n isWorking = working;\n if (!working) stopTyping();\n if (messageId !== null) {\n const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n await bot.updateMessageRaw(event.conversationId, messageId, displayText);\n }\n } catch (err) {\n log.logWarning(\n \"Discord setWorking error\",\n err instanceof Error ? err.message : String(err),\n );\n }\n });\n await updatePromise;\n },\n\n uploadFile: async (filePath: string, title?: string) => {\n await bot.uploadFile(event.conversationId, filePath, title);\n },\n\n deleteResponse: async () => {\n updatePromise = updatePromise.then(async () => {\n stopTyping();\n if (messageId !== null) {\n try {\n await bot.deleteMessageRaw(event.conversationId, messageId);\n } catch {\n // Ignore errors\n }\n messageId = null;\n }\n });\n await updatePromise;\n },\n };\n\n return { message, responseCtx, platform };\n}\n"]}
@@ -45,7 +45,7 @@ export interface SlackContext {
45
45
  channel: string;
46
46
  ts: string;
47
47
  attachments: Array<{
48
- local: string;
48
+ localPath: string;
49
49
  }>;
50
50
  };
51
51
  channelName?: string;
@@ -80,13 +80,14 @@ export declare class SlackBot implements Bot {
80
80
  store: ChannelStore;
81
81
  });
82
82
  setEventsWatcher(watcher: EventsWatcher): void;
83
+ private toBotEvent;
83
84
  start(): Promise<void>;
84
85
  getUser(userId: string): SlackUser | undefined;
85
86
  getChannel(channelId: string): SlackChannel | undefined;
86
87
  getAllUsers(): SlackUser[];
87
88
  getAllChannels(): SlackChannel[];
88
- postMessage(channel: string, text: string): Promise<string>;
89
- updateMessage(channel: string, ts: string, text: string): Promise<void>;
89
+ postMessage(conversationId: string, text: string): Promise<string>;
90
+ updateMessage(conversationId: string, ts: string, text: string): Promise<void>;
90
91
  deleteMessage(channel: string, ts: string): Promise<void>;
91
92
  /** Set the status for an assistant thread (shows "thinking" state) */
92
93
  setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type { Bot, BotEvent, BotHandler, PlatformInfo } from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { createSlackAdapters } from \"./context.js\";\n\n// ============================================================================\n// Exponential backoff utility for Slack API calls\n// ============================================================================\n\n/**\n * Retry a function with exponential backoff on rate limit errors.\n */\nasync function withRetry<T>(\n fn: () => Promise<T>,\n maxRetries: number = 3,\n baseDelayMs: number = 1000,\n): Promise<T> {\n let lastError: Error | undefined;\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n\n // Check for rate limit errors\n let isRateLimited = false;\n\n // Check for rate_limited error code (Slack SDK)\n if (\"code\" in lastError && lastError.code === \"rate_limited\") {\n isRateLimited = true;\n }\n\n // Check for rate_limited in error response\n if (\"data\" in lastError) {\n const data = (lastError as { data?: { error?: string; response?: { status?: number } } })\n .data;\n if (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n isRateLimited = true;\n }\n }\n\n if (isRateLimited) {\n const delay = baseDelayMs * Math.pow(2, attempt);\n log.logWarning(\n `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,\n );\n await new Promise((resolve) => setTimeout(resolve, delay));\n continue;\n }\n\n // Non-retryable error\n throw lastError;\n }\n }\n throw lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ local: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondInThread: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n/** @deprecated Use BotHandler from adapter.ts instead */\nexport type MomHandler = BotHandler;\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n private queue: QueuedWork[] = [];\n private processing = false;\n\n enqueue(work: QueuedWork): void {\n this.queue.push(work);\n this.processNext();\n }\n\n size(): number {\n return this.queue.length;\n }\n\n private async processNext(): Promise<void> {\n if (this.processing || this.queue.length === 0) return;\n this.processing = true;\n const work = this.queue.shift()!;\n try {\n await work();\n } catch (err) {\n log.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n }\n this.processing = false;\n this.processNext();\n }\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({ appToken: config.appToken });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected();\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return withRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return withRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n /**\n * Log a message to log.jsonl (SYNC)\n * This is the ONLY place messages are written to log.jsonl\n */\n logToFile(channel: string, entry: object): void {\n const dir = join(this.workingDir, channel);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n appendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n }\n\n /**\n * Log a bot response to log.jsonl\n */\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n this.logToFile(channel, {\n date: new Date().toISOString(),\n ts,\n threadTs,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const queue = this.getQueue(event.channel);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const adapters = createSlackAdapters(event as unknown as SlackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue();\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: \"*Pi Agent*\\nWelcome back! Start a new task or check on running work.\",\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: \"Pi Agent\",\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.channelId);\n const channelName = channel ? `#${channel.name}` : ev.channelId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.channelId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n /**\n * Resolve which session key to stop.\n * When stop is called from a thread, the thread session (channelId:thread_ts) might\n * not be running — but the channel session (channelId) might be, because the bot's\n * reply to a top-level mention creates a thread. Check both, prefer thread first.\n */\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n if (threadTs) {\n const threadKey = `${channelId}:${threadTs}`;\n if (this.handler.isRunning(threadKey)) return threadKey;\n // Fall back to channel session — the thread may have been spawned by a top-level run\n if (this.handler.isRunning(channelId)) return channelId;\n return null;\n }\n return this.handler.isRunning(channelId) ? channelId : null;\n }\n\n private setupEventHandlers(): void {\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n // SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n // SYNC: Check if busy (per-thread)\n if (this.handler.isRunning(sessionKey)) {\n this.postMessage(\n e.channel,\n \"_Already working in this thread. Say `@mama stop` to cancel._\",\n );\n } else {\n this.getQueue(sessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n }\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip bot messages, edits, etc.\n if (e.bot_id || !e.user || e.user === this.botUserId) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!e.text && (!e.files || e.files.length === 0)) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey: isDM ? e.channel : undefined,\n };\n\n // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command in channel threads (without @mention)\n // app_mention handles \"@mama stop\", but bare \"stop\" in a thread comes here\n if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n // Only trigger handler for DMs\n if (isDM) {\n const dmSessionKey = slackEvent.sessionKey!;\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n if (this.handler.isRunning(dmSessionKey)) {\n this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue\n } else {\n this.postMessage(e.channel, \"_Nothing running_\");\n }\n ack();\n return;\n }\n\n if (this.handler.isRunning(dmSessionKey)) {\n this.postMessage(e.channel, \"_Already working. Say `stop` to cancel._\");\n } else {\n this.getQueue(dmSessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n }\n }\n\n ack();\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, `_🔴 Force stopped by ${userId}_`);\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl (SYNC)\n * Downloads attachments in background via store\n */\n private logUserMessage(event: SlackEvent): Attachment[] {\n const user = this.users.get(event.user);\n // Process attachments - queues downloads in background\n const attachments = event.files\n ? this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.ts) timestamps.add(entry.ts);\n } catch {}\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n text?: string;\n ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, exclude other bots, skip already logged\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n if (msg.bot_id) return false;\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const user = this.users.get(msg.user!);\n // Strip @mentions from text (same as live messages)\n const text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n // Process attachments - queues downloads in background\n const attachments = msg.files\n ? this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
1
+ {"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type { Bot, BotEvent, BotHandler, PlatformInfo } from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport { parseLoginCommand } from \"../../login.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport {\n PRODUCT_NAME,\n formatAlreadyWorking,\n formatForceStopped,\n formatNothingRunning,\n} from \"../../ui-copy.js\";\nimport { createSlackAdapters } from \"./context.js\";\n\n// ============================================================================\n// Exponential backoff utility for Slack API calls\n// ============================================================================\n\n/**\n * Retry a function with exponential backoff on rate limit errors.\n */\nasync function withRetry<T>(\n fn: () => Promise<T>,\n maxRetries: number = 3,\n baseDelayMs: number = 1000,\n): Promise<T> {\n let lastError: Error | undefined;\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n\n // Check for rate limit errors\n let isRateLimited = false;\n\n // Check for rate_limited error code (Slack SDK)\n if (\"code\" in lastError && lastError.code === \"rate_limited\") {\n isRateLimited = true;\n }\n\n // Check for rate_limited in error response\n if (\"data\" in lastError) {\n const data = (lastError as { data?: { error?: string; response?: { status?: number } } })\n .data;\n if (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n isRateLimited = true;\n }\n }\n\n if (isRateLimited) {\n const delay = baseDelayMs * Math.pow(2, attempt);\n log.logWarning(\n `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,\n );\n await new Promise((resolve) => setTimeout(resolve, delay));\n continue;\n }\n\n // Non-retryable error\n throw lastError;\n }\n }\n throw lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondInThread: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n/** @deprecated Use BotHandler from adapter.ts instead */\nexport type MomHandler = BotHandler;\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n private queue: QueuedWork[] = [];\n private processing = false;\n\n enqueue(work: QueuedWork): void {\n this.queue.push(work);\n this.processNext();\n }\n\n size(): number {\n return this.queue.length;\n }\n\n private async processNext(): Promise<void> {\n if (this.processing || this.queue.length === 0) return;\n this.processing = true;\n const work = this.queue.shift()!;\n try {\n await work();\n } catch (err) {\n log.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n }\n this.processing = false;\n this.processNext();\n }\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({ appToken: config.appToken });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n private toBotEvent(event: SlackEvent): BotEvent {\n return {\n type: event.type,\n conversationId: event.channel,\n ts: event.ts,\n thread_ts: event.thread_ts,\n user: event.user,\n text: event.text,\n attachments: event.attachments?.map((attachment) => ({\n name: attachment.original,\n localPath: attachment.localPath,\n })),\n sessionKey: event.sessionKey,\n };\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected();\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n async postMessage(conversationId: string, text: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel: conversationId, text });\n return result.ts as string;\n });\n }\n\n async updateMessage(conversationId: string, ts: string, text: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.update({ channel: conversationId, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return withRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return withRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n /**\n * Log a message to log.jsonl (SYNC)\n * This is the ONLY place messages are written to log.jsonl\n */\n logToFile(channel: string, entry: object): void {\n const channelDir = join(this.workingDir, channel);\n if (!existsSync(channelDir)) mkdirSync(channelDir, { recursive: true });\n appendFileSync(join(channelDir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n }\n\n /**\n * Log a bot response to log.jsonl\n */\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n this.logToFile(channel, {\n date: new Date().toISOString(),\n ts,\n threadTs,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const queue = this.getQueue(event.conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const slackEvent: SlackEvent = {\n type: \"mention\",\n channel: event.conversationId,\n ts: event.ts,\n thread_ts: event.thread_ts,\n user: event.user,\n text: event.text,\n sessionKey: event.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue();\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n const timestampFormatter = new Intl.DateTimeFormat(undefined, {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n });\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.channelId);\n const channelName = channel ? `#${channel.name}` : ev.channelId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.channelId}`;\n const nextStr = ev.nextRun ? timestampFormatter.format(new Date(ev.nextRun)) : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n /**\n * Resolve which session key to stop.\n * When stop is called from a thread, the thread session (channelId:thread_ts) might\n * not be running — but the channel session (channelId) might be, because the bot's\n * reply to a top-level mention creates a thread. Check both, prefer thread first.\n */\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n if (threadTs) {\n const threadKey = `${channelId}:${threadTs}`;\n if (this.handler.isRunning(threadKey)) return threadKey;\n // Fall back to channel session — the thread may have been spawned by a top-level run\n if (this.handler.isRunning(channelId)) return channelId;\n return null;\n }\n return this.handler.isRunning(channelId) ? channelId : null;\n }\n\n private setupEventHandlers(): void {\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = e.thread_ts ? `${e.channel}:${e.thread_ts}` : e.channel;\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n // SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n ack();\n return;\n }\n\n // Check for login command\n if (parseLoginCommand(slackEvent.text)) {\n void this.handler.handleLogin(\"slack\", e.user, e.channel, this, slackEvent.text, false);\n ack();\n return;\n }\n\n // SYNC: Check if busy (per-thread)\n if (this.handler.isRunning(sessionKey)) {\n this.postMessage(\n e.channel,\n formatAlreadyWorking(\"slack\", \"@mama stop\", { scope: \"thread\" }),\n );\n } else {\n this.getQueue(sessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);\n });\n }\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip bot messages, edits, etc.\n if (e.bot_id || !e.user || e.user === this.botUserId) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!e.text && (!e.files || e.files.length === 0)) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey: isDM ? e.channel : undefined,\n };\n\n // SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n // Also downloads attachments in background and stores local paths\n slackEvent.attachments = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n ack();\n return;\n }\n\n // Check for stop command in channel threads (without @mention)\n // app_mention handles \"@mama stop\", but bare \"stop\" in a thread comes here\n if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n ack();\n return;\n }\n\n // Only trigger handler for DMs\n if (isDM) {\n const dmSessionKey = slackEvent.sessionKey!;\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n if (this.handler.isRunning(dmSessionKey)) {\n this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n ack();\n return;\n }\n\n // Check for login command\n if (parseLoginCommand(slackEvent.text)) {\n void this.handler.handleLogin(\"slack\", e.user, e.channel, this, slackEvent.text, true);\n ack();\n return;\n }\n\n if (this.handler.isRunning(dmSessionKey)) {\n this.postMessage(e.channel, formatAlreadyWorking(\"slack\", \"stop\"));\n } else {\n this.getQueue(dmSessionKey).enqueue(() => {\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);\n });\n }\n }\n\n ack();\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n const actorLabel = userId ? `<@${userId}>` : \"someone\";\n await this.postMessage(channelId, formatForceStopped(\"slack\", actorLabel));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl (SYNC)\n * Downloads attachments in background via store\n */\n private logUserMessage(event: SlackEvent): Attachment[] {\n const user = this.users.get(event.user);\n // Process attachments - queues downloads in background\n const attachments = event.files\n ? this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.ts) timestamps.add(entry.ts);\n } catch {}\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n text?: string;\n ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, exclude other bots, skip already logged\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n if (msg.bot_id) return false;\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const user = this.users.get(msg.user!);\n // Strip @mentions from text (same as live messages)\n const text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n // Process attachments - queues downloads in background\n const attachments = msg.files\n ? this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
@@ -3,7 +3,9 @@ import { WebClient } from "@slack/web-api";
3
3
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
+ import { parseLoginCommand } from "../../login.js";
6
7
  import * as log from "../../log.js";
8
+ import { PRODUCT_NAME, formatAlreadyWorking, formatForceStopped, formatNothingRunning, } from "../../ui-copy.js";
7
9
  import { createSlackAdapters } from "./context.js";
8
10
  // ============================================================================
9
11
  // Exponential backoff utility for Slack API calls
@@ -92,6 +94,21 @@ export class SlackBot {
92
94
  setEventsWatcher(watcher) {
93
95
  this.eventsWatcher = watcher;
94
96
  }
97
+ toBotEvent(event) {
98
+ return {
99
+ type: event.type,
100
+ conversationId: event.channel,
101
+ ts: event.ts,
102
+ thread_ts: event.thread_ts,
103
+ user: event.user,
104
+ text: event.text,
105
+ attachments: event.attachments?.map((attachment) => ({
106
+ name: attachment.original,
107
+ localPath: attachment.localPath,
108
+ })),
109
+ sessionKey: event.sessionKey,
110
+ };
111
+ }
95
112
  // ==========================================================================
96
113
  // Public API
97
114
  // ==========================================================================
@@ -119,15 +136,15 @@ export class SlackBot {
119
136
  getAllChannels() {
120
137
  return Array.from(this.channels.values());
121
138
  }
122
- async postMessage(channel, text) {
139
+ async postMessage(conversationId, text) {
123
140
  return withRetry(async () => {
124
- const result = await this.webClient.chat.postMessage({ channel, text });
141
+ const result = await this.webClient.chat.postMessage({ channel: conversationId, text });
125
142
  return result.ts;
126
143
  });
127
144
  }
128
- async updateMessage(channel, ts, text) {
145
+ async updateMessage(conversationId, ts, text) {
129
146
  return withRetry(async () => {
130
- await this.webClient.chat.update({ channel, ts, text });
147
+ await this.webClient.chat.update({ channel: conversationId, ts, text });
131
148
  });
132
149
  }
133
150
  async deleteMessage(channel, ts) {
@@ -197,10 +214,10 @@ export class SlackBot {
197
214
  * This is the ONLY place messages are written to log.jsonl
198
215
  */
199
216
  logToFile(channel, entry) {
200
- const dir = join(this.workingDir, channel);
201
- if (!existsSync(dir))
202
- mkdirSync(dir, { recursive: true });
203
- appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
217
+ const channelDir = join(this.workingDir, channel);
218
+ if (!existsSync(channelDir))
219
+ mkdirSync(channelDir, { recursive: true });
220
+ appendFileSync(join(channelDir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
204
221
  }
205
222
  /**
206
223
  * Log a bot response to log.jsonl
@@ -236,14 +253,23 @@ export class SlackBot {
236
253
  * Returns true if enqueued, false if queue is full (max 5).
237
254
  */
238
255
  enqueueEvent(event) {
239
- const queue = this.getQueue(event.channel);
256
+ const queue = this.getQueue(event.conversationId);
240
257
  if (queue.size() >= 5) {
241
- log.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);
258
+ log.logWarning(`Event queue full for ${event.conversationId}, discarding: ${event.text.substring(0, 50)}`);
242
259
  return false;
243
260
  }
244
- log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);
261
+ log.logInfo(`Enqueueing event for ${event.conversationId}: ${event.text.substring(0, 50)}`);
245
262
  queue.enqueue(() => {
246
- const adapters = createSlackAdapters(event, this, true);
263
+ const slackEvent = {
264
+ type: "mention",
265
+ channel: event.conversationId,
266
+ ts: event.ts,
267
+ thread_ts: event.thread_ts,
268
+ user: event.user,
269
+ text: event.text,
270
+ sessionKey: event.sessionKey,
271
+ };
272
+ const adapters = createSlackAdapters(slackEvent, this, true);
247
273
  return this.handler.handleEvent(event, this, adapters, true);
248
274
  });
249
275
  return true;
@@ -267,12 +293,12 @@ export class SlackBot {
267
293
  type: "section",
268
294
  text: {
269
295
  type: "mrkdwn",
270
- text: "*Pi Agent*\nWelcome back! Start a new task or check on running work.",
296
+ text: `*${PRODUCT_NAME}*\nStart a new task or check on running work.`,
271
297
  },
272
298
  accessory: {
273
299
  type: "image",
274
300
  image_url: "https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif",
275
- alt_text: "Pi Agent",
301
+ alt_text: PRODUCT_NAME,
276
302
  },
277
303
  },
278
304
  ];
@@ -361,6 +387,12 @@ export class SlackBot {
361
387
  });
362
388
  }
363
389
  else {
390
+ const timestampFormatter = new Intl.DateTimeFormat(undefined, {
391
+ month: "short",
392
+ day: "numeric",
393
+ hour: "2-digit",
394
+ minute: "2-digit",
395
+ });
364
396
  for (const ev of periodicEvents) {
365
397
  const channelLabel = ev.platform === "slack"
366
398
  ? (() => {
@@ -369,14 +401,7 @@ export class SlackBot {
369
401
  return `${ev.platform}:${channelName}`;
370
402
  })()
371
403
  : `${ev.platform}:${ev.channelId}`;
372
- const nextStr = ev.nextRun
373
- ? new Date(ev.nextRun).toLocaleString("en-US", {
374
- month: "short",
375
- day: "numeric",
376
- hour: "2-digit",
377
- minute: "2-digit",
378
- })
379
- : "—";
404
+ const nextStr = ev.nextRun ? timestampFormatter.format(new Date(ev.nextRun)) : "—";
380
405
  blocks.push({
381
406
  type: "section",
382
407
  text: {
@@ -451,19 +476,25 @@ export class SlackBot {
451
476
  this.handler.handleStop(stopTarget, e.channel, this);
452
477
  }
453
478
  else {
454
- this.postMessage(e.channel, "_Nothing running_");
479
+ this.postMessage(e.channel, formatNothingRunning("slack"));
455
480
  }
456
481
  ack();
457
482
  return;
458
483
  }
484
+ // Check for login command
485
+ if (parseLoginCommand(slackEvent.text)) {
486
+ void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, false);
487
+ ack();
488
+ return;
489
+ }
459
490
  // SYNC: Check if busy (per-thread)
460
491
  if (this.handler.isRunning(sessionKey)) {
461
- this.postMessage(e.channel, "_Already working in this thread. Say `@mama stop` to cancel._");
492
+ this.postMessage(e.channel, formatAlreadyWorking("slack", "@mama stop", { scope: "thread" }));
462
493
  }
463
494
  else {
464
495
  this.getQueue(sessionKey).enqueue(() => {
465
496
  const adapters = createSlackAdapters(slackEvent, this, false);
466
- return this.handler.handleEvent(slackEvent, this, adapters, false);
497
+ return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
467
498
  });
468
499
  }
469
500
  ack();
@@ -518,7 +549,7 @@ export class SlackBot {
518
549
  this.handler.handleStop(stopTarget, e.channel, this);
519
550
  }
520
551
  else {
521
- this.postMessage(e.channel, "_Nothing running_");
552
+ this.postMessage(e.channel, formatNothingRunning("slack"));
522
553
  }
523
554
  ack();
524
555
  return;
@@ -532,18 +563,24 @@ export class SlackBot {
532
563
  this.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue
533
564
  }
534
565
  else {
535
- this.postMessage(e.channel, "_Nothing running_");
566
+ this.postMessage(e.channel, formatNothingRunning("slack"));
536
567
  }
537
568
  ack();
538
569
  return;
539
570
  }
571
+ // Check for login command
572
+ if (parseLoginCommand(slackEvent.text)) {
573
+ void this.handler.handleLogin("slack", e.user, e.channel, this, slackEvent.text, true);
574
+ ack();
575
+ return;
576
+ }
540
577
  if (this.handler.isRunning(dmSessionKey)) {
541
- this.postMessage(e.channel, "_Already working. Say `stop` to cancel._");
578
+ this.postMessage(e.channel, formatAlreadyWorking("slack", "stop"));
542
579
  }
543
580
  else {
544
581
  this.getQueue(dmSessionKey).enqueue(() => {
545
582
  const adapters = createSlackAdapters(slackEvent, this, false);
546
- return this.handler.handleEvent(slackEvent, this, adapters, false);
583
+ return this.handler.handleEvent(this.toBotEvent(slackEvent), this, adapters, false);
547
584
  });
548
585
  }
549
586
  }
@@ -579,7 +616,8 @@ export class SlackBot {
579
616
  // Use handler's forceStop method
580
617
  this.handler.forceStop(sessionKey);
581
618
  // Notify in channel
582
- await this.postMessage(channelId, `_🔴 Force stopped by ${userId}_`);
619
+ const actorLabel = userId ? `<@${userId}>` : "someone";
620
+ await this.postMessage(channelId, formatForceStopped("slack", actorLabel));
583
621
  // Refresh home tab
584
622
  if (userId) {
585
623
  this.webClient.views