@mariozechner/pi-mom 0.12.6 → 0.12.8

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Added
6
+
7
+ - Message backfill on startup (#103)
8
+ - Fetches missed messages from Slack using `conversations.history` API when mom restarts
9
+ - Backfills up to 3 pages (3000 messages) per channel since last logged timestamp
10
+ - Includes mom's own responses and user messages (excludes other bots)
11
+ - Downloads attachments from backfilled messages
12
+ - Logs progress: channel count, per-channel message counts, total with duration
13
+
5
14
  ## [0.10.2] - 2025-11-27
6
15
 
7
16
  ### Breaking Changes
package/dist/log.d.ts CHANGED
@@ -33,4 +33,7 @@ export declare function logUsageSummary(ctx: LogContext, usage: {
33
33
  export declare function logStartup(workingDir: string, sandbox: string): void;
34
34
  export declare function logConnected(): void;
35
35
  export declare function logDisconnected(): void;
36
+ export declare function logBackfillStart(channelCount: number): void;
37
+ export declare function logBackfillChannel(channelName: string, messageCount: number): void;
38
+ export declare function logBackfillComplete(totalMessages: number, durationMs: number): void;
36
39
  //# sourceMappingURL=log.d.ts.map
package/dist/log.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAiED,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAElE;AAGD,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWlH;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAY1G;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAUvG;AAGD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAEtD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQnE;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAQ/D;AAGD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAG3F;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAExE;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAGvF;AAGD,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAGpD;AAGD,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CASlE;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAQ7E;AAGD,wBAAgB,eAAe,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE;IACN,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9F,GACC,MAAM,CA8BR;AAGD,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAIpE;AAED,wBAAgB,YAAY,IAAI,IAAI,CAGnC;AAED,wBAAgB,eAAe,IAAI,IAAI,CAEtC","sourcesContent":["import chalk from \"chalk\";\n\nexport interface LogContext {\n\tchannelId: string;\n\tuserName?: string;\n\tchannelName?: string; // For display like #dev-team vs C16HET4EQ\n}\n\nfunction timestamp(): string {\n\tconst now = new Date();\n\tconst hh = String(now.getHours()).padStart(2, \"0\");\n\tconst mm = String(now.getMinutes()).padStart(2, \"0\");\n\tconst ss = String(now.getSeconds()).padStart(2, \"0\");\n\treturn `[${hh}:${mm}:${ss}]`;\n}\n\nfunction formatContext(ctx: LogContext): string {\n\t// DMs: [DM:username]\n\t// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name\n\tif (ctx.channelId.startsWith(\"D\")) {\n\t\treturn `[DM:${ctx.userName || ctx.channelId}]`;\n\t}\n\tconst channel = ctx.channelName || ctx.channelId;\n\tconst user = ctx.userName || \"unknown\";\n\treturn `[${channel.startsWith(\"#\") ? channel : `#${channel}`}:${user}]`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn text.substring(0, maxLen) + `\\n(truncated at ${maxLen} chars)`;\n}\n\nfunction formatToolArgs(args: Record<string, unknown>): string {\n\tconst lines: string[] = [];\n\n\tfor (const [key, value] of Object.entries(args)) {\n\t\t// Skip the label - it's already shown in the tool name\n\t\tif (key === \"label\") continue;\n\n\t\t// For read tool, format path with offset/limit\n\t\tif (key === \"path\" && typeof value === \"string\") {\n\t\t\tconst offset = args.offset as number | undefined;\n\t\t\tconst limit = args.limit as number | undefined;\n\t\t\tif (offset !== undefined && limit !== undefined) {\n\t\t\t\tlines.push(`${value}:${offset}-${offset + limit}`);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip offset/limit since we already handled them\n\t\tif (key === \"offset\" || key === \"limit\") continue;\n\n\t\t// For other values, format them\n\t\tif (typeof value === \"string\") {\n\t\t\t// Multi-line strings get indented\n\t\t\tif (value.includes(\"\\n\")) {\n\t\t\t\tlines.push(value);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t} else {\n\t\t\tlines.push(JSON.stringify(value));\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n// User messages\nexport function logUserMessage(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));\n}\n\n// Tool execution\nexport function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {\n\tconst formattedArgs = formatToolArgs(args);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));\n\tif (formattedArgs) {\n\t\t// Indent the args\n\t\tconst indented = formattedArgs\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(result, 1000);\n\tif (truncated) {\n\t\tconst indented = truncated\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(error, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Response streaming\nexport function logResponseStart(ctx: LogContext): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));\n}\n\nexport function logThinking(ctx: LogContext, thinking: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));\n\tconst truncated = truncate(thinking, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\nexport function logResponse(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));\n\tconst truncated = truncate(text, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Attachments\nexport function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));\n\tconsole.log(chalk.dim(` ${filename} → ${localPath}`));\n}\n\nexport function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));\n}\n\nexport function logDownloadError(ctx: LogContext, filename: string, error: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));\n\tconsole.log(chalk.dim(` ${filename}: ${error}`));\n}\n\n// Control\nexport function logStopRequest(ctx: LogContext): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));\n}\n\n// System\nexport function logInfo(message: string): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] ${message}`));\n}\n\nexport function logWarning(message: string, details?: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));\n\tif (details) {\n\t\tconst indented = details\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logAgentError(ctx: LogContext | \"system\", error: string): void {\n\tconst context = ctx === \"system\" ? \"[system]\" : formatContext(ctx);\n\tconsole.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));\n\tconst indented = error\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Usage summary\nexport function logUsageSummary(\n\tctx: LogContext,\n\tusage: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t},\n): string {\n\tconst lines: string[] = [];\n\tlines.push(\"*Usage Summary*\");\n\tlines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);\n\tif (usage.cacheRead > 0 || usage.cacheWrite > 0) {\n\t\tlines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);\n\t}\n\tlines.push(\n\t\t`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +\n\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`\n\t\t\t\t: \"\"),\n\t);\n\tlines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);\n\n\tconst summary = lines.join(\"\\n\");\n\n\t// Log to console\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));\n\tconsole.log(\n\t\tchalk.dim(\n\t\t\t` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +\n\t\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t\t? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`\n\t\t\t\t\t: \"\") +\n\t\t\t\t` = $${usage.cost.total.toFixed(4)}`,\n\t\t),\n\t);\n\n\treturn summary;\n}\n\n// Startup (no context needed)\nexport function logStartup(workingDir: string, sandbox: string): void {\n\tconsole.log(\"Starting mom bot...\");\n\tconsole.log(` Working directory: ${workingDir}`);\n\tconsole.log(` Sandbox: ${sandbox}`);\n}\n\nexport function logConnected(): void {\n\tconsole.log(\"⚡️ Mom bot connected and listening!\");\n\tconsole.log(\"\");\n}\n\nexport function logDisconnected(): void {\n\tconsole.log(\"Mom bot disconnected.\");\n}\n"]}
1
+ {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAiED,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAElE;AAGD,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWlH;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAY1G;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAUvG;AAGD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAEtD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQnE;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAQ/D;AAGD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAG3F;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAExE;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAGvF;AAGD,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAGpD;AAGD,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CASlE;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAQ7E;AAGD,wBAAgB,eAAe,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE;IACN,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9F,GACC,MAAM,CA8BR;AAGD,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAIpE;AAED,wBAAgB,YAAY,IAAI,IAAI,CAGnC;AAED,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAGD,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAElF;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAGnF","sourcesContent":["import chalk from \"chalk\";\n\nexport interface LogContext {\n\tchannelId: string;\n\tuserName?: string;\n\tchannelName?: string; // For display like #dev-team vs C16HET4EQ\n}\n\nfunction timestamp(): string {\n\tconst now = new Date();\n\tconst hh = String(now.getHours()).padStart(2, \"0\");\n\tconst mm = String(now.getMinutes()).padStart(2, \"0\");\n\tconst ss = String(now.getSeconds()).padStart(2, \"0\");\n\treturn `[${hh}:${mm}:${ss}]`;\n}\n\nfunction formatContext(ctx: LogContext): string {\n\t// DMs: [DM:username]\n\t// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name\n\tif (ctx.channelId.startsWith(\"D\")) {\n\t\treturn `[DM:${ctx.userName || ctx.channelId}]`;\n\t}\n\tconst channel = ctx.channelName || ctx.channelId;\n\tconst user = ctx.userName || \"unknown\";\n\treturn `[${channel.startsWith(\"#\") ? channel : `#${channel}`}:${user}]`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn text.substring(0, maxLen) + `\\n(truncated at ${maxLen} chars)`;\n}\n\nfunction formatToolArgs(args: Record<string, unknown>): string {\n\tconst lines: string[] = [];\n\n\tfor (const [key, value] of Object.entries(args)) {\n\t\t// Skip the label - it's already shown in the tool name\n\t\tif (key === \"label\") continue;\n\n\t\t// For read tool, format path with offset/limit\n\t\tif (key === \"path\" && typeof value === \"string\") {\n\t\t\tconst offset = args.offset as number | undefined;\n\t\t\tconst limit = args.limit as number | undefined;\n\t\t\tif (offset !== undefined && limit !== undefined) {\n\t\t\t\tlines.push(`${value}:${offset}-${offset + limit}`);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip offset/limit since we already handled them\n\t\tif (key === \"offset\" || key === \"limit\") continue;\n\n\t\t// For other values, format them\n\t\tif (typeof value === \"string\") {\n\t\t\t// Multi-line strings get indented\n\t\t\tif (value.includes(\"\\n\")) {\n\t\t\t\tlines.push(value);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t} else {\n\t\t\tlines.push(JSON.stringify(value));\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n// User messages\nexport function logUserMessage(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));\n}\n\n// Tool execution\nexport function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {\n\tconst formattedArgs = formatToolArgs(args);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));\n\tif (formattedArgs) {\n\t\t// Indent the args\n\t\tconst indented = formattedArgs\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(result, 1000);\n\tif (truncated) {\n\t\tconst indented = truncated\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(error, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Response streaming\nexport function logResponseStart(ctx: LogContext): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));\n}\n\nexport function logThinking(ctx: LogContext, thinking: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));\n\tconst truncated = truncate(thinking, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\nexport function logResponse(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));\n\tconst truncated = truncate(text, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Attachments\nexport function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));\n\tconsole.log(chalk.dim(` ${filename} → ${localPath}`));\n}\n\nexport function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));\n}\n\nexport function logDownloadError(ctx: LogContext, filename: string, error: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));\n\tconsole.log(chalk.dim(` ${filename}: ${error}`));\n}\n\n// Control\nexport function logStopRequest(ctx: LogContext): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));\n}\n\n// System\nexport function logInfo(message: string): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] ${message}`));\n}\n\nexport function logWarning(message: string, details?: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));\n\tif (details) {\n\t\tconst indented = details\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logAgentError(ctx: LogContext | \"system\", error: string): void {\n\tconst context = ctx === \"system\" ? \"[system]\" : formatContext(ctx);\n\tconsole.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));\n\tconst indented = error\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Usage summary\nexport function logUsageSummary(\n\tctx: LogContext,\n\tusage: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t},\n): string {\n\tconst lines: string[] = [];\n\tlines.push(\"*Usage Summary*\");\n\tlines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);\n\tif (usage.cacheRead > 0 || usage.cacheWrite > 0) {\n\t\tlines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);\n\t}\n\tlines.push(\n\t\t`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +\n\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`\n\t\t\t\t: \"\"),\n\t);\n\tlines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);\n\n\tconst summary = lines.join(\"\\n\");\n\n\t// Log to console\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));\n\tconsole.log(\n\t\tchalk.dim(\n\t\t\t` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +\n\t\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t\t? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`\n\t\t\t\t\t: \"\") +\n\t\t\t\t` = $${usage.cost.total.toFixed(4)}`,\n\t\t),\n\t);\n\n\treturn summary;\n}\n\n// Startup (no context needed)\nexport function logStartup(workingDir: string, sandbox: string): void {\n\tconsole.log(\"Starting mom bot...\");\n\tconsole.log(` Working directory: ${workingDir}`);\n\tconsole.log(` Sandbox: ${sandbox}`);\n}\n\nexport function logConnected(): void {\n\tconsole.log(\"⚡️ Mom bot connected and listening!\");\n\tconsole.log(\"\");\n}\n\nexport function logDisconnected(): void {\n\tconsole.log(\"Mom bot disconnected.\");\n}\n\n// Backfill\nexport function logBackfillStart(channelCount: number): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] Backfilling ${channelCount} channels...`));\n}\n\nexport function logBackfillChannel(channelName: string, messageCount: number): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] #${channelName}: ${messageCount} messages`));\n}\n\nexport function logBackfillComplete(totalMessages: number, durationMs: number): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.blue(`${timestamp()} [system] Backfill complete: ${totalMessages} messages in ${duration}s`));\n}\n"]}
package/dist/log.js CHANGED
@@ -195,4 +195,15 @@ export function logConnected() {
195
195
  export function logDisconnected() {
196
196
  console.log("Mom bot disconnected.");
197
197
  }
198
+ // Backfill
199
+ export function logBackfillStart(channelCount) {
200
+ console.log(chalk.blue(`${timestamp()} [system] Backfilling ${channelCount} channels...`));
201
+ }
202
+ export function logBackfillChannel(channelName, messageCount) {
203
+ console.log(chalk.blue(`${timestamp()} [system] #${channelName}: ${messageCount} messages`));
204
+ }
205
+ export function logBackfillComplete(totalMessages, durationMs) {
206
+ const duration = (durationMs / 1000).toFixed(1);
207
+ console.log(chalk.blue(`${timestamp()} [system] Backfill complete: ${totalMessages} messages in ${duration}s`));
208
+ }
198
209
  //# sourceMappingURL=log.js.map
package/dist/log.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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","sourcesContent":["import chalk from \"chalk\";\n\nexport interface LogContext {\n\tchannelId: string;\n\tuserName?: string;\n\tchannelName?: string; // For display like #dev-team vs C16HET4EQ\n}\n\nfunction timestamp(): string {\n\tconst now = new Date();\n\tconst hh = String(now.getHours()).padStart(2, \"0\");\n\tconst mm = String(now.getMinutes()).padStart(2, \"0\");\n\tconst ss = String(now.getSeconds()).padStart(2, \"0\");\n\treturn `[${hh}:${mm}:${ss}]`;\n}\n\nfunction formatContext(ctx: LogContext): string {\n\t// DMs: [DM:username]\n\t// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name\n\tif (ctx.channelId.startsWith(\"D\")) {\n\t\treturn `[DM:${ctx.userName || ctx.channelId}]`;\n\t}\n\tconst channel = ctx.channelName || ctx.channelId;\n\tconst user = ctx.userName || \"unknown\";\n\treturn `[${channel.startsWith(\"#\") ? channel : `#${channel}`}:${user}]`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn text.substring(0, maxLen) + `\\n(truncated at ${maxLen} chars)`;\n}\n\nfunction formatToolArgs(args: Record<string, unknown>): string {\n\tconst lines: string[] = [];\n\n\tfor (const [key, value] of Object.entries(args)) {\n\t\t// Skip the label - it's already shown in the tool name\n\t\tif (key === \"label\") continue;\n\n\t\t// For read tool, format path with offset/limit\n\t\tif (key === \"path\" && typeof value === \"string\") {\n\t\t\tconst offset = args.offset as number | undefined;\n\t\t\tconst limit = args.limit as number | undefined;\n\t\t\tif (offset !== undefined && limit !== undefined) {\n\t\t\t\tlines.push(`${value}:${offset}-${offset + limit}`);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip offset/limit since we already handled them\n\t\tif (key === \"offset\" || key === \"limit\") continue;\n\n\t\t// For other values, format them\n\t\tif (typeof value === \"string\") {\n\t\t\t// Multi-line strings get indented\n\t\t\tif (value.includes(\"\\n\")) {\n\t\t\t\tlines.push(value);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t} else {\n\t\t\tlines.push(JSON.stringify(value));\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n// User messages\nexport function logUserMessage(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));\n}\n\n// Tool execution\nexport function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {\n\tconst formattedArgs = formatToolArgs(args);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));\n\tif (formattedArgs) {\n\t\t// Indent the args\n\t\tconst indented = formattedArgs\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(result, 1000);\n\tif (truncated) {\n\t\tconst indented = truncated\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(error, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Response streaming\nexport function logResponseStart(ctx: LogContext): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));\n}\n\nexport function logThinking(ctx: LogContext, thinking: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));\n\tconst truncated = truncate(thinking, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\nexport function logResponse(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));\n\tconst truncated = truncate(text, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Attachments\nexport function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));\n\tconsole.log(chalk.dim(` ${filename} → ${localPath}`));\n}\n\nexport function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));\n}\n\nexport function logDownloadError(ctx: LogContext, filename: string, error: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));\n\tconsole.log(chalk.dim(` ${filename}: ${error}`));\n}\n\n// Control\nexport function logStopRequest(ctx: LogContext): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));\n}\n\n// System\nexport function logInfo(message: string): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] ${message}`));\n}\n\nexport function logWarning(message: string, details?: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));\n\tif (details) {\n\t\tconst indented = details\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logAgentError(ctx: LogContext | \"system\", error: string): void {\n\tconst context = ctx === \"system\" ? \"[system]\" : formatContext(ctx);\n\tconsole.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));\n\tconst indented = error\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Usage summary\nexport function logUsageSummary(\n\tctx: LogContext,\n\tusage: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t},\n): string {\n\tconst lines: string[] = [];\n\tlines.push(\"*Usage Summary*\");\n\tlines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);\n\tif (usage.cacheRead > 0 || usage.cacheWrite > 0) {\n\t\tlines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);\n\t}\n\tlines.push(\n\t\t`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +\n\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`\n\t\t\t\t: \"\"),\n\t);\n\tlines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);\n\n\tconst summary = lines.join(\"\\n\");\n\n\t// Log to console\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));\n\tconsole.log(\n\t\tchalk.dim(\n\t\t\t` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +\n\t\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t\t? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`\n\t\t\t\t\t: \"\") +\n\t\t\t\t` = $${usage.cost.total.toFixed(4)}`,\n\t\t),\n\t);\n\n\treturn summary;\n}\n\n// Startup (no context needed)\nexport function logStartup(workingDir: string, sandbox: string): void {\n\tconsole.log(\"Starting mom bot...\");\n\tconsole.log(` Working directory: ${workingDir}`);\n\tconsole.log(` Sandbox: ${sandbox}`);\n}\n\nexport function logConnected(): void {\n\tconsole.log(\"⚡️ Mom bot connected and listening!\");\n\tconsole.log(\"\");\n}\n\nexport function logDisconnected(): void {\n\tconsole.log(\"Mom bot disconnected.\");\n}\n"]}
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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","sourcesContent":["import chalk from \"chalk\";\n\nexport interface LogContext {\n\tchannelId: string;\n\tuserName?: string;\n\tchannelName?: string; // For display like #dev-team vs C16HET4EQ\n}\n\nfunction timestamp(): string {\n\tconst now = new Date();\n\tconst hh = String(now.getHours()).padStart(2, \"0\");\n\tconst mm = String(now.getMinutes()).padStart(2, \"0\");\n\tconst ss = String(now.getSeconds()).padStart(2, \"0\");\n\treturn `[${hh}:${mm}:${ss}]`;\n}\n\nfunction formatContext(ctx: LogContext): string {\n\t// DMs: [DM:username]\n\t// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name\n\tif (ctx.channelId.startsWith(\"D\")) {\n\t\treturn `[DM:${ctx.userName || ctx.channelId}]`;\n\t}\n\tconst channel = ctx.channelName || ctx.channelId;\n\tconst user = ctx.userName || \"unknown\";\n\treturn `[${channel.startsWith(\"#\") ? channel : `#${channel}`}:${user}]`;\n}\n\nfunction truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn text.substring(0, maxLen) + `\\n(truncated at ${maxLen} chars)`;\n}\n\nfunction formatToolArgs(args: Record<string, unknown>): string {\n\tconst lines: string[] = [];\n\n\tfor (const [key, value] of Object.entries(args)) {\n\t\t// Skip the label - it's already shown in the tool name\n\t\tif (key === \"label\") continue;\n\n\t\t// For read tool, format path with offset/limit\n\t\tif (key === \"path\" && typeof value === \"string\") {\n\t\t\tconst offset = args.offset as number | undefined;\n\t\t\tconst limit = args.limit as number | undefined;\n\t\t\tif (offset !== undefined && limit !== undefined) {\n\t\t\t\tlines.push(`${value}:${offset}-${offset + limit}`);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip offset/limit since we already handled them\n\t\tif (key === \"offset\" || key === \"limit\") continue;\n\n\t\t// For other values, format them\n\t\tif (typeof value === \"string\") {\n\t\t\t// Multi-line strings get indented\n\t\t\tif (value.includes(\"\\n\")) {\n\t\t\t\tlines.push(value);\n\t\t\t} else {\n\t\t\t\tlines.push(value);\n\t\t\t}\n\t\t} else {\n\t\t\tlines.push(JSON.stringify(value));\n\t\t}\n\t}\n\n\treturn lines.join(\"\\n\");\n}\n\n// User messages\nexport function logUserMessage(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));\n}\n\n// Tool execution\nexport function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {\n\tconst formattedArgs = formatToolArgs(args);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));\n\tif (formattedArgs) {\n\t\t// Indent the args\n\t\tconst indented = formattedArgs\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(result, 1000);\n\tif (truncated) {\n\t\tconst indented = truncated\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));\n\n\tconst truncated = truncate(error, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Response streaming\nexport function logResponseStart(ctx: LogContext): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));\n}\n\nexport function logThinking(ctx: LogContext, thinking: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));\n\tconst truncated = truncate(thinking, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\nexport function logResponse(ctx: LogContext, text: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));\n\tconst truncated = truncate(text, 1000);\n\tconst indented = truncated\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Attachments\nexport function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));\n\tconsole.log(chalk.dim(` ${filename} → ${localPath}`));\n}\n\nexport function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));\n}\n\nexport function logDownloadError(ctx: LogContext, filename: string, error: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));\n\tconsole.log(chalk.dim(` ${filename}: ${error}`));\n}\n\n// Control\nexport function logStopRequest(ctx: LogContext): void {\n\tconsole.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));\n}\n\n// System\nexport function logInfo(message: string): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] ${message}`));\n}\n\nexport function logWarning(message: string, details?: string): void {\n\tconsole.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));\n\tif (details) {\n\t\tconst indented = details\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => ` ${line}`)\n\t\t\t.join(\"\\n\");\n\t\tconsole.log(chalk.dim(indented));\n\t}\n}\n\nexport function logAgentError(ctx: LogContext | \"system\", error: string): void {\n\tconst context = ctx === \"system\" ? \"[system]\" : formatContext(ctx);\n\tconsole.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));\n\tconst indented = error\n\t\t.split(\"\\n\")\n\t\t.map((line) => ` ${line}`)\n\t\t.join(\"\\n\");\n\tconsole.log(chalk.dim(indented));\n}\n\n// Usage summary\nexport function logUsageSummary(\n\tctx: LogContext,\n\tusage: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\tcost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };\n\t},\n): string {\n\tconst lines: string[] = [];\n\tlines.push(\"*Usage Summary*\");\n\tlines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);\n\tif (usage.cacheRead > 0 || usage.cacheWrite > 0) {\n\t\tlines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);\n\t}\n\tlines.push(\n\t\t`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +\n\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`\n\t\t\t\t: \"\"),\n\t);\n\tlines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);\n\n\tconst summary = lines.join(\"\\n\");\n\n\t// Log to console\n\tconsole.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));\n\tconsole.log(\n\t\tchalk.dim(\n\t\t\t` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +\n\t\t\t\t(usage.cacheRead > 0 || usage.cacheWrite > 0\n\t\t\t\t\t? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`\n\t\t\t\t\t: \"\") +\n\t\t\t\t` = $${usage.cost.total.toFixed(4)}`,\n\t\t),\n\t);\n\n\treturn summary;\n}\n\n// Startup (no context needed)\nexport function logStartup(workingDir: string, sandbox: string): void {\n\tconsole.log(\"Starting mom bot...\");\n\tconsole.log(` Working directory: ${workingDir}`);\n\tconsole.log(` Sandbox: ${sandbox}`);\n}\n\nexport function logConnected(): void {\n\tconsole.log(\"⚡️ Mom bot connected and listening!\");\n\tconsole.log(\"\");\n}\n\nexport function logDisconnected(): void {\n\tconsole.log(\"Mom bot disconnected.\");\n}\n\n// Backfill\nexport function logBackfillStart(channelCount: number): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] Backfilling ${channelCount} channels...`));\n}\n\nexport function logBackfillChannel(channelName: string, messageCount: number): void {\n\tconsole.log(chalk.blue(`${timestamp()} [system] #${channelName}: ${messageCount} messages`));\n}\n\nexport function logBackfillComplete(totalMessages: number, durationMs: number): void {\n\tconst duration = (durationMs / 1000).toFixed(1);\n\tconsole.log(chalk.blue(`${timestamp()} [system] Backfill complete: ${totalMessages} messages in ${duration}s`));\n}\n"]}
package/dist/slack.d.ts CHANGED
@@ -75,6 +75,8 @@ export declare class MomBot {
75
75
  private setupEventHandlers;
76
76
  private logMessage;
77
77
  private createContext;
78
+ private backfillChannel;
79
+ private backfillAllChannels;
78
80
  start(): Promise<void>;
79
81
  stop(): Promise<void>;
80
82
  }
@@ -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,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,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;YAmEZ,UAAU;YAsBV,aAAa;IA4KrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAU3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAG1B;CACD","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { 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, log?: 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\t// Note: We don't log here - the message event handler logs all messages\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\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, log = 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\tif (isThinking) {\n\t\t\t\t\t\t// First real response replaces \"Thinking...\"\n\t\t\t\t\t\taccumulatedText = responseText;\n\t\t\t\t\t\tisThinking = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Subsequent responses get appended\n\t\t\t\t\t\taccumulatedText += \"\\n\" + responseText;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add working indicator if still working\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t// Update existing message\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Log the response if requested\n\t\t\t\t\tif (log) {\n\t\t\t\t\t\tawait this.store.logBotResponse(event.channel, responseText, messageTs!);\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\tif (!messageTs) {\n\t\t\t\t\t\t// No main message yet, just skip\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Obfuscate usernames to avoid pinging people in thread details\n\t\t\t\t\tconst obfuscatedText = this.obfuscateUsernames(threadText);\n\t\t\t\t\t// Post in thread under the main message\n\t\t\t\t\tawait this.webClient.chat.postMessage({\n\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\tthread_ts: messageTs,\n\t\t\t\t\t\ttext: obfuscatedText,\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\t// Replace the accumulated text entirely\n\t\t\t\t\taccumulatedText = text;\n\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\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\tisWorking = working;\n\n\t\t\t\t\t// If we have a message, update it to add/remove indicator\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\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\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\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":"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,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,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;YAmEZ,UAAU;YAsBV,aAAa;YAgLb,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, log?: 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\t// Note: We don't log here - the message event handler logs all messages\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\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, log = 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\tif (isThinking) {\n\t\t\t\t\t\t// First real response replaces \"Thinking...\"\n\t\t\t\t\t\taccumulatedText = responseText;\n\t\t\t\t\t\tisThinking = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Subsequent responses get appended\n\t\t\t\t\t\taccumulatedText += \"\\n\" + responseText;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add working indicator if still working\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t// Update existing message\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Log the response if requested\n\t\t\t\t\tif (log) {\n\t\t\t\t\t\tawait this.store.logBotResponse(event.channel, responseText, messageTs!);\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\tif (!messageTs) {\n\t\t\t\t\t\t// No main message yet, just skip\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Obfuscate usernames to avoid pinging people in thread details\n\t\t\t\t\tconst obfuscatedText = this.obfuscateUsernames(threadText);\n\t\t\t\t\t// Post in thread under the main message\n\t\t\t\t\tawait this.webClient.chat.postMessage({\n\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\tthread_ts: messageTs,\n\t\t\t\t\t\ttext: obfuscatedText,\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\t// Replace the accumulated text entirely\n\t\t\t\t\taccumulatedText = text;\n\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\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\tisWorking = working;\n\n\t\t\t\t\t// If we have a message, update it to add/remove indicator\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\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"]}
package/dist/slack.js CHANGED
@@ -355,12 +355,111 @@ export class MomBot {
355
355
  },
356
356
  };
357
357
  }
358
+ /**
359
+ * Backfill missed messages for a single channel
360
+ * Returns the number of messages backfilled
361
+ */
362
+ async backfillChannel(channelId) {
363
+ const lastTs = this.store.getLastTimestamp(channelId);
364
+ const allMessages = [];
365
+ let cursor;
366
+ let pageCount = 0;
367
+ const maxPages = 3;
368
+ do {
369
+ const result = await this.webClient.conversations.history({
370
+ channel: channelId,
371
+ oldest: lastTs ?? undefined,
372
+ inclusive: false,
373
+ limit: 1000,
374
+ cursor,
375
+ });
376
+ if (result.messages) {
377
+ allMessages.push(...result.messages);
378
+ }
379
+ cursor = result.response_metadata?.next_cursor;
380
+ pageCount++;
381
+ } while (cursor && pageCount < maxPages);
382
+ // Filter messages: include mom's messages, exclude other bots
383
+ const relevantMessages = allMessages.filter((msg) => {
384
+ // Always include mom's own messages
385
+ if (msg.user === this.botUserId)
386
+ return true;
387
+ // Exclude other bot messages
388
+ if (msg.bot_id)
389
+ return false;
390
+ // Standard filters for user messages
391
+ if (msg.subtype !== undefined && msg.subtype !== "file_share")
392
+ return false;
393
+ if (!msg.user)
394
+ return false;
395
+ if (!msg.text && (!msg.files || msg.files.length === 0))
396
+ return false;
397
+ return true;
398
+ });
399
+ // Reverse to chronological order (API returns newest first)
400
+ relevantMessages.reverse();
401
+ // Log each message
402
+ for (const msg of relevantMessages) {
403
+ const isMomMessage = msg.user === this.botUserId;
404
+ const attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts) : [];
405
+ if (isMomMessage) {
406
+ // Log mom's message as bot response
407
+ await this.store.logMessage(channelId, {
408
+ date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
409
+ ts: msg.ts,
410
+ user: "bot",
411
+ text: msg.text || "",
412
+ attachments,
413
+ isBot: true,
414
+ });
415
+ }
416
+ else {
417
+ // Log user message
418
+ const { userName, displayName } = await this.getUserInfo(msg.user);
419
+ await this.store.logMessage(channelId, {
420
+ date: new Date(parseFloat(msg.ts) * 1000).toISOString(),
421
+ ts: msg.ts,
422
+ user: msg.user,
423
+ userName,
424
+ displayName,
425
+ text: msg.text || "",
426
+ attachments,
427
+ isBot: false,
428
+ });
429
+ }
430
+ }
431
+ return relevantMessages.length;
432
+ }
433
+ /**
434
+ * Backfill missed messages for all channels
435
+ */
436
+ async backfillAllChannels() {
437
+ const startTime = Date.now();
438
+ log.logBackfillStart(this.channelCache.size);
439
+ let totalMessages = 0;
440
+ for (const [channelId, channelName] of this.channelCache) {
441
+ try {
442
+ const count = await this.backfillChannel(channelId);
443
+ if (count > 0) {
444
+ log.logBackfillChannel(channelName, count);
445
+ }
446
+ totalMessages += count;
447
+ }
448
+ catch (error) {
449
+ log.logWarning(`Failed to backfill channel #${channelName}`, String(error));
450
+ }
451
+ }
452
+ const durationMs = Date.now() - startTime;
453
+ log.logBackfillComplete(totalMessages, durationMs);
454
+ }
358
455
  async start() {
359
456
  const auth = await this.webClient.auth.test();
360
457
  this.botUserId = auth.user_id;
361
458
  // Fetch channels and users in parallel
362
459
  await Promise.all([this.fetchChannels(), this.fetchUsers()]);
363
460
  log.logInfo(`Loaded ${this.channelCache.size} channels, ${this.userCache.size} users`);
461
+ // Backfill any messages missed while offline
462
+ await this.backfillAllChannels();
364
463
  await this.socketClient.start();
365
464
  log.logConnected();
366
465
  }
package/dist/slack.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"slack.js","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 { 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, log?: 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\t// Note: We don't log here - the message event handler logs all messages\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\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, log = 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\tif (isThinking) {\n\t\t\t\t\t\t// First real response replaces \"Thinking...\"\n\t\t\t\t\t\taccumulatedText = responseText;\n\t\t\t\t\t\tisThinking = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Subsequent responses get appended\n\t\t\t\t\t\taccumulatedText += \"\\n\" + responseText;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add working indicator if still working\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t// Update existing message\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Log the response if requested\n\t\t\t\t\tif (log) {\n\t\t\t\t\t\tawait this.store.logBotResponse(event.channel, responseText, messageTs!);\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\tif (!messageTs) {\n\t\t\t\t\t\t// No main message yet, just skip\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Obfuscate usernames to avoid pinging people in thread details\n\t\t\t\t\tconst obfuscatedText = this.obfuscateUsernames(threadText);\n\t\t\t\t\t// Post in thread under the main message\n\t\t\t\t\tawait this.webClient.chat.postMessage({\n\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\tthread_ts: messageTs,\n\t\t\t\t\t\ttext: obfuscatedText,\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\t// Replace the accumulated text entirely\n\t\t\t\t\taccumulatedText = text;\n\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\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\tisWorking = working;\n\n\t\t\t\t\t// If we have a message, update it to add/remove indicator\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\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\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\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.js","sourceRoot":"","sources":["../src/slack.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAqC,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC9E,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AAChC,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAChC,OAAO,EAAmB,YAAY,EAAE,MAAM,YAAY,CAAC;AAwD3D,MAAM,OAAO,MAAM;IACV,YAAY,CAAmB;IAC/B,SAAS,CAAY;IACrB,OAAO,CAAa;IACpB,SAAS,GAAkB,IAAI,CAAC;IACxB,KAAK,CAAe;IAC5B,SAAS,GAA2D,IAAI,GAAG,EAAE,CAAC;IAC9E,YAAY,GAAwB,IAAI,GAAG,EAAE,CAAC,CAAC,aAAa;IAEpE,YAAY,OAAmB,EAAE,MAAoB,EAAE;QACtD,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,YAAY,GAAG,IAAI,gBAAgB,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QACxE,IAAI,CAAC,SAAS,GAAG,IAAI,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAChD,IAAI,CAAC,KAAK,GAAG,IAAI,YAAY,CAAC;YAC7B,UAAU,EAAE,MAAM,CAAC,UAAU;YAC7B,QAAQ,EAAE,MAAM,CAAC,QAAQ;SACzB,CAAC,CAAC;QAEH,IAAI,CAAC,kBAAkB,EAAE,CAAC;IAAA,CAC1B;IAED;;OAEG;IACK,KAAK,CAAC,aAAa,GAAkB;QAC5C,IAAI,CAAC;YACJ,IAAI,MAA0B,CAAC;YAC/B,GAAG,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,IAAI,CAAC;oBACtD,KAAK,EAAE,gCAAgC;oBACvC,gBAAgB,EAAE,IAAI;oBACtB,KAAK,EAAE,GAAG;oBACV,MAAM;iBACN,CAAC,CAAC;gBAEH,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAkF,CAAC;gBAC3G,IAAI,QAAQ,EAAE,CAAC;oBACd,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;wBAChC,IAAI,OAAO,CAAC,EAAE,IAAI,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;4BACrD,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;wBACjD,CAAC;oBACF,CAAC;gBACF,CAAC;gBAED,MAAM,GAAG,MAAM,CAAC,iBAAiB,EAAE,WAAW,CAAC;YAChD,CAAC,QAAQ,MAAM,EAAE;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,GAAG,CAAC,UAAU,CAAC,0BAA0B,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAC3D,CAAC;IAAA,CACD;IAED;;OAEG;IACK,KAAK,CAAC,UAAU,GAAkB;QACzC,IAAI,CAAC;YACJ,IAAI,MAA0B,CAAC;YAC/B,GAAG,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC;oBAC9C,KAAK,EAAE,GAAG;oBACV,MAAM;iBACN,CAAC,CAAC;gBAEH,MAAM,OAAO,GAAG,MAAM,CAAC,OAEX,CAAC;gBACb,IAAI,OAAO,EAAE,CAAC;oBACb,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;wBAC5B,IAAI,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;4BAC3C,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE;gCAC3B,QAAQ,EAAE,IAAI,CAAC,IAAI;gCACnB,WAAW,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,IAAI;6BACxC,CAAC,CAAC;wBACJ,CAAC;oBACF,CAAC;gBACF,CAAC;gBAED,MAAM,GAAG,MAAM,CAAC,iBAAiB,EAAE,WAAW,CAAC;YAChD,CAAC,QAAQ,MAAM,EAAE;QAClB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,GAAG,CAAC,UAAU,CAAC,uBAAuB,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACxD,CAAC;IAAA,CACD;IAED;;OAEG;IACH,WAAW,GAAkB;QAC5B,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAAA,CACnF;IAED;;OAEG;IACH,QAAQ,GAAe;QACtB,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;YACrF,EAAE;YACF,QAAQ;YACR,WAAW;SACX,CAAC,CAAC,CAAC;IAAA,CACJ;IAED;;;OAGG;IACK,kBAAkB,CAAC,IAAY,EAAU;QAChD,IAAI,MAAM,GAAG,IAAI,CAAC;QAElB,uCAAuC;QACvC,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,kBAAkB,EAAE,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,CAAC;YAC3D,OAAO,KAAK,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;QAAA,CACtC,CAAC,CAAC;QAEH,sBAAsB;QACtB,KAAK,MAAM,EAAE,QAAQ,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;YACpD,8CAA8C;YAC9C,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;YAChE,mFAAmF;YACnF,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,cAAc,OAAO,MAAM,EAAE,IAAI,CAAC,CAAC;YAC9D,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;gBAC1D,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBAC5C,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC,GAAG,UAAU,CAAC;YAAA,CACnC,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,MAAM,CAAC;IAAA,CACd;IAEO,KAAK,CAAC,WAAW,CAAC,MAAc,EAAsD;QAC7F,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC;QACpC,CAAC;QAED,IAAI,CAAC;YACJ,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;YACjE,MAAM,IAAI,GAAG,MAAM,CAAC,IAA6C,CAAC;YAClE,MAAM,IAAI,GAAG;gBACZ,QAAQ,EAAE,IAAI,EAAE,IAAI,IAAI,MAAM;gBAC9B,WAAW,EAAE,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE,IAAI,IAAI,MAAM;aACpD,CAAC;YACF,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;YACjC,OAAO,IAAI,CAAC;QACb,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC;QAClD,CAAC;IAAA,CACD;IAEO,kBAAkB,GAAS;QAClC,+BAA+B;QAC/B,wEAAwE;QACxE,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC,aAAa,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;YAC7D,MAAM,GAAG,EAAE,CAAC;YAEZ,MAAM,UAAU,GAAG,KAMlB,CAAC;YAEF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC;YACjD,MAAM,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QAAA,CACzC,CAAC,CAAC;QAEH,qEAAqE;QACrE,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;YACzD,MAAM,GAAG,EAAE,CAAC;YAEZ,MAAM,UAAU,GAAG,KASlB,CAAC;YAEF,sBAAsB;YACtB,IAAI,UAAU,CAAC,MAAM;gBAAE,OAAO;YAC9B,oDAAoD;YACpD,IAAI,UAAU,CAAC,OAAO,KAAK,SAAS,IAAI,UAAU,CAAC,OAAO,KAAK,YAAY;gBAAE,OAAO;YACpF,oBAAoB;YACpB,IAAI,CAAC,UAAU,CAAC,IAAI;gBAAE,OAAO;YAC7B,sCAAsC;YACtC,IAAI,UAAU,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS;gBAAE,OAAO;YAC/C,iCAAiC;YACjC,IAAI,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC;gBAAE,OAAO;YAErF,oCAAoC;YACpC,MAAM,IAAI,CAAC,UAAU,CAAC;gBACrB,IAAI,EAAE,UAAU,CAAC,IAAI,IAAI,EAAE;gBAC3B,OAAO,EAAE,UAAU,CAAC,OAAO;gBAC3B,IAAI,EAAE,UAAU,CAAC,IAAI;gBACrB,EAAE,EAAE,UAAU,CAAC,EAAE;gBACjB,KAAK,EAAE,UAAU,CAAC,KAAK;aACvB,CAAC,CAAC;YAEH,mFAAmF;YACnF,IAAI,UAAU,CAAC,YAAY,KAAK,IAAI,EAAE,CAAC;gBACtC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC;oBACpC,IAAI,EAAE,UAAU,CAAC,IAAI,IAAI,EAAE;oBAC3B,OAAO,EAAE,UAAU,CAAC,OAAO;oBAC3B,IAAI,EAAE,UAAU,CAAC,IAAI;oBACrB,EAAE,EAAE,UAAU,CAAC,EAAE;oBACjB,KAAK,EAAE,UAAU,CAAC,KAAK;iBACvB,CAAC,CAAC;gBACH,MAAM,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;YACzC,CAAC;QAAA,CACD,CAAC,CAAC;IAAA,CACH;IAEO,KAAK,CAAC,UAAU,CAAC,KAMxB,EAAiB;QACjB,MAAM,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3G,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAErE,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,OAAO,EAAE;YAC1C,IAAI,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;YACzD,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,QAAQ;YACR,WAAW;YACX,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,WAAW;YACX,KAAK,EAAE,KAAK;SACZ,CAAC,CAAC;IAAA,CACH;IAEO,KAAK,CAAC,aAAa,CAAC,KAM3B,EAAyB;QACzB,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC;QAC3B,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAE1D,4BAA4B;QAC5B,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAExD,6CAA6C;QAC7C,IAAI,WAA+B,CAAC;QACpC,IAAI,CAAC;YACJ,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;gBACnC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;gBACnF,WAAW,GAAG,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YAC5E,CAAC;QACF,CAAC;QAAC,MAAM,CAAC;YACR,gDAAgD;QACjD,CAAC;QAED,uEAAuE;QACvE,MAAM,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAE3G,wCAAwC;QACxC,IAAI,SAAS,GAAkB,IAAI,CAAC;QACpC,IAAI,eAAe,GAAG,EAAE,CAAC;QACzB,IAAI,UAAU,GAAG,IAAI,CAAC,CAAC,2CAA2C;QAClE,IAAI,SAAS,GAAG,IAAI,CAAC,CAAC,4BAA4B;QAClD,MAAM,gBAAgB,GAAG,MAAM,CAAC;QAChC,IAAI,aAAa,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAC;QAErD,OAAO;YACN,OAAO,EAAE;gBACR,IAAI;gBACJ,OAAO;gBACP,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,QAAQ;gBACR,OAAO,EAAE,KAAK,CAAC,OAAO;gBACtB,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,WAAW;aACX;YACD,WAAW;YACX,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE;YAC5B,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE;YACtB,OAAO,EAAE,KAAK,EAAE,YAAoB,EAAE,GAAG,GAAG,IAAI,EAAE,EAAE,CAAC;gBACpD,yCAAyC;gBACzC,aAAa,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC9C,IAAI,UAAU,EAAE,CAAC;wBAChB,6CAA6C;wBAC7C,eAAe,GAAG,YAAY,CAAC;wBAC/B,UAAU,GAAG,KAAK,CAAC;oBACpB,CAAC;yBAAM,CAAC;wBACP,oCAAoC;wBACpC,eAAe,IAAI,IAAI,GAAG,YAAY,CAAC;oBACxC,CAAC;oBAED,yCAAyC;oBACzC,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,CAAC,eAAe,GAAG,gBAAgB,CAAC,CAAC,CAAC,eAAe,CAAC;oBAErF,IAAI,SAAS,EAAE,CAAC;wBACf,0BAA0B;wBAC1B,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC;4BAChC,OAAO,EAAE,KAAK,CAAC,OAAO;4BACtB,EAAE,EAAE,SAAS;4BACb,IAAI,EAAE,WAAW;yBACjB,CAAC,CAAC;oBACJ,CAAC;yBAAM,CAAC;wBACP,uBAAuB;wBACvB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;4BACpD,OAAO,EAAE,KAAK,CAAC,OAAO;4BACtB,IAAI,EAAE,WAAW;yBACjB,CAAC,CAAC;wBACH,SAAS,GAAG,MAAM,CAAC,EAAY,CAAC;oBACjC,CAAC;oBAED,gCAAgC;oBAChC,IAAI,GAAG,EAAE,CAAC;wBACT,MAAM,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,KAAK,CAAC,OAAO,EAAE,YAAY,EAAE,SAAU,CAAC,CAAC;oBAC1E,CAAC;gBAAA,CACD,CAAC,CAAC;gBAEH,MAAM,aAAa,CAAC;YAAA,CACpB;YACD,eAAe,EAAE,KAAK,EAAE,UAAkB,EAAE,EAAE,CAAC;gBAC9C,uCAAuC;gBACvC,aAAa,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC9C,IAAI,CAAC,SAAS,EAAE,CAAC;wBAChB,iCAAiC;wBACjC,OAAO;oBACR,CAAC;oBACD,gEAAgE;oBAChE,MAAM,cAAc,GAAG,IAAI,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC;oBAC3D,wCAAwC;oBACxC,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;wBACrC,OAAO,EAAE,KAAK,CAAC,OAAO;wBACtB,SAAS,EAAE,SAAS;wBACpB,IAAI,EAAE,cAAc;qBACpB,CAAC,CAAC;gBAAA,CACH,CAAC,CAAC;gBACH,MAAM,aAAa,CAAC;YAAA,CACpB;YACD,SAAS,EAAE,KAAK,EAAE,QAAiB,EAAE,EAAE,CAAC;gBACvC,IAAI,QAAQ,IAAI,CAAC,SAAS,EAAE,CAAC;oBAC5B,2EAA2E;oBAC3E,eAAe,GAAG,YAAY,CAAC;oBAC/B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;wBACpD,OAAO,EAAE,KAAK,CAAC,OAAO;wBACtB,IAAI,EAAE,eAAe;qBACrB,CAAC,CAAC;oBACH,SAAS,GAAG,MAAM,CAAC,EAAY,CAAC;gBACjC,CAAC;gBACD,oEAAoE;YADnE,CAED;YACD,UAAU,EAAE,KAAK,EAAE,QAAgB,EAAE,KAAc,EAAE,EAAE,CAAC;gBACvD,MAAM,QAAQ,GAAG,KAAK,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC;gBAC7C,MAAM,WAAW,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;gBAE3C,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,QAAQ,CAAC;oBACnC,UAAU,EAAE,KAAK,CAAC,OAAO;oBACzB,IAAI,EAAE,WAAW;oBACjB,QAAQ,EAAE,QAAQ;oBAClB,KAAK,EAAE,QAAQ;iBACf,CAAC,CAAC;YAAA,CACH;YACD,cAAc,EAAE,KAAK,EAAE,IAAY,EAAE,EAAE,CAAC;gBACvC,aAAa,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC9C,wCAAwC;oBACxC,eAAe,GAAG,IAAI,CAAC;oBAEvB,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,CAAC,eAAe,GAAG,gBAAgB,CAAC,CAAC,CAAC,eAAe,CAAC;oBAErF,IAAI,SAAS,EAAE,CAAC;wBACf,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC;4BAChC,OAAO,EAAE,KAAK,CAAC,OAAO;4BACtB,EAAE,EAAE,SAAS;4BACb,IAAI,EAAE,WAAW;yBACjB,CAAC,CAAC;oBACJ,CAAC;yBAAM,CAAC;wBACP,uBAAuB;wBACvB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC;4BACpD,OAAO,EAAE,KAAK,CAAC,OAAO;4BACtB,IAAI,EAAE,WAAW;yBACjB,CAAC,CAAC;wBACH,SAAS,GAAG,MAAM,CAAC,EAAY,CAAC;oBACjC,CAAC;gBAAA,CACD,CAAC,CAAC;gBACH,MAAM,aAAa,CAAC;YAAA,CACpB;YACD,UAAU,EAAE,KAAK,EAAE,OAAgB,EAAE,EAAE,CAAC;gBACvC,aAAa,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC9C,SAAS,GAAG,OAAO,CAAC;oBAEpB,0DAA0D;oBAC1D,IAAI,SAAS,EAAE,CAAC;wBACf,MAAM,WAAW,GAAG,SAAS,CAAC,CAAC,CAAC,eAAe,GAAG,gBAAgB,CAAC,CAAC,CAAC,eAAe,CAAC;wBACrF,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC;4BAChC,OAAO,EAAE,KAAK,CAAC,OAAO;4BACtB,EAAE,EAAE,SAAS;4BACb,IAAI,EAAE,WAAW;yBACjB,CAAC,CAAC;oBACJ,CAAC;gBAAA,CACD,CAAC,CAAC;gBACH,MAAM,aAAa,CAAC;YAAA,CACpB;SACD,CAAC;IAAA,CACF;IAED;;;OAGG;IACK,KAAK,CAAC,eAAe,CAAC,SAAiB,EAAmB;QACjE,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC;QAItD,MAAM,WAAW,GAAc,EAAE,CAAC;QAElC,IAAI,MAA0B,CAAC;QAC/B,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,MAAM,QAAQ,GAAG,CAAC,CAAC;QAEnB,GAAG,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,OAAO,CAAC;gBACzD,OAAO,EAAE,SAAS;gBAClB,MAAM,EAAE,MAAM,IAAI,SAAS;gBAC3B,SAAS,EAAE,KAAK;gBAChB,KAAK,EAAE,IAAI;gBACX,MAAM;aACN,CAAC,CAAC;YAEH,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;gBACrB,WAAW,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;YACtC,CAAC;YAED,MAAM,GAAG,MAAM,CAAC,iBAAiB,EAAE,WAAW,CAAC;YAC/C,SAAS,EAAE,CAAC;QACb,CAAC,QAAQ,MAAM,IAAI,SAAS,GAAG,QAAQ,EAAE;QAEzC,8DAA8D;QAC9D,MAAM,gBAAgB,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC;YACpD,oCAAoC;YACpC,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS;gBAAE,OAAO,IAAI,CAAC;YAC7C,6BAA6B;YAC7B,IAAI,GAAG,CAAC,MAAM;gBAAE,OAAO,KAAK,CAAC;YAC7B,qCAAqC;YACrC,IAAI,GAAG,CAAC,OAAO,KAAK,SAAS,IAAI,GAAG,CAAC,OAAO,KAAK,YAAY;gBAAE,OAAO,KAAK,CAAC;YAC5E,IAAI,CAAC,GAAG,CAAC,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC5B,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC;gBAAE,OAAO,KAAK,CAAC;YACtE,OAAO,IAAI,CAAC;QAAA,CACZ,CAAC,CAAC;QAEH,4DAA4D;QAC5D,gBAAgB,CAAC,OAAO,EAAE,CAAC;QAE3B,mBAAmB;QACnB,KAAK,MAAM,GAAG,IAAI,gBAAgB,EAAE,CAAC;YACpC,MAAM,YAAY,GAAG,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC,SAAS,CAAC;YACjD,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,SAAS,EAAE,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,EAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAElG,IAAI,YAAY,EAAE,CAAC;gBAClB,oCAAoC;gBACpC,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,SAAS,EAAE;oBACtC,IAAI,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAG,CAAC,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;oBACxD,EAAE,EAAE,GAAG,CAAC,EAAG;oBACX,IAAI,EAAE,KAAK;oBACX,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE;oBACpB,WAAW;oBACX,KAAK,EAAE,IAAI;iBACX,CAAC,CAAC;YACJ,CAAC;iBAAM,CAAC;gBACP,mBAAmB;gBACnB,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAK,CAAC,CAAC;gBACpE,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,SAAS,EAAE;oBACtC,IAAI,EAAE,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAG,CAAC,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;oBACxD,EAAE,EAAE,GAAG,CAAC,EAAG;oBACX,IAAI,EAAE,GAAG,CAAC,IAAK;oBACf,QAAQ;oBACR,WAAW;oBACX,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE;oBACpB,WAAW;oBACX,KAAK,EAAE,KAAK;iBACZ,CAAC,CAAC;YACJ,CAAC;QACF,CAAC;QAED,OAAO,gBAAgB,CAAC,MAAM,CAAC;IAAA,CAC/B;IAED;;OAEG;IACK,KAAK,CAAC,mBAAmB,GAAkB;QAClD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAC7B,GAAG,CAAC,gBAAgB,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QAE7C,IAAI,aAAa,GAAG,CAAC,CAAC;QAEtB,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YAC1D,IAAI,CAAC;gBACJ,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC;gBACpD,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;oBACf,GAAG,CAAC,kBAAkB,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;gBAC5C,CAAC;gBACD,aAAa,IAAI,KAAK,CAAC;YACxB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,GAAG,CAAC,UAAU,CAAC,+BAA+B,WAAW,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;YAC7E,CAAC;QACF,CAAC;QAED,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;QAC1C,GAAG,CAAC,mBAAmB,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IAAA,CACnD;IAED,KAAK,CAAC,KAAK,GAAkB;QAC5B,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,OAAiB,CAAC;QAExC,uCAAuC;QACvC,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;QAC7D,GAAG,CAAC,OAAO,CAAC,UAAU,IAAI,CAAC,YAAY,CAAC,IAAI,cAAc,IAAI,CAAC,SAAS,CAAC,IAAI,QAAQ,CAAC,CAAC;QAEvF,6CAA6C;QAC7C,MAAM,IAAI,CAAC,mBAAmB,EAAE,CAAC;QAEjC,MAAM,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC;QAChC,GAAG,CAAC,YAAY,EAAE,CAAC;IAAA,CACnB;IAED,KAAK,CAAC,IAAI,GAAkB;QAC3B,MAAM,IAAI,CAAC,YAAY,CAAC,UAAU,EAAE,CAAC;QACrC,GAAG,CAAC,eAAe,EAAE,CAAC;IAAA,CACtB;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, log?: 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\t// Note: We don't log here - the message event handler logs all messages\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\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, log = 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\tif (isThinking) {\n\t\t\t\t\t\t// First real response replaces \"Thinking...\"\n\t\t\t\t\t\taccumulatedText = responseText;\n\t\t\t\t\t\tisThinking = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Subsequent responses get appended\n\t\t\t\t\t\taccumulatedText += \"\\n\" + responseText;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add working indicator if still working\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\t// Update existing message\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Log the response if requested\n\t\t\t\t\tif (log) {\n\t\t\t\t\t\tawait this.store.logBotResponse(event.channel, responseText, messageTs!);\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\tif (!messageTs) {\n\t\t\t\t\t\t// No main message yet, just skip\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Obfuscate usernames to avoid pinging people in thread details\n\t\t\t\t\tconst obfuscatedText = this.obfuscateUsernames(threadText);\n\t\t\t\t\t// Post in thread under the main message\n\t\t\t\t\tawait this.webClient.chat.postMessage({\n\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\tthread_ts: messageTs,\n\t\t\t\t\t\ttext: obfuscatedText,\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\t// Replace the accumulated text entirely\n\t\t\t\t\taccumulatedText = text;\n\n\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Post initial message\n\t\t\t\t\t\tconst result = await this.webClient.chat.postMessage({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tmessageTs = result.ts as string;\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\tisWorking = working;\n\n\t\t\t\t\t// If we have a message, update it to add/remove indicator\n\t\t\t\t\tif (messageTs) {\n\t\t\t\t\t\tconst displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;\n\t\t\t\t\t\tawait this.webClient.chat.update({\n\t\t\t\t\t\t\tchannel: event.channel,\n\t\t\t\t\t\t\tts: messageTs,\n\t\t\t\t\t\t\ttext: displayText,\n\t\t\t\t\t\t});\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"]}
package/dist/store.d.ts CHANGED
@@ -35,7 +35,7 @@ export declare class ChannelStore {
35
35
  * Returns attachment metadata and queues downloads
36
36
  */
37
37
  processAttachments(channelId: string, files: Array<{
38
- name: string;
38
+ name?: string;
39
39
  url_private_download?: string;
40
40
  url_private?: string;
41
41
  }>, timestamp: string): Attachment[];
@@ -47,6 +47,11 @@ export declare class ChannelStore {
47
47
  * Log a bot response
48
48
  */
49
49
  logBotResponse(channelId: string, text: string, ts: string): Promise<void>;
50
+ /**
51
+ * Get the timestamp of the last logged message for a channel
52
+ * Returns null if no log exists
53
+ */
54
+ getLastTimestamp(channelId: string): string | null;
50
55
  private processDownloadQueue;
51
56
  private downloadAttachment;
52
57
  }
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAQD,qBAAa,YAAY;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,gBAAgB,CAAyB;IACjD,OAAO,CAAC,aAAa,CAAS;IAE9B,YAAY,MAAM,EAAE,kBAAkB,EAQrC;IAED;;OAEG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMvC;IAED;;OAEG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMrE;IAED;;;OAGG;IACH,kBAAkB,CACjB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACnF,SAAS,EAAE,MAAM,GACf,UAAU,EAAE,CAuBd;IAED;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAmBzE;IAED;;OAEG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;YAKa,oBAAoB;YAwBpB,kBAAkB;CAsBhC","sourcesContent":["import { existsSync, mkdirSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n\toriginal: string; // original filename from uploader\n\tlocal: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n\tdate: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n\tts: string; // slack timestamp or epoch ms\n\tuser: string; // user ID (or \"bot\" for bot responses)\n\tuserName?: string; // handle (e.g., \"mario\")\n\tdisplayName?: string; // display name (e.g., \"Mario Zechner\")\n\ttext: string;\n\tattachments: Attachment[];\n\tisBot: boolean;\n}\n\nexport interface ChannelStoreConfig {\n\tworkingDir: string;\n\tbotToken: string; // needed for authenticated file downloads\n}\n\ninterface PendingDownload {\n\tchannelId: string;\n\tlocalPath: string; // relative path\n\turl: string;\n}\n\nexport class ChannelStore {\n\tprivate workingDir: string;\n\tprivate botToken: string;\n\tprivate pendingDownloads: PendingDownload[] = [];\n\tprivate isDownloading = false;\n\n\tconstructor(config: ChannelStoreConfig) {\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.botToken = config.botToken;\n\n\t\t// Ensure working directory exists\n\t\tif (!existsSync(this.workingDir)) {\n\t\t\tmkdirSync(this.workingDir, { recursive: true });\n\t\t}\n\t}\n\n\t/**\n\t * Get or create the directory for a channel/DM\n\t */\n\tgetChannelDir(channelId: string): string {\n\t\tconst dir = join(this.workingDir, channelId);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\t/**\n\t * Generate a unique local filename for an attachment\n\t */\n\tgenerateLocalFilename(originalName: string, timestamp: string): string {\n\t\t// Convert slack timestamp (1234567890.123456) to milliseconds\n\t\tconst ts = Math.floor(parseFloat(timestamp) * 1000);\n\t\t// Sanitize original name (remove problematic characters)\n\t\tconst sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n\t\treturn `${ts}_${sanitized}`;\n\t}\n\n\t/**\n\t * Process attachments from a Slack message event\n\t * Returns attachment metadata and queues downloads\n\t */\n\tprocessAttachments(\n\t\tchannelId: string,\n\t\tfiles: Array<{ name: string; url_private_download?: string; url_private?: string }>,\n\t\ttimestamp: string,\n\t): Attachment[] {\n\t\tconst attachments: Attachment[] = [];\n\n\t\tfor (const file of files) {\n\t\t\tconst url = file.url_private_download || file.url_private;\n\t\t\tif (!url) continue;\n\n\t\t\tconst filename = this.generateLocalFilename(file.name, timestamp);\n\t\t\tconst localPath = `${channelId}/attachments/${filename}`;\n\n\t\t\tattachments.push({\n\t\t\t\toriginal: file.name,\n\t\t\t\tlocal: localPath,\n\t\t\t});\n\n\t\t\t// Queue for background download\n\t\t\tthis.pendingDownloads.push({ channelId, localPath, url });\n\t\t}\n\n\t\t// Trigger background download\n\t\tthis.processDownloadQueue();\n\n\t\treturn attachments;\n\t}\n\n\t/**\n\t * Log a message to the channel's log.jsonl\n\t */\n\tasync logMessage(channelId: string, message: LoggedMessage): Promise<void> {\n\t\tconst logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n\t\t// Ensure message has a date field\n\t\tif (!message.date) {\n\t\t\t// Parse timestamp to get date\n\t\t\tlet date: Date;\n\t\t\tif (message.ts.includes(\".\")) {\n\t\t\t\t// Slack timestamp format (1234567890.123456)\n\t\t\t\tdate = new Date(parseFloat(message.ts) * 1000);\n\t\t\t} else {\n\t\t\t\t// Epoch milliseconds\n\t\t\t\tdate = new Date(parseInt(message.ts, 10));\n\t\t\t}\n\t\t\tmessage.date = date.toISOString();\n\t\t}\n\n\t\tconst line = JSON.stringify(message) + \"\\n\";\n\t\tawait appendFile(logPath, line, \"utf-8\");\n\t}\n\n\t/**\n\t * Log a bot response\n\t */\n\tasync logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n\t\tawait this.logMessage(channelId, {\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 * Process the download queue in the background\n\t */\n\tprivate async processDownloadQueue(): Promise<void> {\n\t\tif (this.isDownloading || this.pendingDownloads.length === 0) return;\n\n\t\tthis.isDownloading = true;\n\n\t\twhile (this.pendingDownloads.length > 0) {\n\t\t\tconst item = this.pendingDownloads.shift();\n\t\t\tif (!item) break;\n\n\t\t\ttry {\n\t\t\t\tawait this.downloadAttachment(item.localPath, item.url);\n\t\t\t\t// Success - could add success logging here if we have context\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\tlog.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);\n\t\t\t}\n\t\t}\n\n\t\tthis.isDownloading = false;\n\t}\n\n\t/**\n\t * Download a single attachment\n\t */\n\tprivate async downloadAttachment(localPath: string, url: string): Promise<void> {\n\t\tconst filePath = join(this.workingDir, localPath);\n\n\t\t// Ensure directory exists\n\t\tconst dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\tconst response = await fetch(url, {\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${this.botToken}`,\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`HTTP ${response.status}: ${response.statusText}`);\n\t\t}\n\n\t\tconst buffer = await response.arrayBuffer();\n\t\tawait writeFile(filePath, Buffer.from(buffer));\n\t}\n}\n"]}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAQD,qBAAa,YAAY;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,gBAAgB,CAAyB;IACjD,OAAO,CAAC,aAAa,CAAS;IAE9B,YAAY,MAAM,EAAE,kBAAkB,EAQrC;IAED;;OAEG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMvC;IAED;;OAEG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMrE;IAED;;;OAGG;IACH,kBAAkB,CACjB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACpF,SAAS,EAAE,MAAM,GACf,UAAU,EAAE,CA2Bd;IAED;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAmBzE;IAED;;OAEG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;IAED;;;OAGG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBjD;YAKa,oBAAoB;YAwBpB,kBAAkB;CAsBhC","sourcesContent":["import { existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n\toriginal: string; // original filename from uploader\n\tlocal: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n\tdate: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n\tts: string; // slack timestamp or epoch ms\n\tuser: string; // user ID (or \"bot\" for bot responses)\n\tuserName?: string; // handle (e.g., \"mario\")\n\tdisplayName?: string; // display name (e.g., \"Mario Zechner\")\n\ttext: string;\n\tattachments: Attachment[];\n\tisBot: boolean;\n}\n\nexport interface ChannelStoreConfig {\n\tworkingDir: string;\n\tbotToken: string; // needed for authenticated file downloads\n}\n\ninterface PendingDownload {\n\tchannelId: string;\n\tlocalPath: string; // relative path\n\turl: string;\n}\n\nexport class ChannelStore {\n\tprivate workingDir: string;\n\tprivate botToken: string;\n\tprivate pendingDownloads: PendingDownload[] = [];\n\tprivate isDownloading = false;\n\n\tconstructor(config: ChannelStoreConfig) {\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.botToken = config.botToken;\n\n\t\t// Ensure working directory exists\n\t\tif (!existsSync(this.workingDir)) {\n\t\t\tmkdirSync(this.workingDir, { recursive: true });\n\t\t}\n\t}\n\n\t/**\n\t * Get or create the directory for a channel/DM\n\t */\n\tgetChannelDir(channelId: string): string {\n\t\tconst dir = join(this.workingDir, channelId);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\t/**\n\t * Generate a unique local filename for an attachment\n\t */\n\tgenerateLocalFilename(originalName: string, timestamp: string): string {\n\t\t// Convert slack timestamp (1234567890.123456) to milliseconds\n\t\tconst ts = Math.floor(parseFloat(timestamp) * 1000);\n\t\t// Sanitize original name (remove problematic characters)\n\t\tconst sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n\t\treturn `${ts}_${sanitized}`;\n\t}\n\n\t/**\n\t * Process attachments from a Slack message event\n\t * Returns attachment metadata and queues downloads\n\t */\n\tprocessAttachments(\n\t\tchannelId: string,\n\t\tfiles: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n\t\ttimestamp: string,\n\t): Attachment[] {\n\t\tconst attachments: Attachment[] = [];\n\n\t\tfor (const file of files) {\n\t\t\tconst url = file.url_private_download || file.url_private;\n\t\t\tif (!url) continue;\n\t\t\tif (!file.name) {\n\t\t\t\tlog.logWarning(\"Attachment missing name, skipping\", url);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst filename = this.generateLocalFilename(file.name, timestamp);\n\t\t\tconst localPath = `${channelId}/attachments/${filename}`;\n\n\t\t\tattachments.push({\n\t\t\t\toriginal: file.name,\n\t\t\t\tlocal: localPath,\n\t\t\t});\n\n\t\t\t// Queue for background download\n\t\t\tthis.pendingDownloads.push({ channelId, localPath, url });\n\t\t}\n\n\t\t// Trigger background download\n\t\tthis.processDownloadQueue();\n\n\t\treturn attachments;\n\t}\n\n\t/**\n\t * Log a message to the channel's log.jsonl\n\t */\n\tasync logMessage(channelId: string, message: LoggedMessage): Promise<void> {\n\t\tconst logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n\t\t// Ensure message has a date field\n\t\tif (!message.date) {\n\t\t\t// Parse timestamp to get date\n\t\t\tlet date: Date;\n\t\t\tif (message.ts.includes(\".\")) {\n\t\t\t\t// Slack timestamp format (1234567890.123456)\n\t\t\t\tdate = new Date(parseFloat(message.ts) * 1000);\n\t\t\t} else {\n\t\t\t\t// Epoch milliseconds\n\t\t\t\tdate = new Date(parseInt(message.ts, 10));\n\t\t\t}\n\t\t\tmessage.date = date.toISOString();\n\t\t}\n\n\t\tconst line = JSON.stringify(message) + \"\\n\";\n\t\tawait appendFile(logPath, line, \"utf-8\");\n\t}\n\n\t/**\n\t * Log a bot response\n\t */\n\tasync logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n\t\tawait this.logMessage(channelId, {\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 * Get the timestamp of the last logged message for a channel\n\t * Returns null if no log exists\n\t */\n\tgetLastTimestamp(channelId: string): string | null {\n\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\tif (!existsSync(logPath)) {\n\t\t\treturn null;\n\t\t}\n\n\t\ttry {\n\t\t\tconst content = readFileSync(logPath, \"utf-8\");\n\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\tif (lines.length === 0 || lines[0] === \"\") {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst lastLine = lines[lines.length - 1];\n\t\t\tconst message = JSON.parse(lastLine) as LoggedMessage;\n\t\t\treturn message.ts;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Process the download queue in the background\n\t */\n\tprivate async processDownloadQueue(): Promise<void> {\n\t\tif (this.isDownloading || this.pendingDownloads.length === 0) return;\n\n\t\tthis.isDownloading = true;\n\n\t\twhile (this.pendingDownloads.length > 0) {\n\t\t\tconst item = this.pendingDownloads.shift();\n\t\t\tif (!item) break;\n\n\t\t\ttry {\n\t\t\t\tawait this.downloadAttachment(item.localPath, item.url);\n\t\t\t\t// Success - could add success logging here if we have context\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\tlog.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);\n\t\t\t}\n\t\t}\n\n\t\tthis.isDownloading = false;\n\t}\n\n\t/**\n\t * Download a single attachment\n\t */\n\tprivate async downloadAttachment(localPath: string, url: string): Promise<void> {\n\t\tconst filePath = join(this.workingDir, localPath);\n\n\t\t// Ensure directory exists\n\t\tconst dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\tconst response = await fetch(url, {\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${this.botToken}`,\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`HTTP ${response.status}: ${response.statusText}`);\n\t\t}\n\n\t\tconst buffer = await response.arrayBuffer();\n\t\tawait writeFile(filePath, Buffer.from(buffer));\n\t}\n}\n"]}
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync } from "fs";
1
+ import { existsSync, mkdirSync, readFileSync } from "fs";
2
2
  import { appendFile, writeFile } from "fs/promises";
3
3
  import { join } from "path";
4
4
  import * as log from "./log.js";
@@ -45,6 +45,10 @@ export class ChannelStore {
45
45
  const url = file.url_private_download || file.url_private;
46
46
  if (!url)
47
47
  continue;
48
+ if (!file.name) {
49
+ log.logWarning("Attachment missing name, skipping", url);
50
+ continue;
51
+ }
48
52
  const filename = this.generateLocalFilename(file.name, timestamp);
49
53
  const localPath = `${channelId}/attachments/${filename}`;
50
54
  attachments.push({
@@ -93,6 +97,29 @@ export class ChannelStore {
93
97
  isBot: true,
94
98
  });
95
99
  }
100
+ /**
101
+ * Get the timestamp of the last logged message for a channel
102
+ * Returns null if no log exists
103
+ */
104
+ getLastTimestamp(channelId) {
105
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
106
+ if (!existsSync(logPath)) {
107
+ return null;
108
+ }
109
+ try {
110
+ const content = readFileSync(logPath, "utf-8");
111
+ const lines = content.trim().split("\n");
112
+ if (lines.length === 0 || lines[0] === "") {
113
+ return null;
114
+ }
115
+ const lastLine = lines[lines.length - 1];
116
+ const message = JSON.parse(lastLine);
117
+ return message.ts;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
96
123
  /**
97
124
  * Process the download queue in the background
98
125
  */
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n\toriginal: string; // original filename from uploader\n\tlocal: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n\tdate: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n\tts: string; // slack timestamp or epoch ms\n\tuser: string; // user ID (or \"bot\" for bot responses)\n\tuserName?: string; // handle (e.g., \"mario\")\n\tdisplayName?: string; // display name (e.g., \"Mario Zechner\")\n\ttext: string;\n\tattachments: Attachment[];\n\tisBot: boolean;\n}\n\nexport interface ChannelStoreConfig {\n\tworkingDir: string;\n\tbotToken: string; // needed for authenticated file downloads\n}\n\ninterface PendingDownload {\n\tchannelId: string;\n\tlocalPath: string; // relative path\n\turl: string;\n}\n\nexport class ChannelStore {\n\tprivate workingDir: string;\n\tprivate botToken: string;\n\tprivate pendingDownloads: PendingDownload[] = [];\n\tprivate isDownloading = false;\n\n\tconstructor(config: ChannelStoreConfig) {\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.botToken = config.botToken;\n\n\t\t// Ensure working directory exists\n\t\tif (!existsSync(this.workingDir)) {\n\t\t\tmkdirSync(this.workingDir, { recursive: true });\n\t\t}\n\t}\n\n\t/**\n\t * Get or create the directory for a channel/DM\n\t */\n\tgetChannelDir(channelId: string): string {\n\t\tconst dir = join(this.workingDir, channelId);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\t/**\n\t * Generate a unique local filename for an attachment\n\t */\n\tgenerateLocalFilename(originalName: string, timestamp: string): string {\n\t\t// Convert slack timestamp (1234567890.123456) to milliseconds\n\t\tconst ts = Math.floor(parseFloat(timestamp) * 1000);\n\t\t// Sanitize original name (remove problematic characters)\n\t\tconst sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n\t\treturn `${ts}_${sanitized}`;\n\t}\n\n\t/**\n\t * Process attachments from a Slack message event\n\t * Returns attachment metadata and queues downloads\n\t */\n\tprocessAttachments(\n\t\tchannelId: string,\n\t\tfiles: Array<{ name: string; url_private_download?: string; url_private?: string }>,\n\t\ttimestamp: string,\n\t): Attachment[] {\n\t\tconst attachments: Attachment[] = [];\n\n\t\tfor (const file of files) {\n\t\t\tconst url = file.url_private_download || file.url_private;\n\t\t\tif (!url) continue;\n\n\t\t\tconst filename = this.generateLocalFilename(file.name, timestamp);\n\t\t\tconst localPath = `${channelId}/attachments/${filename}`;\n\n\t\t\tattachments.push({\n\t\t\t\toriginal: file.name,\n\t\t\t\tlocal: localPath,\n\t\t\t});\n\n\t\t\t// Queue for background download\n\t\t\tthis.pendingDownloads.push({ channelId, localPath, url });\n\t\t}\n\n\t\t// Trigger background download\n\t\tthis.processDownloadQueue();\n\n\t\treturn attachments;\n\t}\n\n\t/**\n\t * Log a message to the channel's log.jsonl\n\t */\n\tasync logMessage(channelId: string, message: LoggedMessage): Promise<void> {\n\t\tconst logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n\t\t// Ensure message has a date field\n\t\tif (!message.date) {\n\t\t\t// Parse timestamp to get date\n\t\t\tlet date: Date;\n\t\t\tif (message.ts.includes(\".\")) {\n\t\t\t\t// Slack timestamp format (1234567890.123456)\n\t\t\t\tdate = new Date(parseFloat(message.ts) * 1000);\n\t\t\t} else {\n\t\t\t\t// Epoch milliseconds\n\t\t\t\tdate = new Date(parseInt(message.ts, 10));\n\t\t\t}\n\t\t\tmessage.date = date.toISOString();\n\t\t}\n\n\t\tconst line = JSON.stringify(message) + \"\\n\";\n\t\tawait appendFile(logPath, line, \"utf-8\");\n\t}\n\n\t/**\n\t * Log a bot response\n\t */\n\tasync logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n\t\tawait this.logMessage(channelId, {\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 * Process the download queue in the background\n\t */\n\tprivate async processDownloadQueue(): Promise<void> {\n\t\tif (this.isDownloading || this.pendingDownloads.length === 0) return;\n\n\t\tthis.isDownloading = true;\n\n\t\twhile (this.pendingDownloads.length > 0) {\n\t\t\tconst item = this.pendingDownloads.shift();\n\t\t\tif (!item) break;\n\n\t\t\ttry {\n\t\t\t\tawait this.downloadAttachment(item.localPath, item.url);\n\t\t\t\t// Success - could add success logging here if we have context\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\tlog.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);\n\t\t\t}\n\t\t}\n\n\t\tthis.isDownloading = false;\n\t}\n\n\t/**\n\t * Download a single attachment\n\t */\n\tprivate async downloadAttachment(localPath: string, url: string): Promise<void> {\n\t\tconst filePath = join(this.workingDir, localPath);\n\n\t\t// Ensure directory exists\n\t\tconst dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\tconst response = await fetch(url, {\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${this.botToken}`,\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`HTTP ${response.status}: ${response.statusText}`);\n\t\t}\n\n\t\tconst buffer = await response.arrayBuffer();\n\t\tawait writeFile(filePath, Buffer.from(buffer));\n\t}\n}\n"]}
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n\toriginal: string; // original filename from uploader\n\tlocal: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n\tdate: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n\tts: string; // slack timestamp or epoch ms\n\tuser: string; // user ID (or \"bot\" for bot responses)\n\tuserName?: string; // handle (e.g., \"mario\")\n\tdisplayName?: string; // display name (e.g., \"Mario Zechner\")\n\ttext: string;\n\tattachments: Attachment[];\n\tisBot: boolean;\n}\n\nexport interface ChannelStoreConfig {\n\tworkingDir: string;\n\tbotToken: string; // needed for authenticated file downloads\n}\n\ninterface PendingDownload {\n\tchannelId: string;\n\tlocalPath: string; // relative path\n\turl: string;\n}\n\nexport class ChannelStore {\n\tprivate workingDir: string;\n\tprivate botToken: string;\n\tprivate pendingDownloads: PendingDownload[] = [];\n\tprivate isDownloading = false;\n\n\tconstructor(config: ChannelStoreConfig) {\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.botToken = config.botToken;\n\n\t\t// Ensure working directory exists\n\t\tif (!existsSync(this.workingDir)) {\n\t\t\tmkdirSync(this.workingDir, { recursive: true });\n\t\t}\n\t}\n\n\t/**\n\t * Get or create the directory for a channel/DM\n\t */\n\tgetChannelDir(channelId: string): string {\n\t\tconst dir = join(this.workingDir, channelId);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\t/**\n\t * Generate a unique local filename for an attachment\n\t */\n\tgenerateLocalFilename(originalName: string, timestamp: string): string {\n\t\t// Convert slack timestamp (1234567890.123456) to milliseconds\n\t\tconst ts = Math.floor(parseFloat(timestamp) * 1000);\n\t\t// Sanitize original name (remove problematic characters)\n\t\tconst sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n\t\treturn `${ts}_${sanitized}`;\n\t}\n\n\t/**\n\t * Process attachments from a Slack message event\n\t * Returns attachment metadata and queues downloads\n\t */\n\tprocessAttachments(\n\t\tchannelId: string,\n\t\tfiles: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n\t\ttimestamp: string,\n\t): Attachment[] {\n\t\tconst attachments: Attachment[] = [];\n\n\t\tfor (const file of files) {\n\t\t\tconst url = file.url_private_download || file.url_private;\n\t\t\tif (!url) continue;\n\t\t\tif (!file.name) {\n\t\t\t\tlog.logWarning(\"Attachment missing name, skipping\", url);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst filename = this.generateLocalFilename(file.name, timestamp);\n\t\t\tconst localPath = `${channelId}/attachments/${filename}`;\n\n\t\t\tattachments.push({\n\t\t\t\toriginal: file.name,\n\t\t\t\tlocal: localPath,\n\t\t\t});\n\n\t\t\t// Queue for background download\n\t\t\tthis.pendingDownloads.push({ channelId, localPath, url });\n\t\t}\n\n\t\t// Trigger background download\n\t\tthis.processDownloadQueue();\n\n\t\treturn attachments;\n\t}\n\n\t/**\n\t * Log a message to the channel's log.jsonl\n\t */\n\tasync logMessage(channelId: string, message: LoggedMessage): Promise<void> {\n\t\tconst logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n\t\t// Ensure message has a date field\n\t\tif (!message.date) {\n\t\t\t// Parse timestamp to get date\n\t\t\tlet date: Date;\n\t\t\tif (message.ts.includes(\".\")) {\n\t\t\t\t// Slack timestamp format (1234567890.123456)\n\t\t\t\tdate = new Date(parseFloat(message.ts) * 1000);\n\t\t\t} else {\n\t\t\t\t// Epoch milliseconds\n\t\t\t\tdate = new Date(parseInt(message.ts, 10));\n\t\t\t}\n\t\t\tmessage.date = date.toISOString();\n\t\t}\n\n\t\tconst line = JSON.stringify(message) + \"\\n\";\n\t\tawait appendFile(logPath, line, \"utf-8\");\n\t}\n\n\t/**\n\t * Log a bot response\n\t */\n\tasync logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n\t\tawait this.logMessage(channelId, {\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 * Get the timestamp of the last logged message for a channel\n\t * Returns null if no log exists\n\t */\n\tgetLastTimestamp(channelId: string): string | null {\n\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\tif (!existsSync(logPath)) {\n\t\t\treturn null;\n\t\t}\n\n\t\ttry {\n\t\t\tconst content = readFileSync(logPath, \"utf-8\");\n\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\tif (lines.length === 0 || lines[0] === \"\") {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst lastLine = lines[lines.length - 1];\n\t\t\tconst message = JSON.parse(lastLine) as LoggedMessage;\n\t\t\treturn message.ts;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Process the download queue in the background\n\t */\n\tprivate async processDownloadQueue(): Promise<void> {\n\t\tif (this.isDownloading || this.pendingDownloads.length === 0) return;\n\n\t\tthis.isDownloading = true;\n\n\t\twhile (this.pendingDownloads.length > 0) {\n\t\t\tconst item = this.pendingDownloads.shift();\n\t\t\tif (!item) break;\n\n\t\t\ttry {\n\t\t\t\tawait this.downloadAttachment(item.localPath, item.url);\n\t\t\t\t// Success - could add success logging here if we have context\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMsg = error instanceof Error ? error.message : String(error);\n\t\t\t\tlog.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);\n\t\t\t}\n\t\t}\n\n\t\tthis.isDownloading = false;\n\t}\n\n\t/**\n\t * Download a single attachment\n\t */\n\tprivate async downloadAttachment(localPath: string, url: string): Promise<void> {\n\t\tconst filePath = join(this.workingDir, localPath);\n\n\t\t// Ensure directory exists\n\t\tconst dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\tconst response = await fetch(url, {\n\t\t\theaders: {\n\t\t\t\tAuthorization: `Bearer ${this.botToken}`,\n\t\t\t},\n\t\t});\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`HTTP ${response.status}: ${response.statusText}`);\n\t\t}\n\n\t\tconst buffer = await response.arrayBuffer();\n\t\tawait writeFile(filePath, Buffer.from(buffer));\n\t}\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariozechner/pi-mom",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "Slack bot that delegates messages to the pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,8 +21,8 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@anthropic-ai/sandbox-runtime": "^0.0.16",
24
- "@mariozechner/pi-agent-core": "^0.12.5",
25
- "@mariozechner/pi-ai": "^0.12.5",
24
+ "@mariozechner/pi-agent-core": "^0.12.8",
25
+ "@mariozechner/pi-ai": "^0.12.8",
26
26
  "@sinclair/typebox": "^0.34.0",
27
27
  "@slack/socket-mode": "^2.0.0",
28
28
  "@slack/web-api": "^7.0.0",