@mariozechner/pi-mom 0.18.2 → 0.18.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.
- package/CHANGELOG.md +31 -11
- package/README.md +30 -18
- package/dist/agent.d.ts +14 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +294 -331
- package/dist/agent.js.map +1 -1
- package/dist/context.d.ts +132 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +538 -0
- package/dist/context.js.map +1 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +14 -1
- package/dist/log.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +166 -90
- package/dist/main.js.map +1 -1
- package/dist/slack.d.ts +86 -55
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +322 -418
- package/dist/slack.js.map +1 -1
- package/package.json +4 -3
package/dist/slack.d.ts
CHANGED
|
@@ -1,42 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
text: string;
|
|
4
|
-
rawText: string;
|
|
5
|
-
user: string;
|
|
6
|
-
userName?: string;
|
|
1
|
+
export interface SlackEvent {
|
|
2
|
+
type: "mention" | "dm";
|
|
7
3
|
channel: string;
|
|
8
4
|
ts: string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
channels: ChannelInfo[];
|
|
17
|
-
/** All known users in the workspace */
|
|
18
|
-
users: UserInfo[];
|
|
19
|
-
/** Send/update the main message (accumulates text). Set log=false to skip logging. */
|
|
20
|
-
respond(text: string, shouldLog?: boolean): Promise<void>;
|
|
21
|
-
/** Replace the entire message text (not append) */
|
|
22
|
-
replaceMessage(text: string): Promise<void>;
|
|
23
|
-
/** Post a message in the thread under the main message (for verbose details) */
|
|
24
|
-
respondInThread(text: string): Promise<void>;
|
|
25
|
-
/** Show/hide typing indicator */
|
|
26
|
-
setTyping(isTyping: boolean): Promise<void>;
|
|
27
|
-
/** Upload a file to the channel */
|
|
28
|
-
uploadFile(filePath: string, title?: string): Promise<void>;
|
|
29
|
-
/** Set working state (adds/removes working indicator emoji) */
|
|
30
|
-
setWorking(working: boolean): Promise<void>;
|
|
5
|
+
user: string;
|
|
6
|
+
text: string;
|
|
7
|
+
files?: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
url_private_download?: string;
|
|
10
|
+
url_private?: string;
|
|
11
|
+
}>;
|
|
31
12
|
}
|
|
32
|
-
export interface
|
|
33
|
-
|
|
34
|
-
|
|
13
|
+
export interface SlackUser {
|
|
14
|
+
id: string;
|
|
15
|
+
userName: string;
|
|
16
|
+
displayName: string;
|
|
35
17
|
}
|
|
36
|
-
export interface
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
workingDir: string;
|
|
18
|
+
export interface SlackChannel {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
40
21
|
}
|
|
41
22
|
export interface ChannelInfo {
|
|
42
23
|
id: string;
|
|
@@ -47,37 +28,87 @@ export interface UserInfo {
|
|
|
47
28
|
userName: string;
|
|
48
29
|
displayName: string;
|
|
49
30
|
}
|
|
50
|
-
export
|
|
31
|
+
export interface SlackContext {
|
|
32
|
+
message: {
|
|
33
|
+
text: string;
|
|
34
|
+
rawText: string;
|
|
35
|
+
user: string;
|
|
36
|
+
userName?: string;
|
|
37
|
+
channel: string;
|
|
38
|
+
ts: string;
|
|
39
|
+
attachments: Array<{
|
|
40
|
+
local: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
channelName?: string;
|
|
44
|
+
channels: ChannelInfo[];
|
|
45
|
+
users: UserInfo[];
|
|
46
|
+
respond: (text: string, shouldLog?: boolean) => Promise<void>;
|
|
47
|
+
replaceMessage: (text: string) => Promise<void>;
|
|
48
|
+
respondInThread: (text: string) => Promise<void>;
|
|
49
|
+
setTyping: (isTyping: boolean) => Promise<void>;
|
|
50
|
+
uploadFile: (filePath: string, title?: string) => Promise<void>;
|
|
51
|
+
setWorking: (working: boolean) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export interface MomHandler {
|
|
54
|
+
/**
|
|
55
|
+
* Check if channel is currently running (SYNC)
|
|
56
|
+
*/
|
|
57
|
+
isRunning(channelId: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Handle an event that triggers mom (ASYNC)
|
|
60
|
+
* Called only when isRunning() returned false
|
|
61
|
+
*/
|
|
62
|
+
handleEvent(event: SlackEvent, slack: SlackBot): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Handle stop command (ASYNC)
|
|
65
|
+
* Called when user says "stop" while mom is running
|
|
66
|
+
*/
|
|
67
|
+
handleStop(channelId: string, slack: SlackBot): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export declare class SlackBot {
|
|
51
70
|
private socketClient;
|
|
52
71
|
private webClient;
|
|
53
72
|
private handler;
|
|
73
|
+
private workingDir;
|
|
54
74
|
private botUserId;
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
private startupTs;
|
|
76
|
+
private users;
|
|
77
|
+
private channels;
|
|
78
|
+
private queues;
|
|
79
|
+
constructor(handler: MomHandler, config: {
|
|
80
|
+
appToken: string;
|
|
81
|
+
botToken: string;
|
|
82
|
+
workingDir: string;
|
|
83
|
+
});
|
|
84
|
+
start(): Promise<void>;
|
|
85
|
+
getUser(userId: string): SlackUser | undefined;
|
|
86
|
+
getChannel(channelId: string): SlackChannel | undefined;
|
|
87
|
+
getAllUsers(): SlackUser[];
|
|
88
|
+
getAllChannels(): SlackChannel[];
|
|
89
|
+
postMessage(channel: string, text: string): Promise<string>;
|
|
90
|
+
updateMessage(channel: string, ts: string, text: string): Promise<void>;
|
|
91
|
+
postInThread(channel: string, threadTs: string, text: string): Promise<void>;
|
|
92
|
+
uploadFile(channel: string, filePath: string, title?: string): Promise<void>;
|
|
61
93
|
/**
|
|
62
|
-
*
|
|
94
|
+
* Log a message to log.jsonl (SYNC)
|
|
95
|
+
* This is the ONLY place messages are written to log.jsonl
|
|
63
96
|
*/
|
|
64
|
-
|
|
97
|
+
logToFile(channel: string, entry: object): void;
|
|
65
98
|
/**
|
|
66
|
-
*
|
|
99
|
+
* Log a bot response to log.jsonl
|
|
67
100
|
*/
|
|
68
|
-
|
|
101
|
+
logBotResponse(channel: string, text: string, ts: string): void;
|
|
102
|
+
private getQueue;
|
|
103
|
+
private setupEventHandlers;
|
|
69
104
|
/**
|
|
70
|
-
*
|
|
71
|
-
* e.g., "nate" -> "n_a_t_e", "@mario" -> "@m_a_r_i_o", "<@U123>" -> "<@U_1_2_3>"
|
|
105
|
+
* Log a user message to log.jsonl (SYNC)
|
|
72
106
|
*/
|
|
73
|
-
private
|
|
74
|
-
private
|
|
75
|
-
private setupEventHandlers;
|
|
76
|
-
private logMessage;
|
|
77
|
-
private createContext;
|
|
107
|
+
private logUserMessage;
|
|
108
|
+
private getExistingTimestamps;
|
|
78
109
|
private backfillChannel;
|
|
79
110
|
private backfillAllChannels;
|
|
80
|
-
|
|
81
|
-
|
|
111
|
+
private fetchUsers;
|
|
112
|
+
private fetchChannels;
|
|
82
113
|
}
|
|
83
114
|
//# sourceMappingURL=slack.d.ts.map
|
package/dist/slack.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../src/slack.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,UAAU,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC5B,OAAO,EAAE,YAAY,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,0CAA0C;IAC1C,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,uCAAuC;IACvC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,sFAAsF;IACtF,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,mDAAmD;IACnD,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,gFAAgF;IAChF,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,iCAAiC;IACjC,SAAS,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,mCAAmC;IACnC,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,+DAA+D;IAC/D,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,UAAU;IAC1B,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,eAAe,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,YAAY;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,QAAQ;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,MAAM;IAClB,OAAO,CAAC,YAAY,CAAmB;IACvC,OAAO,CAAC,SAAS,CAAY;IAC7B,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,SAAS,CAAuB;IACxC,SAAgB,KAAK,EAAE,YAAY,CAAC;IACpC,OAAO,CAAC,SAAS,CAAqE;IACtF,OAAO,CAAC,YAAY,CAAkC;IAEtD,YAAY,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAUpD;YAKa,aAAa;YA8Bb,UAAU;IA8BxB;;OAEG;IACH,WAAW,IAAI,WAAW,EAAE,CAE3B;IAED;;OAEG;IACH,QAAQ,IAAI,QAAQ,EAAE,CAMrB;IAED;;;OAGG;IACH,OAAO,CAAC,kBAAkB;YAsBZ,WAAW;IAmBzB,OAAO,CAAC,kBAAkB;YA2EZ,UAAU;YAsBV,aAAa;YAqNb,eAAe;YAiFf,mBAAmB;IAsB3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAa3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAG1B;CACD","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { type ConversationsHistoryResponse, WebClient } from \"@slack/web-api\";\nimport { readFileSync } from \"fs\";\nimport { basename } from \"path\";\nimport * as log from \"./log.js\";\nimport { type Attachment, ChannelStore } from \"./store.js\";\n\nexport interface SlackMessage {\n\ttext: string; // message content (mentions stripped)\n\trawText: string; // original text with mentions\n\tuser: string; // user ID\n\tuserName?: string; // user handle\n\tchannel: string; // channel ID\n\tts: string; // timestamp (for threading)\n\tattachments: Attachment[]; // file attachments\n}\n\nexport interface SlackContext {\n\tmessage: SlackMessage;\n\tchannelName?: string; // channel name for logging (e.g., #dev-team)\n\tstore: ChannelStore;\n\t/** All channels the bot is a member of */\n\tchannels: ChannelInfo[];\n\t/** All known users in the workspace */\n\tusers: UserInfo[];\n\t/** Send/update the main message (accumulates text). Set log=false to skip logging. */\n\trespond(text: string, shouldLog?: boolean): Promise<void>;\n\t/** Replace the entire message text (not append) */\n\treplaceMessage(text: string): Promise<void>;\n\t/** Post a message in the thread under the main message (for verbose details) */\n\trespondInThread(text: string): Promise<void>;\n\t/** Show/hide typing indicator */\n\tsetTyping(isTyping: boolean): Promise<void>;\n\t/** Upload a file to the channel */\n\tuploadFile(filePath: string, title?: string): Promise<void>;\n\t/** Set working state (adds/removes working indicator emoji) */\n\tsetWorking(working: boolean): Promise<void>;\n}\n\nexport interface MomHandler {\n\tonChannelMention(ctx: SlackContext): Promise<void>;\n\tonDirectMessage(ctx: SlackContext): Promise<void>;\n}\n\nexport interface MomBotConfig {\n\tappToken: string;\n\tbotToken: string;\n\tworkingDir: string; // directory for channel data and attachments\n}\n\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 class MomBot {\n\tprivate socketClient: SocketModeClient;\n\tprivate webClient: WebClient;\n\tprivate handler: MomHandler;\n\tprivate botUserId: string | null = null;\n\tpublic readonly store: ChannelStore;\n\tprivate userCache: Map<string, { userName: string; displayName: string }> = new Map();\n\tprivate channelCache: Map<string, string> = new Map(); // id -> name\n\n\tconstructor(handler: MomHandler, config: MomBotConfig) {\n\t\tthis.handler = handler;\n\t\tthis.socketClient = new SocketModeClient({ appToken: config.appToken });\n\t\tthis.webClient = new WebClient(config.botToken);\n\t\tthis.store = new ChannelStore({\n\t\t\tworkingDir: config.workingDir,\n\t\t\tbotToken: config.botToken,\n\t\t});\n\n\t\tthis.setupEventHandlers();\n\t}\n\n\t/**\n\t * Fetch all channels the bot is a member of\n\t */\n\tprivate async fetchChannels(): Promise<void> {\n\t\ttry {\n\t\t\tlet cursor: string | undefined;\n\t\t\tdo {\n\t\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\t\ttypes: \"public_channel,private_channel\",\n\t\t\t\t\texclude_archived: true,\n\t\t\t\t\tlimit: 200,\n\t\t\t\t\tcursor,\n\t\t\t\t});\n\n\t\t\t\tconst channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;\n\t\t\t\tif (channels) {\n\t\t\t\t\tfor (const channel of channels) {\n\t\t\t\t\t\tif (channel.id && channel.name && channel.is_member) {\n\t\t\t\t\t\t\tthis.channelCache.set(channel.id, channel.name);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t\t} while (cursor);\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to fetch channels\", String(error));\n\t\t}\n\t}\n\n\t/**\n\t * Fetch all workspace users\n\t */\n\tprivate async fetchUsers(): Promise<void> {\n\t\ttry {\n\t\t\tlet cursor: string | undefined;\n\t\t\tdo {\n\t\t\t\tconst result = await this.webClient.users.list({\n\t\t\t\t\tlimit: 200,\n\t\t\t\t\tcursor,\n\t\t\t\t});\n\n\t\t\t\tconst members = result.members as\n\t\t\t\t\t| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n\t\t\t\t\t| undefined;\n\t\t\t\tif (members) {\n\t\t\t\t\tfor (const user of members) {\n\t\t\t\t\t\tif (user.id && user.name && !user.deleted) {\n\t\t\t\t\t\t\tthis.userCache.set(user.id, {\n\t\t\t\t\t\t\t\tuserName: user.name,\n\t\t\t\t\t\t\t\tdisplayName: user.real_name || user.name,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t\t} while (cursor);\n\t\t} catch (error) {\n\t\t\tlog.logWarning(\"Failed to fetch users\", String(error));\n\t\t}\n\t}\n\n\t/**\n\t * Get all known channels (id -> name)\n\t */\n\tgetChannels(): ChannelInfo[] {\n\t\treturn Array.from(this.channelCache.entries()).map(([id, name]) => ({ id, name }));\n\t}\n\n\t/**\n\t * Get all known users\n\t */\n\tgetUsers(): UserInfo[] {\n\t\treturn Array.from(this.userCache.entries()).map(([id, { userName, displayName }]) => ({\n\t\t\tid,\n\t\t\tuserName,\n\t\t\tdisplayName,\n\t\t}));\n\t}\n\n\t/**\n\t * Obfuscate usernames and user IDs in text to prevent pinging people\n\t * e.g., \"nate\" -> \"n_a_t_e\", \"@mario\" -> \"@m_a_r_i_o\", \"<@U123>\" -> \"<@U_1_2_3>\"\n\t */\n\tprivate obfuscateUsernames(text: string): string {\n\t\tlet result = text;\n\n\t\t// Obfuscate user IDs like <@U16LAL8LS>\n\t\tresult = result.replace(/<@([A-Z0-9]+)>/gi, (_match, id) => {\n\t\t\treturn `<@${id.split(\"\").join(\"_\")}>`;\n\t\t});\n\n\t\t// Obfuscate usernames\n\t\tfor (const { userName } of this.userCache.values()) {\n\t\t\t// Escape special regex characters in username\n\t\t\tconst escaped = userName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\t// Match @username, <@username>, or bare username (case insensitive, word boundary)\n\t\t\tconst pattern = new RegExp(`(<@|@)?(\\\\b${escaped}\\\\b)`, \"gi\");\n\t\t\tresult = result.replace(pattern, (_match, prefix, name) => {\n\t\t\t\tconst obfuscated = name.split(\"\").join(\"_\");\n\t\t\t\treturn (prefix || \"\") + obfuscated;\n\t\t\t});\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate async getUserInfo(userId: string): Promise<{ userName: string; displayName: string }> {\n\t\tif (this.userCache.has(userId)) {\n\t\t\treturn this.userCache.get(userId)!;\n\t\t}\n\n\t\ttry {\n\t\t\tconst result = await this.webClient.users.info({ user: userId });\n\t\t\tconst user = result.user as { name?: string; real_name?: string };\n\t\t\tconst info = {\n\t\t\t\tuserName: user?.name || userId,\n\t\t\t\tdisplayName: user?.real_name || user?.name || userId,\n\t\t\t};\n\t\t\tthis.userCache.set(userId, info);\n\t\t\treturn info;\n\t\t} catch {\n\t\t\treturn { userName: userId, displayName: userId };\n\t\t}\n\t}\n\n\tprivate setupEventHandlers(): void {\n\t\t// Handle @mentions in channels\n\t\tthis.socketClient.on(\"app_mention\", async ({ event, ack }) => {\n\t\t\tawait ack();\n\n\t\t\tconst slackEvent = 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\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Log the mention message (message event may not fire for all channel types)\n\t\t\tawait this.logMessage({\n\t\t\t\ttext: slackEvent.text,\n\t\t\t\tchannel: slackEvent.channel,\n\t\t\t\tuser: slackEvent.user,\n\t\t\t\tts: slackEvent.ts,\n\t\t\t\tfiles: slackEvent.files,\n\t\t\t});\n\n\t\t\tconst ctx = await this.createContext(slackEvent);\n\t\t\tawait this.handler.onChannelMention(ctx);\n\t\t});\n\n\t\t// Handle all messages (for logging) and DMs (for triggering handler)\n\t\tthis.socketClient.on(\"message\", async ({ event, ack }) => {\n\t\t\tawait ack();\n\n\t\t\tconst slackEvent = 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\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// Ignore bot messages\n\t\t\tif (slackEvent.bot_id) return;\n\t\t\t// Ignore message edits, etc. (but allow file_share)\n\t\t\tif (slackEvent.subtype !== undefined && slackEvent.subtype !== \"file_share\") return;\n\t\t\t// Ignore if no user\n\t\t\tif (!slackEvent.user) return;\n\t\t\t// Ignore messages from the bot itself\n\t\t\tif (slackEvent.user === this.botUserId) return;\n\t\t\t// Ignore if no text AND no files\n\t\t\tif (!slackEvent.text && (!slackEvent.files || slackEvent.files.length === 0)) return;\n\n\t\t\t// Log ALL messages (channel and DM)\n\t\t\tawait this.logMessage({\n\t\t\t\ttext: slackEvent.text || \"\",\n\t\t\t\tchannel: slackEvent.channel,\n\t\t\t\tuser: slackEvent.user,\n\t\t\t\tts: slackEvent.ts,\n\t\t\t\tfiles: slackEvent.files,\n\t\t\t});\n\n\t\t\t// Only trigger handler for DMs (channel mentions are handled by app_mention event)\n\t\t\tif (slackEvent.channel_type === \"im\") {\n\t\t\t\tconst ctx = await this.createContext({\n\t\t\t\t\ttext: slackEvent.text || \"\",\n\t\t\t\t\tchannel: slackEvent.channel,\n\t\t\t\t\tuser: slackEvent.user,\n\t\t\t\t\tts: slackEvent.ts,\n\t\t\t\t\tfiles: slackEvent.files,\n\t\t\t\t});\n\t\t\t\tawait this.handler.onDirectMessage(ctx);\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate async logMessage(event: {\n\t\ttext: string;\n\t\tchannel: string;\n\t\tuser: string;\n\t\tts: string;\n\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t}): Promise<void> {\n\t\tconst attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];\n\t\tconst { userName, displayName } = await this.getUserInfo(event.user);\n\n\t\tawait this.store.logMessage(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,\n\t\t\tdisplayName,\n\t\t\ttext: event.text,\n\t\t\tattachments,\n\t\t\tisBot: false,\n\t\t});\n\t}\n\n\tprivate async createContext(event: {\n\t\ttext: string;\n\t\tchannel: string;\n\t\tuser: string;\n\t\tts: string;\n\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t}): Promise<SlackContext> {\n\t\tconst rawText = event.text;\n\t\tconst text = rawText.replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n\n\t\t// Get user info for logging\n\t\tconst { userName } = await this.getUserInfo(event.user);\n\n\t\t// Get channel name for logging (best effort)\n\t\tlet channelName: string | undefined;\n\t\ttry {\n\t\t\tif (event.channel.startsWith(\"C\")) {\n\t\t\t\tconst result = await this.webClient.conversations.info({ channel: event.channel });\n\t\t\t\tchannelName = result.channel?.name ? `#${result.channel.name}` : undefined;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ignore errors - we'll just use the channel ID\n\t\t}\n\n\t\t// Process attachments (for context, already logged by message handler)\n\t\tconst attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];\n\n\t\t// Track the single message for this run\n\t\tlet messageTs: string | null = null;\n\t\tlet accumulatedText = \"\";\n\t\tlet isThinking = true; // Track if we're still in \"thinking\" state\n\t\tlet isWorking = true; // Track if still processing\n\t\tconst workingIndicator = \" ...\";\n\t\tlet updatePromise: Promise<void> = Promise.resolve();\n\n\t\treturn {\n\t\t\tmessage: {\n\t\t\t\ttext,\n\t\t\t\trawText,\n\t\t\t\tuser: event.user,\n\t\t\t\tuserName,\n\t\t\t\tchannel: event.channel,\n\t\t\t\tts: event.ts,\n\t\t\t\tattachments,\n\t\t\t},\n\t\t\tchannelName,\n\t\t\tstore: this.store,\n\t\t\tchannels: this.getChannels(),\n\t\t\tusers: this.getUsers(),\n\t\t\trespond: async (responseText: string, shouldLog = true) => {\n\t\t\t\t// Queue updates to avoid race conditions\n\t\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (isThinking) {\n\t\t\t\t\t\t\t// First real response replaces \"Thinking...\"\n\t\t\t\t\t\t\taccumulatedText = responseText;\n\t\t\t\t\t\t\tisThinking = false;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Subsequent responses get appended\n\t\t\t\t\t\t\taccumulatedText += \"\\n\" + responseText;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Truncate accumulated text if too long (Slack limit is 40K, we use 35K for safety)\n\t\t\t\t\t\tconst MAX_MAIN_LENGTH = 35000;\n\t\t\t\t\t\tconst truncationNote = \"\\n\\n_(message truncated, ask me to elaborate on specific parts)_\";\n\t\t\t\t\t\tif (accumulatedText.length > MAX_MAIN_LENGTH) {\n\t\t\t\t\t\t\taccumulatedText =\n\t\t\t\t\t\t\t\taccumulatedText.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Add working indicator if still working\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t\t// Update existing message\n\t\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Log the response if requested\n\t\t\t\t\t\tif (shouldLog) {\n\t\t\t\t\t\t\tawait this.store.logBotResponse(event.channel, responseText, messageTs!);\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tlog.logWarning(\"Slack respond error\", err instanceof Error ? err.message : String(err));\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tawait updatePromise;\n\t\t\t},\n\t\t\trespondInThread: async (threadText: string) => {\n\t\t\t\t// Queue thread posts to maintain order\n\t\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (!messageTs) {\n\t\t\t\t\t\t\t// No main message yet, just skip\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Obfuscate usernames to avoid pinging people in thread details\n\t\t\t\t\t\tlet obfuscatedText = this.obfuscateUsernames(threadText);\n\n\t\t\t\t\t\t// Truncate thread messages if too long (20K limit for safety)\n\t\t\t\t\t\tconst MAX_THREAD_LENGTH = 20000;\n\t\t\t\t\t\tif (obfuscatedText.length > MAX_THREAD_LENGTH) {\n\t\t\t\t\t\t\tobfuscatedText = obfuscatedText.substring(0, MAX_THREAD_LENGTH - 50) + \"\\n\\n_(truncated)_\";\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Post in thread under the main message\n\t\t\t\t\t\tawait this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tthread_ts: messageTs,\n\t\t\t\t\t\t\ttext: obfuscatedText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tlog.logWarning(\"Slack respondInThread error\", err instanceof Error ? err.message : String(err));\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tawait updatePromise;\n\t\t\t},\n\t\t\tsetTyping: async (isTyping: boolean) => {\n\t\t\t\tif (isTyping && !messageTs) {\n\t\t\t\t\t// Post initial \"thinking\" message (... auto-appended by working indicator)\n\t\t\t\t\taccumulatedText = \"_Thinking_\";\n\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\ttext: accumulatedText,\n\t\t\t\t\t});\n\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t}\n\t\t\t\t// We don't delete/clear anymore - message persists and gets updated\n\t\t\t},\n\t\t\tuploadFile: async (filePath: string, title?: string) => {\n\t\t\t\tconst fileName = title || basename(filePath);\n\t\t\t\tconst fileContent = readFileSync(filePath);\n\n\t\t\t\tawait this.webClient.files.uploadV2({\n\t\t\t\t\tchannel_id: event.channel,\n\t\t\t\t\tfile: fileContent,\n\t\t\t\t\tfilename: fileName,\n\t\t\t\t\ttitle: fileName,\n\t\t\t\t});\n\t\t\t},\n\t\t\treplaceMessage: async (text: string) => {\n\t\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\t// Replace the accumulated text entirely, with truncation\n\t\t\t\t\t\tconst MAX_MAIN_LENGTH = 35000;\n\t\t\t\t\t\tconst truncationNote = \"\\n\\n_(message truncated, ask me to elaborate on specific parts)_\";\n\t\t\t\t\t\tif (text.length > MAX_MAIN_LENGTH) {\n\t\t\t\t\t\t\taccumulatedText = text.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\taccumulatedText = text;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tlog.logWarning(\"Slack replaceMessage error\", err instanceof Error ? err.message : String(err));\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tawait updatePromise;\n\t\t\t},\n\t\t\tsetWorking: async (working: boolean) => {\n\t\t\t\tupdatePromise = updatePromise.then(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tisWorking = working;\n\n\t\t\t\t\t\t// If we have a message, update it to add/remove indicator\n\t\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tlog.logWarning(\"Slack setWorking error\", err instanceof Error ? err.message : String(err));\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tawait updatePromise;\n\t\t\t},\n\t\t};\n\t}\n\n\t/**\n\t * Backfill missed messages for a single channel\n\t * Returns the number of messages backfilled\n\t */\n\tprivate async backfillChannel(channelId: string): Promise<number> {\n\t\tconst lastTs = this.store.getLastTimestamp(channelId);\n\n\t\t// Collect messages from up to 3 pages\n\t\ttype Message = NonNullable<ConversationsHistoryResponse[\"messages\"]>[number];\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: lastTs ?? undefined,\n\t\t\t\tinclusive: false,\n\t\t\t\tlimit: 1000,\n\t\t\t\tcursor,\n\t\t\t});\n\n\t\t\tif (result.messages) {\n\t\t\t\tallMessages.push(...result.messages);\n\t\t\t}\n\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 messages: include mom's messages, exclude other bots\n\t\tconst relevantMessages = allMessages.filter((msg) => {\n\t\t\t// Always include mom's own messages\n\t\t\tif (msg.user === this.botUserId) return true;\n\t\t\t// Exclude other bot messages\n\t\t\tif (msg.bot_id) return false;\n\t\t\t// Standard filters for user messages\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 (API returns newest first)\n\t\trelevantMessages.reverse();\n\n\t\t// Log each message\n\t\tfor (const msg of relevantMessages) {\n\t\t\tconst isMomMessage = msg.user === this.botUserId;\n\t\t\tconst attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];\n\n\t\t\tif (isMomMessage) {\n\t\t\t\t// Log mom's message as bot response\n\t\t\t\tawait this.store.logMessage(channelId, {\n\t\t\t\t\tdate: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n\t\t\t\t\tts: msg.ts!,\n\t\t\t\t\tuser: \"bot\",\n\t\t\t\t\ttext: msg.text || \"\",\n\t\t\t\t\tattachments,\n\t\t\t\t\tisBot: true,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Log user message\n\t\t\t\tconst { userName, displayName } = await this.getUserInfo(msg.user!);\n\t\t\t\tawait this.store.logMessage(channelId, {\n\t\t\t\t\tdate: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n\t\t\t\t\tts: msg.ts!,\n\t\t\t\t\tuser: msg.user!,\n\t\t\t\t\tuserName,\n\t\t\t\t\tdisplayName,\n\t\t\t\t\ttext: msg.text || \"\",\n\t\t\t\t\tattachments,\n\t\t\t\t\tisBot: false,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn relevantMessages.length;\n\t}\n\n\t/**\n\t * Backfill missed messages for all channels\n\t */\n\tprivate async backfillAllChannels(): Promise<void> {\n\t\tconst startTime = Date.now();\n\t\tlog.logBackfillStart(this.channelCache.size);\n\n\t\tlet totalMessages = 0;\n\n\t\tfor (const [channelId, channelName] of this.channelCache) {\n\t\t\ttry {\n\t\t\t\tconst count = await this.backfillChannel(channelId);\n\t\t\t\tif (count > 0) {\n\t\t\t\t\tlog.logBackfillChannel(channelName, count);\n\t\t\t\t}\n\t\t\t\ttotalMessages += count;\n\t\t\t} catch (error) {\n\t\t\t\tlog.logWarning(`Failed to backfill channel #${channelName}`, String(error));\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\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\t// Fetch channels and users in parallel\n\t\tawait Promise.all([this.fetchChannels(), this.fetchUsers()]);\n\t\tlog.logInfo(`Loaded ${this.channelCache.size} channels, ${this.userCache.size} users`);\n\n\t\t// Backfill any messages missed while offline\n\t\tawait this.backfillAllChannels();\n\n\t\tawait this.socketClient.start();\n\t\tlog.logConnected();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.socketClient.disconnect();\n\t\tlog.logDisconnected();\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../src/slack.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 { basename, join } from \"path\";\nimport * as log from \"./log.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n\ttype: \"mention\" | \"dm\";\n\tchannel: string;\n\tts: string;\n\tuser: string;\n\ttext: string;\n\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\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}\n\nexport interface MomHandler {\n\t/**\n\t * Check if channel is currently running (SYNC)\n\t */\n\tisRunning(channelId: string): boolean;\n\n\t/**\n\t * Handle an event that triggers mom (ASYNC)\n\t * Called only when isRunning() returned false\n\t */\n\thandleEvent(event: SlackEvent, slack: SlackBot): Promise<void>;\n\n\t/**\n\t * Handle stop command (ASYNC)\n\t * Called when user says \"stop\" while mom is running\n\t */\n\thandleStop(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\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 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(handler: MomHandler, config: { appToken: string; botToken: string; workingDir: string }) {\n\t\tthis.handler = handler;\n\t\tthis.workingDir = config.workingDir;\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\tconst result = await this.webClient.chat.postMessage({ channel, text });\n\t\treturn result.ts as string;\n\t}\n\n\tasync updateMessage(channel: string, ts: string, text: string): Promise<void> {\n\t\tawait this.webClient.chat.update({ channel, ts, text });\n\t}\n\n\tasync postInThread(channel: string, threadTs: string, text: string): Promise<void> {\n\t\tawait this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n\t}\n\n\tasync uploadFile(channel: string, filePath: string, title?: string): Promise<void> {\n\t\tconst fileName = title || basename(filePath);\n\t\tconst fileContent = readFileSync(filePath);\n\t\tawait this.webClient.files.uploadV2({\n\t\t\tchannel_id: channel,\n\t\t\tfile: fileContent,\n\t\t\tfilename: fileName,\n\t\t\ttitle: fileName,\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// 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\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\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\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\tthis.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(e.channel)) {\n\t\t\t\t\tthis.handler.handleStop(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\n\t\t\tif (this.handler.isRunning(e.channel)) {\n\t\t\t\tthis.postMessage(e.channel, \"_Already working. Say `@mom stop` to cancel._\");\n\t\t\t} else {\n\t\t\t\tthis.getQueue(e.channel).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\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\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\tthis.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\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(e.channel)) {\n\t\t\t\t\t\tthis.handler.handleStop(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(e.channel)) {\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(e.channel).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 */\n\tprivate logUserMessage(event: SlackEvent): void {\n\t\tconst user = this.users.get(event.user);\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: event.files?.map((f) => f.name) || [],\n\t\t\tisBot: false,\n\t\t});\n\t}\n\n\t// ==========================================================================\n\t// Private - Backfill\n\t// ==========================================================================\n\n\tprivate getExistingTimestamps(channelId: string): 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 = readFileSync(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 = 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 mom'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 isMomMessage = 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\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: isMomMessage ? \"bot\" : msg.user!,\n\t\t\t\tuserName: isMomMessage ? undefined : user?.userName,\n\t\t\t\tdisplayName: isMomMessage ? undefined : user?.displayName,\n\t\t\t\ttext,\n\t\t\t\tattachments: msg.files?.map((f) => f.name) || [],\n\t\t\t\tisBot: isMomMessage,\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 (mom 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\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"]}
|