@geminixiang/mama 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +72 -18
  3. package/dist/adapter.d.ts +50 -0
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/adapter.js.map +1 -1
  6. package/dist/adapters/discord/bot.d.ts +47 -0
  7. package/dist/adapters/discord/bot.d.ts.map +1 -0
  8. package/dist/adapters/discord/bot.js +291 -0
  9. package/dist/adapters/discord/bot.js.map +1 -0
  10. package/dist/adapters/discord/context.d.ts +9 -0
  11. package/dist/adapters/discord/context.d.ts.map +1 -0
  12. package/dist/adapters/discord/context.js +148 -0
  13. package/dist/adapters/discord/context.js.map +1 -0
  14. package/dist/adapters/discord/index.d.ts +3 -0
  15. package/dist/adapters/discord/index.d.ts.map +1 -0
  16. package/dist/adapters/discord/index.js +3 -0
  17. package/dist/adapters/discord/index.js.map +1 -0
  18. package/dist/adapters/slack/bot.d.ts +7 -21
  19. package/dist/adapters/slack/bot.d.ts.map +1 -1
  20. package/dist/adapters/slack/bot.js +47 -19
  21. package/dist/adapters/slack/bot.js.map +1 -1
  22. package/dist/adapters/slack/context.d.ts +1 -3
  23. package/dist/adapters/slack/context.d.ts.map +1 -1
  24. package/dist/adapters/slack/context.js +7 -3
  25. package/dist/adapters/slack/context.js.map +1 -1
  26. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  27. package/dist/adapters/slack/tools/attach.js.map +1 -1
  28. package/dist/adapters/telegram/bot.d.ts +35 -0
  29. package/dist/adapters/telegram/bot.d.ts.map +1 -0
  30. package/dist/adapters/telegram/bot.js +233 -0
  31. package/dist/adapters/telegram/bot.js.map +1 -0
  32. package/dist/adapters/telegram/context.d.ts +9 -0
  33. package/dist/adapters/telegram/context.d.ts.map +1 -0
  34. package/dist/adapters/telegram/context.js +144 -0
  35. package/dist/adapters/telegram/context.js.map +1 -0
  36. package/dist/adapters/telegram/index.d.ts +3 -0
  37. package/dist/adapters/telegram/index.d.ts.map +1 -0
  38. package/dist/adapters/telegram/index.js +3 -0
  39. package/dist/adapters/telegram/index.js.map +1 -0
  40. package/dist/agent.d.ts.map +1 -1
  41. package/dist/agent.js +10 -3
  42. package/dist/agent.js.map +1 -1
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js.map +1 -1
  45. package/dist/context.d.ts.map +1 -1
  46. package/dist/context.js +1 -1
  47. package/dist/context.js.map +1 -1
  48. package/dist/download.d.ts.map +1 -1
  49. package/dist/download.js.map +1 -1
  50. package/dist/events.d.ts +4 -4
  51. package/dist/events.d.ts.map +1 -1
  52. package/dist/events.js +12 -15
  53. package/dist/events.js.map +1 -1
  54. package/dist/log.d.ts.map +1 -1
  55. package/dist/log.js.map +1 -1
  56. package/dist/main.d.ts.map +1 -1
  57. package/dist/main.js +55 -36
  58. package/dist/main.js.map +1 -1
  59. package/dist/sandbox.d.ts.map +1 -1
  60. package/dist/sandbox.js +6 -2
  61. package/dist/sandbox.js.map +1 -1
  62. package/dist/store.d.ts.map +1 -1
  63. package/dist/store.js +5 -7
  64. package/dist/store.js.map +1 -1
  65. package/dist/tools/bash.d.ts.map +1 -1
  66. package/dist/tools/bash.js +4 -2
  67. package/dist/tools/bash.js.map +1 -1
  68. package/dist/tools/edit.d.ts.map +1 -1
  69. package/dist/tools/edit.js +3 -1
  70. package/dist/tools/edit.js.map +1 -1
  71. package/dist/tools/index.d.ts.map +1 -1
  72. package/dist/tools/index.js.map +1 -1
  73. package/dist/tools/read.d.ts.map +1 -1
  74. package/dist/tools/read.js +4 -2
  75. package/dist/tools/read.js.map +1 -1
  76. package/dist/tools/truncate.d.ts.map +1 -1
  77. package/dist/tools/truncate.js.map +1 -1
  78. package/dist/tools/write.d.ts.map +1 -1
  79. package/dist/tools/write.js.map +1 -1
  80. package/package.json +65 -55
@@ -0,0 +1,9 @@
1
+ import type { ChatMessage, ChatResponseContext, PlatformInfo } from "../../adapter.js";
2
+ import type { DiscordBot, DiscordEvent } from "./bot.js";
3
+ export declare 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.";
4
+ export declare function createDiscordAdapters(event: DiscordEvent, bot: DiscordBot, isEvent?: boolean): {
5
+ message: ChatMessage;
6
+ responseCtx: ChatResponseContext;
7
+ platform: PlatformInfo;
8
+ };
9
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +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,CA8JA","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 };\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"]}
@@ -0,0 +1,148 @@
1
+ import * as log from "../../log.js";
2
+ export const DISCORD_FORMATTING_GUIDE = `## Discord Formatting (Markdown)
3
+ Bold: **text**, Italic: *text*, Code: \`code\`, Block: \`\`\`language\ncode\`\`\`
4
+ Links: [text](url), Spoiler: ||text||
5
+ Keep messages under 2000 characters. Use code blocks for code.`;
6
+ export function createDiscordAdapters(event, bot, isEvent) {
7
+ let messageId = null;
8
+ let accumulatedText = "";
9
+ let isWorking = true;
10
+ const workingIndicator = " ...";
11
+ let updatePromise = Promise.resolve();
12
+ let typingInterval = null;
13
+ function stopTyping() {
14
+ if (typingInterval !== null) {
15
+ clearInterval(typingInterval);
16
+ typingInterval = null;
17
+ }
18
+ }
19
+ const _eventFilename = isEvent ? event.text.match(/^\[EVENT:([^:]+):/)?.[1] : undefined;
20
+ const isThreaded = !!event.thread_ts;
21
+ const message = {
22
+ id: event.ts,
23
+ sessionKey: `${event.channel}:${event.thread_ts ?? event.ts}`,
24
+ userId: event.user,
25
+ userName: event.userName,
26
+ text: event.text,
27
+ attachments: event.attachments,
28
+ };
29
+ const platform = {
30
+ name: "discord",
31
+ formattingGuide: DISCORD_FORMATTING_GUIDE,
32
+ channels: bot.getAllChannels(),
33
+ users: bot.getAllUsers(),
34
+ };
35
+ // Discord message limit is 2000 chars; use 1900 for safety
36
+ const MAX_LENGTH = 1900;
37
+ const truncationNote = "\n\n*(message truncated, ask me to elaborate on specific parts)*";
38
+ function truncate(text, limit, note) {
39
+ if (text.length > limit) {
40
+ return text.substring(0, limit - note.length) + note;
41
+ }
42
+ return text;
43
+ }
44
+ const responseCtx = {
45
+ respond: async (text) => {
46
+ updatePromise = updatePromise.then(async () => {
47
+ try {
48
+ accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
49
+ const displayText = truncate(isWorking ? accumulatedText + workingIndicator : accumulatedText, MAX_LENGTH, truncationNote);
50
+ if (messageId !== null) {
51
+ await bot.updateMessageRaw(event.channel, messageId, displayText);
52
+ }
53
+ else {
54
+ stopTyping();
55
+ if (isThreaded && event.thread_ts) {
56
+ messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);
57
+ }
58
+ else {
59
+ messageId = await bot.postReply(event.channel, event.ts, displayText);
60
+ }
61
+ }
62
+ if (messageId !== null) {
63
+ bot.logBotResponse(event.channel, text, messageId);
64
+ }
65
+ }
66
+ catch (err) {
67
+ log.logWarning("Discord respond error", err instanceof Error ? err.message : String(err));
68
+ }
69
+ });
70
+ await updatePromise;
71
+ },
72
+ replaceResponse: async (text) => {
73
+ updatePromise = updatePromise.then(async () => {
74
+ try {
75
+ accumulatedText = truncate(text, MAX_LENGTH, truncationNote);
76
+ const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
77
+ if (messageId !== null) {
78
+ await bot.updateMessageRaw(event.channel, messageId, displayText);
79
+ }
80
+ else {
81
+ stopTyping();
82
+ if (isThreaded && event.thread_ts) {
83
+ messageId = await bot.postInThread(event.channel, event.thread_ts, displayText);
84
+ }
85
+ else {
86
+ messageId = await bot.postReply(event.channel, event.ts, displayText);
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ log.logWarning("Discord replaceResponse error", err instanceof Error ? err.message : String(err));
92
+ }
93
+ });
94
+ await updatePromise;
95
+ },
96
+ // Discord threads not used here — discard thread-only messages (e.g. usage summary)
97
+ respondInThread: async (_text) => { },
98
+ setTyping: async (isTyping) => {
99
+ if (isTyping && typingInterval === null) {
100
+ // Send immediately and repeat every 8s (Discord clears indicator after ~10s)
101
+ bot.sendTyping(event.channel).catch(() => { });
102
+ typingInterval = setInterval(() => {
103
+ bot.sendTyping(event.channel).catch(() => { });
104
+ }, 8000);
105
+ }
106
+ else if (!isTyping) {
107
+ stopTyping();
108
+ }
109
+ },
110
+ setWorking: async (working) => {
111
+ updatePromise = updatePromise.then(async () => {
112
+ try {
113
+ isWorking = working;
114
+ if (!working)
115
+ stopTyping();
116
+ if (messageId !== null) {
117
+ const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
118
+ await bot.updateMessageRaw(event.channel, messageId, displayText);
119
+ }
120
+ }
121
+ catch (err) {
122
+ log.logWarning("Discord setWorking error", err instanceof Error ? err.message : String(err));
123
+ }
124
+ });
125
+ await updatePromise;
126
+ },
127
+ uploadFile: async (filePath, title) => {
128
+ await bot.uploadFile(event.channel, filePath, title);
129
+ },
130
+ deleteResponse: async () => {
131
+ updatePromise = updatePromise.then(async () => {
132
+ stopTyping();
133
+ if (messageId !== null) {
134
+ try {
135
+ await bot.deleteMessageRaw(event.channel, messageId);
136
+ }
137
+ catch {
138
+ // Ignore errors
139
+ }
140
+ messageId = null;
141
+ }
142
+ });
143
+ await updatePromise;
144
+ },
145
+ };
146
+ return { message, responseCtx, platform };
147
+ }
148
+ //# sourceMappingURL=context.js.map
@@ -0,0 +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 };\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"]}
@@ -0,0 +1,3 @@
1
+ export * from "./bot.js";
2
+ export * from "./context.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/discord/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -0,0 +1,3 @@
1
+ export * from "./bot.js";
2
+ export * from "./context.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/discord/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC","sourcesContent":["export * from \"./bot.js\";\nexport * from \"./context.js\";\n"]}
@@ -1,3 +1,4 @@
1
+ import type { Bot, BotEvent, BotHandler, PlatformInfo } from "../../adapter.js";
1
2
  import type { Attachment, ChannelStore } from "../../store.js";
2
3
  export interface SlackEvent {
3
4
  type: "mention" | "dm";
@@ -55,25 +56,9 @@ export interface SlackContext {
55
56
  setWorking: (working: boolean) => Promise<void>;
56
57
  deleteMessage: () => Promise<void>;
57
58
  }
58
- export interface MomHandler {
59
- /**
60
- * Check if session is currently running (SYNC).
61
- * sessionKey format: "channelId:rootTs"
62
- */
63
- isRunning(sessionKey: string): boolean;
64
- /**
65
- * Handle an event that triggers mama (ASYNC)
66
- * Called only when isRunning() returned false for user messages.
67
- * Events always queue and pass isEvent=true.
68
- */
69
- handleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;
70
- /**
71
- * Handle stop command (ASYNC)
72
- * Called when user says "stop" while mama is running
73
- */
74
- handleStop(sessionKey: string, channelId: string, slack: SlackBot): Promise<void>;
75
- }
76
- export declare class SlackBot {
59
+ /** @deprecated Use BotHandler from adapter.ts instead */
60
+ export type MomHandler = BotHandler;
61
+ export declare class SlackBot implements Bot {
77
62
  private socketClient;
78
63
  private webClient;
79
64
  private handler;
@@ -84,7 +69,7 @@ export declare class SlackBot {
84
69
  private users;
85
70
  private channels;
86
71
  private queues;
87
- constructor(handler: MomHandler, config: {
72
+ constructor(handler: BotHandler, config: {
88
73
  appToken: string;
89
74
  botToken: string;
90
75
  workingDir: string;
@@ -109,11 +94,12 @@ export declare class SlackBot {
109
94
  * Log a bot response to log.jsonl
110
95
  */
111
96
  logBotResponse(channel: string, text: string, ts: string): void;
97
+ getPlatformInfo(): PlatformInfo;
112
98
  /**
113
99
  * Enqueue an event for processing. Always queues (no "already working" rejection).
114
100
  * Returns true if enqueued, false if queue is full (max 5).
115
101
  */
116
- enqueueEvent(event: SlackEvent): boolean;
102
+ enqueueEvent(event: BotEvent): boolean;
117
103
  private getQueue;
118
104
  private setupEventHandlers;
119
105
  /**
@@ -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 * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.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\tfn: () => Promise<T>,\n\tmaxRetries: number = 3,\n\tbaseDelayMs: number = 1000,\n): Promise<T> {\n\tlet lastError: Error | undefined;\n\tfor (let attempt = 0; attempt < maxRetries; attempt++) {\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} catch (err) {\n\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\n\t\t\t// Check for rate limit errors\n\t\t\tlet isRateLimited = false;\n\n\t\t\t// Check for rate_limited error code (Slack SDK)\n\t\t\tif (\"code\" in lastError && lastError.code === \"rate_limited\") {\n\t\t\t\tisRateLimited = true;\n\t\t\t}\n\n\t\t\t// Check for rate_limited in error response\n\t\t\tif (\"data\" in lastError) {\n\t\t\t\tconst data = (lastError as { data?: { error?: string; response?: { status?: number } } }).data;\n\t\t\t\tif (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n\t\t\t\t\tisRateLimited = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (isRateLimited) {\n\t\t\t\tconst delay = baseDelayMs * Math.pow(2, attempt);\n\t\t\t\tlog.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delay));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Non-retryable error\n\t\t\tthrow lastError;\n\t\t}\n\t}\n\tthrow lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n\ttype: \"mention\" | \"dm\";\n\tchannel: string;\n\tts: string;\n\tthread_ts?: string;\n\tuser: string;\n\ttext: string;\n\tfiles?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n\t/** Processed attachments with local paths (populated after logUserMessage) */\n\tattachments?: Attachment[];\n}\n\nexport interface SlackUser {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackChannel {\n\tid: string;\n\tname: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n\tid: string;\n\tname: string;\n}\n\nexport interface UserInfo {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackContext {\n\tmessage: {\n\t\ttext: string;\n\t\trawText: string;\n\t\tuser: string;\n\t\tuserName?: string;\n\t\tchannel: string;\n\t\tts: string;\n\t\tattachments: Array<{ local: string }>;\n\t};\n\tchannelName?: string;\n\tchannels: ChannelInfo[];\n\tusers: UserInfo[];\n\trespond: (text: string, shouldLog?: boolean) => Promise<void>;\n\treplaceMessage: (text: string) => Promise<void>;\n\trespondInThread: (text: string) => Promise<void>;\n\tsetTyping: (isTyping: boolean) => Promise<void>;\n\tuploadFile: (filePath: string, title?: string) => Promise<void>;\n\tsetWorking: (working: boolean) => Promise<void>;\n\tdeleteMessage: () => Promise<void>;\n}\n\nexport interface MomHandler {\n\t/**\n\t * Check if session is currently running (SYNC).\n\t * sessionKey format: \"channelId:rootTs\"\n\t */\n\tisRunning(sessionKey: string): boolean;\n\n\t/**\n\t * Handle an event that triggers mama (ASYNC)\n\t * Called only when isRunning() returned false for user messages.\n\t * Events always queue and pass isEvent=true.\n\t */\n\thandleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;\n\n\t/**\n\t * Handle stop command (ASYNC)\n\t * Called when user says \"stop\" while mama is running\n\t */\n\thandleStop(sessionKey: string, channelId: string, slack: SlackBot): Promise<void>;\n}\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n\tprivate queue: QueuedWork[] = [];\n\tprivate processing = false;\n\n\tenqueue(work: QueuedWork): void {\n\t\tthis.queue.push(work);\n\t\tthis.processNext();\n\t}\n\n\tsize(): number {\n\t\treturn this.queue.length;\n\t}\n\n\tprivate async processNext(): Promise<void> {\n\t\tif (this.processing || this.queue.length === 0) return;\n\t\tthis.processing = true;\n\t\tconst work = this.queue.shift()!;\n\t\ttry {\n\t\t\tawait work();\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n\t\t}\n\t\tthis.processing = false;\n\t\tthis.processNext();\n\t}\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot {\n\tprivate socketClient: SocketModeClient;\n\tprivate webClient: WebClient;\n\tprivate handler: MomHandler;\n\tprivate workingDir: string;\n\tprivate store: ChannelStore;\n\tprivate botUserId: string | null = null;\n\tprivate startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n\tprivate users = new Map<string, SlackUser>();\n\tprivate channels = new Map<string, SlackChannel>();\n\tprivate queues = new Map<string, ChannelQueue>();\n\n\tconstructor(\n\t\thandler: MomHandler,\n\t\tconfig: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n\t) {\n\t\tthis.handler = handler;\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.store = config.store;\n\t\tthis.socketClient = new SocketModeClient({ appToken: config.appToken });\n\t\tthis.webClient = new WebClient(config.botToken);\n\t}\n\n\t// ==========================================================================\n\t// Public API\n\t// ==========================================================================\n\n\tasync start(): Promise<void> {\n\t\tconst auth = await this.webClient.auth.test();\n\t\tthis.botUserId = auth.user_id as string;\n\n\t\tawait Promise.all([this.fetchUsers(), this.fetchChannels()]);\n\t\tlog.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n\t\tawait this.backfillAllChannels();\n\n\t\tthis.setupEventHandlers();\n\t\tawait this.socketClient.start();\n\n\t\t// Record startup time - messages older than this are just logged, not processed\n\t\tthis.startupTs = (Date.now() / 1000).toFixed(6);\n\n\t\tlog.logConnected();\n\t}\n\n\tgetUser(userId: string): SlackUser | undefined {\n\t\treturn this.users.get(userId);\n\t}\n\n\tgetChannel(channelId: string): SlackChannel | undefined {\n\t\treturn this.channels.get(channelId);\n\t}\n\n\tgetAllUsers(): SlackUser[] {\n\t\treturn Array.from(this.users.values());\n\t}\n\n\tgetAllChannels(): SlackChannel[] {\n\t\treturn Array.from(this.channels.values());\n\t}\n\n\tasync postMessage(channel: string, text: string): Promise<string> {\n\t\treturn withRetry(async () => {\n\t\t\tconst result = await this.webClient.chat.postMessage({ channel, text });\n\t\t\treturn result.ts as string;\n\t\t});\n\t}\n\n\tasync updateMessage(channel: string, ts: string, text: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tawait this.webClient.chat.update({ channel, ts, text });\n\t\t});\n\t}\n\n\tasync deleteMessage(channel: string, ts: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tawait this.webClient.chat.delete({ channel, ts });\n\t\t});\n\t}\n\n\tasync postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n\t\treturn withRetry(async () => {\n\t\t\tconst result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n\t\t\treturn result.ts as string;\n\t\t});\n\t}\n\n\tasync uploadFile(channel: string, filePath: string, title?: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tconst fileName = title || basename(filePath);\n\t\t\tconst fileContent = readFileSync(filePath);\n\t\t\tawait this.webClient.files.uploadV2({\n\t\t\t\tchannel_id: channel,\n\t\t\t\tfile: fileContent,\n\t\t\t\tfilename: fileName,\n\t\t\t\ttitle: fileName,\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Log a message to log.jsonl (SYNC)\n\t * This is the ONLY place messages are written to log.jsonl\n\t */\n\tlogToFile(channel: string, entry: object): void {\n\t\tconst dir = join(this.workingDir, channel);\n\t\tif (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\t\tappendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n\t}\n\n\t/**\n\t * Log a bot response to log.jsonl\n\t */\n\tlogBotResponse(channel: string, text: string, ts: string): void {\n\t\tthis.logToFile(channel, {\n\t\t\tdate: new Date().toISOString(),\n\t\t\tts,\n\t\t\tuser: \"bot\",\n\t\t\ttext,\n\t\t\tattachments: [],\n\t\t\tisBot: true,\n\t\t});\n\t}\n\n\t// ==========================================================================\n\t// Events Integration\n\t// ==========================================================================\n\n\t/**\n\t * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n\t * Returns true if enqueued, false if queue is full (max 5).\n\t */\n\tenqueueEvent(event: SlackEvent): boolean {\n\t\tconst queue = this.getQueue(event.channel);\n\t\tif (queue.size() >= 5) {\n\t\t\tlog.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);\n\t\t\treturn false;\n\t\t}\n\t\tlog.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);\n\t\tqueue.enqueue(() => this.handler.handleEvent(event, this, true));\n\t\treturn true;\n\t}\n\n\t// ==========================================================================\n\t// Private - Event Handlers\n\t// ==========================================================================\n\n\tprivate getQueue(channelId: string): ChannelQueue {\n\t\tlet queue = this.queues.get(channelId);\n\t\tif (!queue) {\n\t\t\tqueue = new ChannelQueue();\n\t\t\tthis.queues.set(channelId, queue);\n\t\t}\n\t\treturn queue;\n\t}\n\n\tprivate setupEventHandlers(): void {\n\t\t// Channel @mentions\n\t\tthis.socketClient.on(\"app_mention\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser: string;\n\t\t\t\tts: string;\n\t\t\t\tthread_ts?: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip DMs (handled by message event)\n\t\t\tif (e.channel.startsWith(\"D\")) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Derive session key from thread context\n\t\t\tconst rootTs = e.thread_ts ?? e.ts;\n\t\t\tconst sessionKey = `${e.channel}:${rootTs}`;\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tthread_ts: e.thread_ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(\n\t\t\t\t\t`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n\t\t\t\t);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\tif (this.handler.isRunning(sessionKey)) {\n\t\t\t\t\tthis.handler.handleStop(sessionKey, e.channel, this); // Don't await, don't queue\n\t\t\t\t} else {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t}\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// SYNC: Check if busy (per-thread)\n\t\t\tif (this.handler.isRunning(sessionKey)) {\n\t\t\t\tthis.postMessage(e.channel, \"_Already working in this thread. Say `@mama stop` to cancel._\");\n\t\t\t} else {\n\t\t\t\tthis.getQueue(sessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\n\t\t// All messages (for logging) + DMs (for triggering)\n\t\tthis.socketClient.on(\"message\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext?: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser?: string;\n\t\t\t\tts: string;\n\t\t\t\tthread_ts?: string;\n\t\t\t\tchannel_type?: string;\n\t\t\t\tsubtype?: string;\n\t\t\t\tbot_id?: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip bot messages, edits, etc.\n\t\t\tif (e.bot_id || !e.user || e.user === this.botUserId) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (e.subtype !== undefined && e.subtype !== \"file_share\") {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!e.text && (!e.files || e.files.length === 0)) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst isDM = e.channel_type === \"im\";\n\t\t\tconst isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n\t\t\t// Skip channel @mentions - already handled by app_mention event\n\t\t\tif (!isDM && isBotMention) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: isDM ? \"dm\" : \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tthread_ts: e.thread_ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Only trigger handler for DMs\n\t\t\tif (isDM) {\n\t\t\t\tconst dmRootTs = e.thread_ts ?? e.ts;\n\t\t\t\tconst dmSessionKey = `${e.channel}:${dmRootTs}`;\n\n\t\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\t\tif (this.handler.isRunning(dmSessionKey)) {\n\t\t\t\t\t\tthis.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t\t}\n\t\t\t\t\tack();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (this.handler.isRunning(dmSessionKey)) {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Already working. Say `stop` to cancel._\");\n\t\t\t\t} else {\n\t\t\t\t\tthis.getQueue(dmSessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\t}\n\n\t/**\n\t * Log a user message to log.jsonl (SYNC)\n\t * Downloads attachments in background via store\n\t */\n\tprivate logUserMessage(event: SlackEvent): Attachment[] {\n\t\tconst user = this.users.get(event.user);\n\t\t// Process attachments - queues downloads in background\n\t\tconst attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];\n\t\tthis.logToFile(event.channel, {\n\t\t\tdate: new Date(parseFloat(event.ts) * 1000).toISOString(),\n\t\t\tts: event.ts,\n\t\t\tuser: event.user,\n\t\t\tuserName: user?.userName,\n\t\t\tdisplayName: user?.displayName,\n\t\t\ttext: event.text,\n\t\t\tattachments,\n\t\t\tisBot: false,\n\t\t});\n\t\treturn attachments;\n\t}\n\n\t// ==========================================================================\n\t// Private - Backfill\n\t// ==========================================================================\n\n\tprivate async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\tconst timestamps = new Set<string>();\n\t\tif (!existsSync(logPath)) return timestamps;\n\n\t\tconst content = await readFile(logPath, \"utf-8\");\n\t\tconst lines = content.trim().split(\"\\n\").filter(Boolean);\n\t\tfor (const line of lines) {\n\t\t\ttry {\n\t\t\t\tconst entry = JSON.parse(line);\n\t\t\t\tif (entry.ts) timestamps.add(entry.ts);\n\t\t\t} catch {}\n\t\t}\n\t\treturn timestamps;\n\t}\n\n\tprivate async backfillChannel(channelId: string): Promise<number> {\n\t\tconst existingTs = await this.getExistingTimestamps(channelId);\n\n\t\t// Find the biggest ts in log.jsonl\n\t\tlet latestTs: string | undefined;\n\t\tfor (const ts of existingTs) {\n\t\t\tif (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n\t\t}\n\n\t\ttype Message = {\n\t\t\tuser?: string;\n\t\t\tbot_id?: string;\n\t\t\ttext?: string;\n\t\t\tts?: string;\n\t\t\tsubtype?: string;\n\t\t\tfiles?: Array<{ name: string }>;\n\t\t};\n\t\tconst allMessages: Message[] = [];\n\n\t\tlet cursor: string | undefined;\n\t\tlet pageCount = 0;\n\t\tconst maxPages = 3;\n\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.history({\n\t\t\t\tchannel: channelId,\n\t\t\t\toldest: latestTs, // Only fetch messages newer than what we have\n\t\t\t\tinclusive: false,\n\t\t\t\tlimit: 1000,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tif (result.messages) {\n\t\t\t\tallMessages.push(...(result.messages as Message[]));\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t\tpageCount++;\n\t\t} while (cursor && pageCount < maxPages);\n\n\t\t// Filter: include mama's messages, exclude other bots, skip already logged\n\t\tconst relevantMessages = allMessages.filter((msg) => {\n\t\t\tif (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n\t\t\tif (msg.user === this.botUserId) return true;\n\t\t\tif (msg.bot_id) return false;\n\t\t\tif (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n\t\t\tif (!msg.user) return false;\n\t\t\tif (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n\t\t\treturn true;\n\t\t});\n\n\t\t// Reverse to chronological order\n\t\trelevantMessages.reverse();\n\n\t\t// Log each message to log.jsonl\n\t\tfor (const msg of relevantMessages) {\n\t\t\tconst isMamaMessage = msg.user === this.botUserId;\n\t\t\tconst user = this.users.get(msg.user!);\n\t\t\t// Strip @mentions from text (same as live messages)\n\t\t\tconst text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n\t\t\t// Process attachments - queues downloads in background\n\t\t\tconst attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];\n\n\t\t\tthis.logToFile(channelId, {\n\t\t\t\tdate: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n\t\t\t\tts: msg.ts!,\n\t\t\t\tuser: isMamaMessage ? \"bot\" : msg.user!,\n\t\t\t\tuserName: isMamaMessage ? undefined : user?.userName,\n\t\t\t\tdisplayName: isMamaMessage ? undefined : user?.displayName,\n\t\t\t\ttext,\n\t\t\t\tattachments,\n\t\t\t\tisBot: isMamaMessage,\n\t\t\t});\n\t\t}\n\n\t\treturn relevantMessages.length;\n\t}\n\n\tprivate async backfillAllChannels(): Promise<void> {\n\t\tconst startTime = Date.now();\n\n\t\t// Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n\t\tconst channelsToBackfill: Array<[string, SlackChannel]> = [];\n\t\tfor (const [channelId, channel] of this.channels) {\n\t\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\t\tif (existsSync(logPath)) {\n\t\t\t\tchannelsToBackfill.push([channelId, channel]);\n\t\t\t}\n\t\t}\n\n\t\tlog.logBackfillStart(channelsToBackfill.length);\n\n\t\tlet totalMessages = 0;\n\t\tfor (const [channelId, channel] of channelsToBackfill) {\n\t\t\ttry {\n\t\t\t\tconst count = await this.backfillChannel(channelId);\n\t\t\t\tif (count > 0) log.logBackfillChannel(channel.name, count);\n\t\t\t\ttotalMessages += count;\n\t\t\t} catch (error) {\n\t\t\t\tlog.logWarning(`Failed to backfill #${channel.name}`, String(error));\n\t\t\t}\n\n\t\t\t// Add delay between channels to avoid hitting Slack rate limits\n\t\t\tif (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 500));\n\t\t\t}\n\t\t}\n\n\t\tconst durationMs = Date.now() - startTime;\n\t\tlog.logBackfillComplete(totalMessages, durationMs);\n\t}\n\n\t// ==========================================================================\n\t// Private - Fetch Users/Channels\n\t// ==========================================================================\n\n\tprivate async fetchUsers(): Promise<void> {\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.users.list({ limit: 200, cursor });\n\t\t\tconst members = result.members as\n\t\t\t\t| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n\t\t\t\t| undefined;\n\t\t\tif (members) {\n\t\t\t\tfor (const u of members) {\n\t\t\t\t\tif (u.id && u.name && !u.deleted) {\n\t\t\t\t\t\tthis.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\n\n\tprivate async fetchChannels(): Promise<void> {\n\t\t// Fetch public/private channels\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"public_channel,private_channel\",\n\t\t\t\texclude_archived: true,\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;\n\t\t\tif (channels) {\n\t\t\t\tfor (const c of channels) {\n\t\t\t\t\tif (c.id && c.name && c.is_member) {\n\t\t\t\t\t\tthis.channels.set(c.id, { id: c.id, name: c.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\n\t\t// Also fetch DM channels (IMs)\n\t\tcursor = undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"im\",\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n\t\t\tif (ims) {\n\t\t\t\tfor (const im of ims) {\n\t\t\t\t\tif (im.id) {\n\t\t\t\t\t\t// Use user's name as channel name for DMs\n\t\t\t\t\t\tconst user = im.user ? this.users.get(im.user) : undefined;\n\t\t\t\t\t\tconst name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n\t\t\t\t\t\tthis.channels.set(im.id, { id: im.id, name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\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 * 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}\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\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 // ==========================================================================\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 async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async uploadFile(channel: string, filePath: string, title?: string): 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 });\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): void {\n this.logToFile(channel, {\n date: new Date().toISOString(),\n ts,\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 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 // Derive session key from thread context\n const rootTs = e.thread_ts ?? e.ts;\n const sessionKey = `${e.channel}:${rootTs}`;\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 };\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 if (this.handler.isRunning(sessionKey)) {\n this.handler.handleStop(sessionKey, 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 // 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 };\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 // Only trigger handler for DMs\n if (isDM) {\n const dmRootTs = e.thread_ts ?? e.ts;\n const dmSessionKey = `${e.channel}:${dmRootTs}`;\n\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\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 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"]}
@@ -4,6 +4,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
4
4
  import { readFile } from "fs/promises";
5
5
  import { basename, join } from "path";
6
6
  import * as log from "../../log.js";
7
+ import { createSlackAdapters } from "./context.js";
7
8
  // ============================================================================
8
9
  // Exponential backoff utility for Slack API calls
9
10
  // ============================================================================
@@ -26,7 +27,8 @@ async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
26
27
  }
27
28
  // Check for rate_limited in error response
28
29
  if ("data" in lastError) {
29
- const data = lastError.data;
30
+ const data = lastError
31
+ .data;
30
32
  if (data?.error === "rate_limited" || data?.response?.status === 429) {
31
33
  isRateLimited = true;
32
34
  }
@@ -44,8 +46,10 @@ async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
44
46
  throw lastError;
45
47
  }
46
48
  class ChannelQueue {
47
- queue = [];
48
- processing = false;
49
+ constructor() {
50
+ this.queue = [];
51
+ this.processing = false;
52
+ }
49
53
  enqueue(work) {
50
54
  this.queue.push(work);
51
55
  this.processNext();
@@ -72,17 +76,12 @@ class ChannelQueue {
72
76
  // SlackBot
73
77
  // ============================================================================
74
78
  export class SlackBot {
75
- socketClient;
76
- webClient;
77
- handler;
78
- workingDir;
79
- store;
80
- botUserId = null;
81
- startupTs = null; // Messages older than this are just logged, not processed
82
- users = new Map();
83
- channels = new Map();
84
- queues = new Map();
85
79
  constructor(handler, config) {
80
+ this.botUserId = null;
81
+ this.startupTs = null; // Messages older than this are just logged, not processed
82
+ this.users = new Map();
83
+ this.channels = new Map();
84
+ this.queues = new Map();
86
85
  this.handler = handler;
87
86
  this.workingDir = config.workingDir;
88
87
  this.store = config.store;
@@ -173,6 +172,18 @@ export class SlackBot {
173
172
  isBot: true,
174
173
  });
175
174
  }
175
+ getPlatformInfo() {
176
+ return {
177
+ name: "slack",
178
+ formattingGuide: "## Slack Formatting (mrkdwn, NOT Markdown)\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\nDo NOT use **double asterisks** or [markdown](links).",
179
+ channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),
180
+ users: this.getAllUsers().map((u) => ({
181
+ id: u.id,
182
+ userName: u.userName,
183
+ displayName: u.displayName,
184
+ })),
185
+ };
186
+ }
176
187
  // ==========================================================================
177
188
  // Events Integration
178
189
  // ==========================================================================
@@ -187,7 +198,10 @@ export class SlackBot {
187
198
  return false;
188
199
  }
189
200
  log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);
190
- queue.enqueue(() => this.handler.handleEvent(event, this, true));
201
+ queue.enqueue(() => {
202
+ const adapters = createSlackAdapters(event, this, true);
203
+ return this.handler.handleEvent(event, this, adapters, true);
204
+ });
191
205
  return true;
192
206
  }
193
207
  // ==========================================================================
@@ -247,7 +261,10 @@ export class SlackBot {
247
261
  this.postMessage(e.channel, "_Already working in this thread. Say `@mama stop` to cancel._");
248
262
  }
249
263
  else {
250
- this.getQueue(sessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));
264
+ this.getQueue(sessionKey).enqueue(() => {
265
+ const adapters = createSlackAdapters(slackEvent, this, false);
266
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
267
+ });
251
268
  }
252
269
  ack();
253
270
  });
@@ -311,7 +328,10 @@ export class SlackBot {
311
328
  this.postMessage(e.channel, "_Already working. Say `stop` to cancel._");
312
329
  }
313
330
  else {
314
- this.getQueue(dmSessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));
331
+ this.getQueue(dmSessionKey).enqueue(() => {
332
+ const adapters = createSlackAdapters(slackEvent, this, false);
333
+ return this.handler.handleEvent(slackEvent, this, adapters, false);
334
+ });
315
335
  }
316
336
  }
317
337
  ack();
@@ -324,7 +344,9 @@ export class SlackBot {
324
344
  logUserMessage(event) {
325
345
  const user = this.users.get(event.user);
326
346
  // Process attachments - queues downloads in background
327
- const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
347
+ const attachments = event.files
348
+ ? this.store.processAttachments(event.channel, event.files, event.ts)
349
+ : [];
328
350
  this.logToFile(event.channel, {
329
351
  date: new Date(parseFloat(event.ts) * 1000).toISOString(),
330
352
  ts: event.ts,
@@ -408,7 +430,9 @@ export class SlackBot {
408
430
  // Strip @mentions from text (same as live messages)
409
431
  const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
410
432
  // Process attachments - queues downloads in background
411
- const attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts) : [];
433
+ const attachments = msg.files
434
+ ? this.store.processAttachments(channelId, msg.files, msg.ts)
435
+ : [];
412
436
  this.logToFile(channelId, {
413
437
  date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
414
438
  ts: msg.ts,
@@ -463,7 +487,11 @@ export class SlackBot {
463
487
  if (members) {
464
488
  for (const u of members) {
465
489
  if (u.id && u.name && !u.deleted) {
466
- this.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });
490
+ this.users.set(u.id, {
491
+ id: u.id,
492
+ userName: u.name,
493
+ displayName: u.real_name || u.name,
494
+ });
467
495
  }
468
496
  }
469
497
  }