@geminixiang/mama 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +158 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/slack/bot.d.ts +130 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -0
- package/dist/adapters/slack/bot.js +516 -0
- package/dist/adapters/slack/bot.js.map +1 -0
- package/dist/adapters/slack/context.d.ts +11 -0
- package/dist/adapters/slack/context.d.ts.map +1 -0
- package/dist/adapters/slack/context.js +178 -0
- package/dist/adapters/slack/context.js.map +1 -0
- package/dist/adapters/slack/index.d.ts +3 -0
- package/dist/adapters/slack/index.d.ts.map +1 -0
- package/dist/adapters/slack/index.js +3 -0
- package/dist/adapters/slack/index.js.map +1 -0
- package/dist/adapters/slack/tools/attach.d.ts +12 -0
- package/dist/adapters/slack/tools/attach.d.ts.map +1 -0
- package/dist/adapters/slack/tools/attach.js +38 -0
- package/dist/adapters/slack/tools/attach.js.map +1 -0
- package/dist/agent.d.ts +26 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +763 -0
- package/dist/agent.js.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +110 -0
- package/dist/context.js.map +1 -0
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +89 -0
- package/dist/download.js.map +1 -0
- package/dist/events.d.ts +57 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +310 -0
- package/dist/events.js.map +1 -0
- package/dist/log.d.ts +39 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +222 -0
- package/dist/log.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +247 -0
- package/dist/main.js.map +1 -0
- package/dist/sandbox.d.ts +34 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +183 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +180 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +78 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +131 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +19 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +134 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/truncate.d.ts +57 -0
- package/dist/tools/truncate.d.ts.map +1 -0
- package/dist/tools/truncate.js +184 -0
- package/dist/tools/truncate.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +33 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Original work Copyright (c) 2024 Mario Zechner
|
|
4
|
+
Modified work Copyright (c) 2025 geminixiang
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# mama
|
|
2
|
+
|
|
3
|
+
A Slack bot that delegates messages to an AI coding agent. Built as an extension of the `mom` package from [badlogic/pi-mono](https://github.com/badlogic/pi-mono), MIT licensed.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Slack integration** — responds to `@mentions` in channels and direct messages
|
|
8
|
+
- **Thread sessions** — each Slack thread gets its own isolated conversation context
|
|
9
|
+
- **Concurrent threads** — multiple threads in the same channel run independently
|
|
10
|
+
- **Sandbox execution** — run agent commands on host or inside a Docker container
|
|
11
|
+
- **Persistent memory** — workspace-level and channel-level `MEMORY.md` files
|
|
12
|
+
- **Skills** — drop custom CLI tools into `skills/` directories
|
|
13
|
+
- **Event system** — schedule one-shot or recurring tasks via JSON files
|
|
14
|
+
- **Multi-provider** — configure any provider/model supported by `pi-ai`
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js >= 20
|
|
19
|
+
- A Slack app with Socket Mode enabled ([setup guide](docs/slack-bot-minimal-guide.md))
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g @geminixiang/mama
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or run directly after cloning:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npm run build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export MOM_SLACK_APP_TOKEN=xapp-...
|
|
38
|
+
export MOM_SLACK_BOT_TOKEN=xoxb-...
|
|
39
|
+
|
|
40
|
+
mama [--sandbox=host|docker:<container>] <working-directory>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Options
|
|
44
|
+
|
|
45
|
+
| Option | Default | Description |
|
|
46
|
+
|--------|---------|-------------|
|
|
47
|
+
| `--sandbox=host` | ✓ | Run commands directly on host |
|
|
48
|
+
| `--sandbox=docker:<name>` | | Run commands inside a Docker container |
|
|
49
|
+
| `--download <channel-id>` | | Download channel history to stdout and exit |
|
|
50
|
+
|
|
51
|
+
### Download channel history
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
mama --download C0123456789
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Create `settings.json` in your working directory to override defaults:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"provider": "anthropic",
|
|
64
|
+
"model": "claude-sonnet-4-5",
|
|
65
|
+
"thinkingLevel": "off",
|
|
66
|
+
"sessionScope": "thread"
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Field | Default | Description |
|
|
71
|
+
|-------|---------|-------------|
|
|
72
|
+
| `provider` | `anthropic` | AI provider (env: `MOM_AI_PROVIDER`) |
|
|
73
|
+
| `model` | `claude-sonnet-4-5` | Model name (env: `MOM_AI_MODEL`) |
|
|
74
|
+
| `thinkingLevel` | `off` | `off` / `low` / `medium` / `high` |
|
|
75
|
+
| `sessionScope` | `thread` | `thread` (per Slack thread) or `channel` |
|
|
76
|
+
|
|
77
|
+
## Working Directory Layout
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
<working-directory>/
|
|
81
|
+
├── settings.json # AI provider/model config
|
|
82
|
+
├── MEMORY.md # Global memory (all channels)
|
|
83
|
+
├── SYSTEM.md # Installed packages / env changes log
|
|
84
|
+
├── skills/ # Global skills (CLI tools)
|
|
85
|
+
├── events/ # Scheduled event files
|
|
86
|
+
└── <channel-id>/
|
|
87
|
+
├── MEMORY.md # Channel-specific memory
|
|
88
|
+
├── log.jsonl # Full message history
|
|
89
|
+
├── attachments/ # Downloaded user files
|
|
90
|
+
├── scratch/ # Agent working directory
|
|
91
|
+
├── skills/ # Channel-specific skills
|
|
92
|
+
└── sessions/
|
|
93
|
+
└── <thread-ts>/
|
|
94
|
+
└── context.jsonl # LLM conversation context
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Docker Sandbox
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Create a container (mount your working directory to /workspace)
|
|
101
|
+
docker run -d --name mama-sandbox \
|
|
102
|
+
-v /path/to/workspace:/workspace \
|
|
103
|
+
alpine:latest sleep infinity
|
|
104
|
+
|
|
105
|
+
# Start mama with Docker sandbox
|
|
106
|
+
mama --sandbox=docker:mama-sandbox /path/to/workspace
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Events
|
|
110
|
+
|
|
111
|
+
Drop JSON files into `<working-directory>/events/` to trigger the agent:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
// Immediate — triggers as soon as mama sees the file
|
|
115
|
+
{"type": "immediate", "channelId": "C0123456789", "text": "New deployment finished"}
|
|
116
|
+
|
|
117
|
+
// One-shot — triggers once at a specific time
|
|
118
|
+
{"type": "one-shot", "channelId": "C0123456789", "text": "Daily standup reminder", "at": "2025-12-15T09:00:00+08:00"}
|
|
119
|
+
|
|
120
|
+
// Periodic — triggers on a cron schedule
|
|
121
|
+
{"type": "periodic", "channelId": "C0123456789", "text": "Check inbox", "schedule": "0 9 * * 1-5", "timezone": "Asia/Taipei"}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Skills
|
|
125
|
+
|
|
126
|
+
Create reusable CLI tools by adding a directory with a `SKILL.md`:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
skills/
|
|
130
|
+
└── my-tool/
|
|
131
|
+
├── SKILL.md # name + description frontmatter, usage docs
|
|
132
|
+
└── run.sh # the actual script
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`SKILL.md` frontmatter:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
---
|
|
139
|
+
name: my-tool
|
|
140
|
+
description: Does something useful
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
Usage: {baseDir}/run.sh <args>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm run dev # watch mode
|
|
150
|
+
npm test # run tests
|
|
151
|
+
npm run build # production build
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT — see [LICENSE](LICENSE).
|
|
157
|
+
|
|
158
|
+
Based on [pi-mono](https://github.com/badlogic/pi-mono) by Mario Zechner, extended from the `mom` package.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ChatMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
sessionKey: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
userName?: string;
|
|
6
|
+
text: string;
|
|
7
|
+
attachments?: {
|
|
8
|
+
name: string;
|
|
9
|
+
localPath: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
export interface ChatResponseContext {
|
|
13
|
+
respond(text: string): Promise<void>;
|
|
14
|
+
replaceResponse(text: string): Promise<void>;
|
|
15
|
+
respondInThread(text: string): Promise<void>;
|
|
16
|
+
setWorking(working: boolean): Promise<void>;
|
|
17
|
+
uploadFile(filePath: string, title?: string): Promise<void>;
|
|
18
|
+
deleteResponse(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface PlatformInfo {
|
|
21
|
+
name: string;
|
|
22
|
+
formattingGuide: string;
|
|
23
|
+
channels: {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}[];
|
|
27
|
+
users: {
|
|
28
|
+
id: string;
|
|
29
|
+
userName: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
}[];
|
|
32
|
+
}
|
|
33
|
+
export interface ChatAdapter {
|
|
34
|
+
start(): Promise<void>;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
getPlatformInfo(): PlatformInfo;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACpD;AAED,MAAM,WAAW,mBAAmB;IACnC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACzC,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC/D;AAED,MAAM,WAAW,WAAW;IAC3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,eAAe,IAAI,YAAY,CAAC;CAChC","sourcesContent":["export interface ChatMessage {\n\tid: string;\n\tsessionKey: string;\n\tuserId: string;\n\tuserName?: string;\n\ttext: string;\n\tattachments?: { name: string; localPath: string }[];\n}\n\nexport interface ChatResponseContext {\n\trespond(text: string): Promise<void>;\n\treplaceResponse(text: string): Promise<void>;\n\trespondInThread(text: string): Promise<void>;\n\tsetWorking(working: boolean): Promise<void>;\n\tuploadFile(filePath: string, title?: string): Promise<void>;\n\tdeleteResponse(): Promise<void>;\n}\n\nexport interface PlatformInfo {\n\tname: string;\n\tformattingGuide: string;\n\tchannels: { id: string; name: string }[];\n\tusers: { id: string; userName: string; displayName: string }[];\n}\n\nexport interface ChatAdapter {\n\tstart(): Promise<void>;\n\tstop(): Promise<void>;\n\tgetPlatformInfo(): PlatformInfo;\n}\n"]}
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"","sourcesContent":["export interface ChatMessage {\n\tid: string;\n\tsessionKey: string;\n\tuserId: string;\n\tuserName?: string;\n\ttext: string;\n\tattachments?: { name: string; localPath: string }[];\n}\n\nexport interface ChatResponseContext {\n\trespond(text: string): Promise<void>;\n\treplaceResponse(text: string): Promise<void>;\n\trespondInThread(text: string): Promise<void>;\n\tsetWorking(working: boolean): Promise<void>;\n\tuploadFile(filePath: string, title?: string): Promise<void>;\n\tdeleteResponse(): Promise<void>;\n}\n\nexport interface PlatformInfo {\n\tname: string;\n\tformattingGuide: string;\n\tchannels: { id: string; name: string }[];\n\tusers: { id: string; userName: string; displayName: string }[];\n}\n\nexport interface ChatAdapter {\n\tstart(): Promise<void>;\n\tstop(): Promise<void>;\n\tgetPlatformInfo(): PlatformInfo;\n}\n"]}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Attachment, ChannelStore } from "../../store.js";
|
|
2
|
+
export interface SlackEvent {
|
|
3
|
+
type: "mention" | "dm";
|
|
4
|
+
channel: string;
|
|
5
|
+
ts: string;
|
|
6
|
+
thread_ts?: string;
|
|
7
|
+
user: string;
|
|
8
|
+
text: string;
|
|
9
|
+
files?: Array<{
|
|
10
|
+
name?: string;
|
|
11
|
+
url_private_download?: string;
|
|
12
|
+
url_private?: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** Processed attachments with local paths (populated after logUserMessage) */
|
|
15
|
+
attachments?: Attachment[];
|
|
16
|
+
}
|
|
17
|
+
export interface SlackUser {
|
|
18
|
+
id: string;
|
|
19
|
+
userName: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SlackChannel {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ChannelInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
export interface UserInfo {
|
|
31
|
+
id: string;
|
|
32
|
+
userName: string;
|
|
33
|
+
displayName: string;
|
|
34
|
+
}
|
|
35
|
+
export interface SlackContext {
|
|
36
|
+
message: {
|
|
37
|
+
text: string;
|
|
38
|
+
rawText: string;
|
|
39
|
+
user: string;
|
|
40
|
+
userName?: string;
|
|
41
|
+
channel: string;
|
|
42
|
+
ts: string;
|
|
43
|
+
attachments: Array<{
|
|
44
|
+
local: string;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
channelName?: string;
|
|
48
|
+
channels: ChannelInfo[];
|
|
49
|
+
users: UserInfo[];
|
|
50
|
+
respond: (text: string, shouldLog?: boolean) => Promise<void>;
|
|
51
|
+
replaceMessage: (text: string) => Promise<void>;
|
|
52
|
+
respondInThread: (text: string) => Promise<void>;
|
|
53
|
+
setTyping: (isTyping: boolean) => Promise<void>;
|
|
54
|
+
uploadFile: (filePath: string, title?: string) => Promise<void>;
|
|
55
|
+
setWorking: (working: boolean) => Promise<void>;
|
|
56
|
+
deleteMessage: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export interface MomHandler {
|
|
59
|
+
/**
|
|
60
|
+
* Check if session is currently running (SYNC).
|
|
61
|
+
* sessionKey format: "channelId:rootTs"
|
|
62
|
+
*/
|
|
63
|
+
isRunning(sessionKey: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Handle an event that triggers mama (ASYNC)
|
|
66
|
+
* Called only when isRunning() returned false for user messages.
|
|
67
|
+
* Events always queue and pass isEvent=true.
|
|
68
|
+
*/
|
|
69
|
+
handleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Handle stop command (ASYNC)
|
|
72
|
+
* Called when user says "stop" while mama is running
|
|
73
|
+
*/
|
|
74
|
+
handleStop(sessionKey: string, channelId: string, slack: SlackBot): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export declare class SlackBot {
|
|
77
|
+
private socketClient;
|
|
78
|
+
private webClient;
|
|
79
|
+
private handler;
|
|
80
|
+
private workingDir;
|
|
81
|
+
private store;
|
|
82
|
+
private botUserId;
|
|
83
|
+
private startupTs;
|
|
84
|
+
private users;
|
|
85
|
+
private channels;
|
|
86
|
+
private queues;
|
|
87
|
+
constructor(handler: MomHandler, config: {
|
|
88
|
+
appToken: string;
|
|
89
|
+
botToken: string;
|
|
90
|
+
workingDir: string;
|
|
91
|
+
store: ChannelStore;
|
|
92
|
+
});
|
|
93
|
+
start(): Promise<void>;
|
|
94
|
+
getUser(userId: string): SlackUser | undefined;
|
|
95
|
+
getChannel(channelId: string): SlackChannel | undefined;
|
|
96
|
+
getAllUsers(): SlackUser[];
|
|
97
|
+
getAllChannels(): SlackChannel[];
|
|
98
|
+
postMessage(channel: string, text: string): Promise<string>;
|
|
99
|
+
updateMessage(channel: string, ts: string, text: string): Promise<void>;
|
|
100
|
+
deleteMessage(channel: string, ts: string): Promise<void>;
|
|
101
|
+
postInThread(channel: string, threadTs: string, text: string): Promise<string>;
|
|
102
|
+
uploadFile(channel: string, filePath: string, title?: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Log a message to log.jsonl (SYNC)
|
|
105
|
+
* This is the ONLY place messages are written to log.jsonl
|
|
106
|
+
*/
|
|
107
|
+
logToFile(channel: string, entry: object): void;
|
|
108
|
+
/**
|
|
109
|
+
* Log a bot response to log.jsonl
|
|
110
|
+
*/
|
|
111
|
+
logBotResponse(channel: string, text: string, ts: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* Enqueue an event for processing. Always queues (no "already working" rejection).
|
|
114
|
+
* Returns true if enqueued, false if queue is full (max 5).
|
|
115
|
+
*/
|
|
116
|
+
enqueueEvent(event: SlackEvent): boolean;
|
|
117
|
+
private getQueue;
|
|
118
|
+
private setupEventHandlers;
|
|
119
|
+
/**
|
|
120
|
+
* Log a user message to log.jsonl (SYNC)
|
|
121
|
+
* Downloads attachments in background via store
|
|
122
|
+
*/
|
|
123
|
+
private logUserMessage;
|
|
124
|
+
private getExistingTimestamps;
|
|
125
|
+
private backfillChannel;
|
|
126
|
+
private backfillAllChannels;
|
|
127
|
+
private fetchUsers;
|
|
128
|
+
private fetchChannels;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=bot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\n\n// ============================================================================\n// Exponential backoff utility for Slack API calls\n// ============================================================================\n\n/**\n * Retry a function with exponential backoff on rate limit errors.\n */\nasync function withRetry<T>(\n\tfn: () => Promise<T>,\n\tmaxRetries: number = 3,\n\tbaseDelayMs: number = 1000,\n): Promise<T> {\n\tlet lastError: Error | undefined;\n\tfor (let attempt = 0; attempt < maxRetries; attempt++) {\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} catch (err) {\n\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\n\t\t\t// Check for rate limit errors\n\t\t\tlet isRateLimited = false;\n\n\t\t\t// Check for rate_limited error code (Slack SDK)\n\t\t\tif (\"code\" in lastError && lastError.code === \"rate_limited\") {\n\t\t\t\tisRateLimited = true;\n\t\t\t}\n\n\t\t\t// Check for rate_limited in error response\n\t\t\tif (\"data\" in lastError) {\n\t\t\t\tconst data = (lastError as { data?: { error?: string; response?: { status?: number } } }).data;\n\t\t\t\tif (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n\t\t\t\t\tisRateLimited = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (isRateLimited) {\n\t\t\t\tconst delay = baseDelayMs * Math.pow(2, attempt);\n\t\t\t\tlog.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delay));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Non-retryable error\n\t\t\tthrow lastError;\n\t\t}\n\t}\n\tthrow lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n\ttype: \"mention\" | \"dm\";\n\tchannel: string;\n\tts: string;\n\tthread_ts?: string;\n\tuser: string;\n\ttext: string;\n\tfiles?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n\t/** Processed attachments with local paths (populated after logUserMessage) */\n\tattachments?: Attachment[];\n}\n\nexport interface SlackUser {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackChannel {\n\tid: string;\n\tname: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n\tid: string;\n\tname: string;\n}\n\nexport interface UserInfo {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackContext {\n\tmessage: {\n\t\ttext: string;\n\t\trawText: string;\n\t\tuser: string;\n\t\tuserName?: string;\n\t\tchannel: string;\n\t\tts: string;\n\t\tattachments: Array<{ local: string }>;\n\t};\n\tchannelName?: string;\n\tchannels: ChannelInfo[];\n\tusers: UserInfo[];\n\trespond: (text: string, shouldLog?: boolean) => Promise<void>;\n\treplaceMessage: (text: string) => Promise<void>;\n\trespondInThread: (text: string) => Promise<void>;\n\tsetTyping: (isTyping: boolean) => Promise<void>;\n\tuploadFile: (filePath: string, title?: string) => Promise<void>;\n\tsetWorking: (working: boolean) => Promise<void>;\n\tdeleteMessage: () => Promise<void>;\n}\n\nexport interface MomHandler {\n\t/**\n\t * Check if session is currently running (SYNC).\n\t * sessionKey format: \"channelId:rootTs\"\n\t */\n\tisRunning(sessionKey: string): boolean;\n\n\t/**\n\t * Handle an event that triggers mama (ASYNC)\n\t * Called only when isRunning() returned false for user messages.\n\t * Events always queue and pass isEvent=true.\n\t */\n\thandleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;\n\n\t/**\n\t * Handle stop command (ASYNC)\n\t * Called when user says \"stop\" while mama is running\n\t */\n\thandleStop(sessionKey: string, channelId: string, slack: SlackBot): Promise<void>;\n}\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n\tprivate queue: QueuedWork[] = [];\n\tprivate processing = false;\n\n\tenqueue(work: QueuedWork): void {\n\t\tthis.queue.push(work);\n\t\tthis.processNext();\n\t}\n\n\tsize(): number {\n\t\treturn this.queue.length;\n\t}\n\n\tprivate async processNext(): Promise<void> {\n\t\tif (this.processing || this.queue.length === 0) return;\n\t\tthis.processing = true;\n\t\tconst work = this.queue.shift()!;\n\t\ttry {\n\t\t\tawait work();\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n\t\t}\n\t\tthis.processing = false;\n\t\tthis.processNext();\n\t}\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot {\n\tprivate socketClient: SocketModeClient;\n\tprivate webClient: WebClient;\n\tprivate handler: MomHandler;\n\tprivate workingDir: string;\n\tprivate store: ChannelStore;\n\tprivate botUserId: string | null = null;\n\tprivate startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n\tprivate users = new Map<string, SlackUser>();\n\tprivate channels = new Map<string, SlackChannel>();\n\tprivate queues = new Map<string, ChannelQueue>();\n\n\tconstructor(\n\t\thandler: MomHandler,\n\t\tconfig: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n\t) {\n\t\tthis.handler = handler;\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.store = config.store;\n\t\tthis.socketClient = new SocketModeClient({ appToken: config.appToken });\n\t\tthis.webClient = new WebClient(config.botToken);\n\t}\n\n\t// ==========================================================================\n\t// Public API\n\t// ==========================================================================\n\n\tasync start(): Promise<void> {\n\t\tconst auth = await this.webClient.auth.test();\n\t\tthis.botUserId = auth.user_id as string;\n\n\t\tawait Promise.all([this.fetchUsers(), this.fetchChannels()]);\n\t\tlog.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n\t\tawait this.backfillAllChannels();\n\n\t\tthis.setupEventHandlers();\n\t\tawait this.socketClient.start();\n\n\t\t// Record startup time - messages older than this are just logged, not processed\n\t\tthis.startupTs = (Date.now() / 1000).toFixed(6);\n\n\t\tlog.logConnected();\n\t}\n\n\tgetUser(userId: string): SlackUser | undefined {\n\t\treturn this.users.get(userId);\n\t}\n\n\tgetChannel(channelId: string): SlackChannel | undefined {\n\t\treturn this.channels.get(channelId);\n\t}\n\n\tgetAllUsers(): SlackUser[] {\n\t\treturn Array.from(this.users.values());\n\t}\n\n\tgetAllChannels(): SlackChannel[] {\n\t\treturn Array.from(this.channels.values());\n\t}\n\n\tasync postMessage(channel: string, text: string): Promise<string> {\n\t\treturn withRetry(async () => {\n\t\t\tconst result = await this.webClient.chat.postMessage({ channel, text });\n\t\t\treturn result.ts as string;\n\t\t});\n\t}\n\n\tasync updateMessage(channel: string, ts: string, text: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tawait this.webClient.chat.update({ channel, ts, text });\n\t\t});\n\t}\n\n\tasync deleteMessage(channel: string, ts: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tawait this.webClient.chat.delete({ channel, ts });\n\t\t});\n\t}\n\n\tasync postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n\t\treturn withRetry(async () => {\n\t\t\tconst result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n\t\t\treturn result.ts as string;\n\t\t});\n\t}\n\n\tasync uploadFile(channel: string, filePath: string, title?: string): Promise<void> {\n\t\treturn withRetry(async () => {\n\t\t\tconst fileName = title || basename(filePath);\n\t\t\tconst fileContent = readFileSync(filePath);\n\t\t\tawait this.webClient.files.uploadV2({\n\t\t\t\tchannel_id: channel,\n\t\t\t\tfile: fileContent,\n\t\t\t\tfilename: fileName,\n\t\t\t\ttitle: fileName,\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Log a message to log.jsonl (SYNC)\n\t * This is the ONLY place messages are written to log.jsonl\n\t */\n\tlogToFile(channel: string, entry: object): void {\n\t\tconst dir = join(this.workingDir, channel);\n\t\tif (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\t\tappendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n\t}\n\n\t/**\n\t * Log a bot response to log.jsonl\n\t */\n\tlogBotResponse(channel: string, text: string, ts: string): void {\n\t\tthis.logToFile(channel, {\n\t\t\tdate: new Date().toISOString(),\n\t\t\tts,\n\t\t\tuser: \"bot\",\n\t\t\ttext,\n\t\t\tattachments: [],\n\t\t\tisBot: true,\n\t\t});\n\t}\n\n\t// ==========================================================================\n\t// Events Integration\n\t// ==========================================================================\n\n\t/**\n\t * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n\t * Returns true if enqueued, false if queue is full (max 5).\n\t */\n\tenqueueEvent(event: SlackEvent): boolean {\n\t\tconst queue = this.getQueue(event.channel);\n\t\tif (queue.size() >= 5) {\n\t\t\tlog.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);\n\t\t\treturn false;\n\t\t}\n\t\tlog.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);\n\t\tqueue.enqueue(() => this.handler.handleEvent(event, this, true));\n\t\treturn true;\n\t}\n\n\t// ==========================================================================\n\t// Private - Event Handlers\n\t// ==========================================================================\n\n\tprivate getQueue(channelId: string): ChannelQueue {\n\t\tlet queue = this.queues.get(channelId);\n\t\tif (!queue) {\n\t\t\tqueue = new ChannelQueue();\n\t\t\tthis.queues.set(channelId, queue);\n\t\t}\n\t\treturn queue;\n\t}\n\n\tprivate setupEventHandlers(): void {\n\t\t// Channel @mentions\n\t\tthis.socketClient.on(\"app_mention\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser: string;\n\t\t\t\tts: string;\n\t\t\t\tthread_ts?: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip DMs (handled by message event)\n\t\t\tif (e.channel.startsWith(\"D\")) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Derive session key from thread context\n\t\t\tconst rootTs = e.thread_ts ?? e.ts;\n\t\t\tconst sessionKey = `${e.channel}:${rootTs}`;\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tthread_ts: e.thread_ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(\n\t\t\t\t\t`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n\t\t\t\t);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\tif (this.handler.isRunning(sessionKey)) {\n\t\t\t\t\tthis.handler.handleStop(sessionKey, e.channel, this); // Don't await, don't queue\n\t\t\t\t} else {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t}\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// SYNC: Check if busy (per-thread)\n\t\t\tif (this.handler.isRunning(sessionKey)) {\n\t\t\t\tthis.postMessage(e.channel, \"_Already working in this thread. Say `@mama stop` to cancel._\");\n\t\t\t} else {\n\t\t\t\tthis.getQueue(sessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\n\t\t// All messages (for logging) + DMs (for triggering)\n\t\tthis.socketClient.on(\"message\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext?: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser?: string;\n\t\t\t\tts: string;\n\t\t\t\tthread_ts?: string;\n\t\t\t\tchannel_type?: string;\n\t\t\t\tsubtype?: string;\n\t\t\t\tbot_id?: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip bot messages, edits, etc.\n\t\t\tif (e.bot_id || !e.user || e.user === this.botUserId) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (e.subtype !== undefined && e.subtype !== \"file_share\") {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!e.text && (!e.files || e.files.length === 0)) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst isDM = e.channel_type === \"im\";\n\t\t\tconst isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n\t\t\t// Skip channel @mentions - already handled by app_mention event\n\t\t\tif (!isDM && isBotMention) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: isDM ? \"dm\" : \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tthread_ts: e.thread_ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Only trigger handler for DMs\n\t\t\tif (isDM) {\n\t\t\t\tconst dmRootTs = e.thread_ts ?? e.ts;\n\t\t\t\tconst dmSessionKey = `${e.channel}:${dmRootTs}`;\n\n\t\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\t\tif (this.handler.isRunning(dmSessionKey)) {\n\t\t\t\t\t\tthis.handler.handleStop(dmSessionKey, e.channel, this); // Don't await, don't queue\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t\t}\n\t\t\t\t\tack();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (this.handler.isRunning(dmSessionKey)) {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Already working. Say `stop` to cancel._\");\n\t\t\t\t} else {\n\t\t\t\t\tthis.getQueue(dmSessionKey).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\t}\n\n\t/**\n\t * Log a user message to log.jsonl (SYNC)\n\t * Downloads attachments in background via store\n\t */\n\tprivate logUserMessage(event: SlackEvent): Attachment[] {\n\t\tconst user = this.users.get(event.user);\n\t\t// Process attachments - queues downloads in background\n\t\tconst attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];\n\t\tthis.logToFile(event.channel, {\n\t\t\tdate: new Date(parseFloat(event.ts) * 1000).toISOString(),\n\t\t\tts: event.ts,\n\t\t\tuser: event.user,\n\t\t\tuserName: user?.userName,\n\t\t\tdisplayName: user?.displayName,\n\t\t\ttext: event.text,\n\t\t\tattachments,\n\t\t\tisBot: false,\n\t\t});\n\t\treturn attachments;\n\t}\n\n\t// ==========================================================================\n\t// Private - Backfill\n\t// ==========================================================================\n\n\tprivate async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\tconst timestamps = new Set<string>();\n\t\tif (!existsSync(logPath)) return timestamps;\n\n\t\tconst content = await readFile(logPath, \"utf-8\");\n\t\tconst lines = content.trim().split(\"\\n\").filter(Boolean);\n\t\tfor (const line of lines) {\n\t\t\ttry {\n\t\t\t\tconst entry = JSON.parse(line);\n\t\t\t\tif (entry.ts) timestamps.add(entry.ts);\n\t\t\t} catch {}\n\t\t}\n\t\treturn timestamps;\n\t}\n\n\tprivate async backfillChannel(channelId: string): Promise<number> {\n\t\tconst existingTs = await this.getExistingTimestamps(channelId);\n\n\t\t// Find the biggest ts in log.jsonl\n\t\tlet latestTs: string | undefined;\n\t\tfor (const ts of existingTs) {\n\t\t\tif (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n\t\t}\n\n\t\ttype Message = {\n\t\t\tuser?: string;\n\t\t\tbot_id?: string;\n\t\t\ttext?: string;\n\t\t\tts?: string;\n\t\t\tsubtype?: string;\n\t\t\tfiles?: Array<{ name: string }>;\n\t\t};\n\t\tconst allMessages: Message[] = [];\n\n\t\tlet cursor: string | undefined;\n\t\tlet pageCount = 0;\n\t\tconst maxPages = 3;\n\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.history({\n\t\t\t\tchannel: channelId,\n\t\t\t\toldest: latestTs, // Only fetch messages newer than what we have\n\t\t\t\tinclusive: false,\n\t\t\t\tlimit: 1000,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tif (result.messages) {\n\t\t\t\tallMessages.push(...(result.messages as Message[]));\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t\tpageCount++;\n\t\t} while (cursor && pageCount < maxPages);\n\n\t\t// Filter: include mama's messages, exclude other bots, skip already logged\n\t\tconst relevantMessages = allMessages.filter((msg) => {\n\t\t\tif (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n\t\t\tif (msg.user === this.botUserId) return true;\n\t\t\tif (msg.bot_id) return false;\n\t\t\tif (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n\t\t\tif (!msg.user) return false;\n\t\t\tif (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n\t\t\treturn true;\n\t\t});\n\n\t\t// Reverse to chronological order\n\t\trelevantMessages.reverse();\n\n\t\t// Log each message to log.jsonl\n\t\tfor (const msg of relevantMessages) {\n\t\t\tconst isMamaMessage = msg.user === this.botUserId;\n\t\t\tconst user = this.users.get(msg.user!);\n\t\t\t// Strip @mentions from text (same as live messages)\n\t\t\tconst text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n\t\t\t// Process attachments - queues downloads in background\n\t\t\tconst attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];\n\n\t\t\tthis.logToFile(channelId, {\n\t\t\t\tdate: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n\t\t\t\tts: msg.ts!,\n\t\t\t\tuser: isMamaMessage ? \"bot\" : msg.user!,\n\t\t\t\tuserName: isMamaMessage ? undefined : user?.userName,\n\t\t\t\tdisplayName: isMamaMessage ? undefined : user?.displayName,\n\t\t\t\ttext,\n\t\t\t\tattachments,\n\t\t\t\tisBot: isMamaMessage,\n\t\t\t});\n\t\t}\n\n\t\treturn relevantMessages.length;\n\t}\n\n\tprivate async backfillAllChannels(): Promise<void> {\n\t\tconst startTime = Date.now();\n\n\t\t// Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n\t\tconst channelsToBackfill: Array<[string, SlackChannel]> = [];\n\t\tfor (const [channelId, channel] of this.channels) {\n\t\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\t\tif (existsSync(logPath)) {\n\t\t\t\tchannelsToBackfill.push([channelId, channel]);\n\t\t\t}\n\t\t}\n\n\t\tlog.logBackfillStart(channelsToBackfill.length);\n\n\t\tlet totalMessages = 0;\n\t\tfor (const [channelId, channel] of channelsToBackfill) {\n\t\t\ttry {\n\t\t\t\tconst count = await this.backfillChannel(channelId);\n\t\t\t\tif (count > 0) log.logBackfillChannel(channel.name, count);\n\t\t\t\ttotalMessages += count;\n\t\t\t} catch (error) {\n\t\t\t\tlog.logWarning(`Failed to backfill #${channel.name}`, String(error));\n\t\t\t}\n\n\t\t\t// Add delay between channels to avoid hitting Slack rate limits\n\t\t\tif (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, 500));\n\t\t\t}\n\t\t}\n\n\t\tconst durationMs = Date.now() - startTime;\n\t\tlog.logBackfillComplete(totalMessages, durationMs);\n\t}\n\n\t// ==========================================================================\n\t// Private - Fetch Users/Channels\n\t// ==========================================================================\n\n\tprivate async fetchUsers(): Promise<void> {\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.users.list({ limit: 200, cursor });\n\t\t\tconst members = result.members as\n\t\t\t\t| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n\t\t\t\t| undefined;\n\t\t\tif (members) {\n\t\t\t\tfor (const u of members) {\n\t\t\t\t\tif (u.id && u.name && !u.deleted) {\n\t\t\t\t\t\tthis.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\n\n\tprivate async fetchChannels(): Promise<void> {\n\t\t// Fetch public/private channels\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"public_channel,private_channel\",\n\t\t\t\texclude_archived: true,\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;\n\t\t\tif (channels) {\n\t\t\t\tfor (const c of channels) {\n\t\t\t\t\tif (c.id && c.name && c.is_member) {\n\t\t\t\t\t\tthis.channels.set(c.id, { id: c.id, name: c.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\n\t\t// Also fetch DM channels (IMs)\n\t\tcursor = undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"im\",\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n\t\t\tif (ims) {\n\t\t\t\tfor (const im of ims) {\n\t\t\t\t\tif (im.id) {\n\t\t\t\t\t\t// Use user's name as channel name for DMs\n\t\t\t\t\t\tconst user = im.user ? this.users.get(im.user) : undefined;\n\t\t\t\t\t\tconst name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n\t\t\t\t\t\tthis.channels.set(im.id, { id: im.id, name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\n}\n"]}
|