custie 1.0.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 +21 -0
- package/README.md +107 -0
- package/dist/chunk-55T4S4ZY.js +521 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +850 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/package.json +47 -0
- package/slack-app-manifest.yml +38 -0
- package/system.default.md +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FlyCoder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="custie.png" alt="Custie" width="240" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Custie
|
|
6
|
+
|
|
7
|
+
**Claude Code, inside your Slack.** Your team gets an AI that can read your codebase, edit files, run commands, and use every Claude Code skill -- all from a Slack thread.
|
|
8
|
+
|
|
9
|
+
## Why Custie?
|
|
10
|
+
|
|
11
|
+
Claude Code is powerful, but it lives in your terminal. Custie brings the **full Claude Code experience** into Slack:
|
|
12
|
+
|
|
13
|
+
- **Ask about your codebase** -- `@custie what does the auth middleware do?`
|
|
14
|
+
- **Make changes** -- `@custie add input validation to the signup endpoint`
|
|
15
|
+
- **Run commands** -- `@custie run the test suite and fix any failures`
|
|
16
|
+
- **Use skills & MCP servers** -- everything Claude Code can do, your bot can do
|
|
17
|
+
|
|
18
|
+
Sessions persist across messages. Start a conversation in a thread, come back hours later, and pick up right where you left off.
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<img src="how.png" alt="Custie in action" width="480" />
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
## How It Works
|
|
25
|
+
|
|
26
|
+
Custie runs on your machine (or server) and connects to Slack via **Socket Mode** -- no webhooks, no tunnels, no public URLs. When someone mentions the bot, it spawns a real Claude Code process with full access to your project.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
@custie in Slack --> Custie server --> Claude Code CLI --> your codebase
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Get Started
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g @anthropic-ai/claude-code # prerequisite
|
|
36
|
+
npm install -g custie # install custie
|
|
37
|
+
custie setup # configure Slack app + tokens
|
|
38
|
+
custie start # run the bot
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That's it. Go to Slack and `@custie hello`.
|
|
42
|
+
|
|
43
|
+
> **Want it always running?** Run `custie install` to set up a background service (launchd on macOS, systemd on Linux).
|
|
44
|
+
|
|
45
|
+
## Customise Your Bot
|
|
46
|
+
|
|
47
|
+
**System prompt** -- Define your bot's personality and behaviour:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
custie prompt # opens ~/.config/custie/prompt.md in your editor
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Access control** -- By default, only the owner can use the bot (it runs with full filesystem access). Add trusted user IDs to open it up:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
custie config --edit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI Reference
|
|
60
|
+
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `custie setup` | Automated setup via Playwright (falls back to manual) |
|
|
64
|
+
| `custie setup --manual` | Manual setup (paste tokens yourself) |
|
|
65
|
+
| `custie start` | Run the bot (foreground) |
|
|
66
|
+
| `custie install` | Install as background service |
|
|
67
|
+
| `custie uninstall` | Remove background service |
|
|
68
|
+
| `custie prompt` | Edit system prompt |
|
|
69
|
+
| `custie config` | Show current config |
|
|
70
|
+
| `custie config --edit` | Edit config file |
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
All config lives in `~/.config/custie/`:
|
|
75
|
+
|
|
76
|
+
| File | Purpose |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `config.env` | Slack tokens, working directory, access control |
|
|
79
|
+
| `prompt.md` | System prompt (bot personality & instructions) |
|
|
80
|
+
|
|
81
|
+
| Variable | Required | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`) |
|
|
84
|
+
| `SLACK_APP_TOKEN` | Yes | App-Level Token (`xapp-...`) |
|
|
85
|
+
| `SLACK_SIGNING_SECRET` | Yes | From Slack app Basic Information |
|
|
86
|
+
| `CLAUDE_CWD` | No | Working directory for Claude |
|
|
87
|
+
| `BOT_NAME` | No | Display name (default: Custie) |
|
|
88
|
+
| `OWNER_USER_ID` | No | Your Slack user ID |
|
|
89
|
+
| `ALLOWED_USER_IDS` | No | Who can use the bot (defaults to owner) |
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone <repo-url> && cd custie
|
|
95
|
+
pnpm install
|
|
96
|
+
pnpm run dev # hot-reload
|
|
97
|
+
pnpm run build # compile
|
|
98
|
+
pnpm run lint # oxlint
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Architecture
|
|
102
|
+
|
|
103
|
+

|
|
104
|
+
|
|
105
|
+
## Licence
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config.ts
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import { existsSync as existsSync2 } from "fs";
|
|
6
|
+
import { resolve as resolve2 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/paths.ts
|
|
9
|
+
import { existsSync, mkdirSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join, resolve } from "path";
|
|
12
|
+
var home = homedir();
|
|
13
|
+
var XDG_CONFIG_HOME = process.env["XDG_CONFIG_HOME"] || join(home, ".config");
|
|
14
|
+
var XDG_DATA_HOME = process.env["XDG_DATA_HOME"] || join(home, ".local", "share");
|
|
15
|
+
var paths = {
|
|
16
|
+
CONFIG_DIR: join(XDG_CONFIG_HOME, "custie"),
|
|
17
|
+
DATA_DIR: join(XDG_DATA_HOME, "custie"),
|
|
18
|
+
get CONFIG_FILE() {
|
|
19
|
+
return join(this.CONFIG_DIR, "config.env");
|
|
20
|
+
},
|
|
21
|
+
get PROMPT_FILE() {
|
|
22
|
+
return join(this.CONFIG_DIR, "prompt.md");
|
|
23
|
+
},
|
|
24
|
+
get DB_FILE() {
|
|
25
|
+
return join(this.DATA_DIR, "custie.db");
|
|
26
|
+
},
|
|
27
|
+
get LOG_DIR() {
|
|
28
|
+
return join(this.DATA_DIR, "logs");
|
|
29
|
+
},
|
|
30
|
+
PACKAGE_ROOT: resolve(import.meta.dirname, "..")
|
|
31
|
+
};
|
|
32
|
+
function ensureDirs() {
|
|
33
|
+
for (const dir of [paths.CONFIG_DIR, paths.DATA_DIR, paths.LOG_DIR]) {
|
|
34
|
+
if (!existsSync(dir)) {
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/config.ts
|
|
41
|
+
function loadEnvFiles() {
|
|
42
|
+
if (existsSync2(paths.CONFIG_FILE)) {
|
|
43
|
+
dotenv.config({ path: paths.CONFIG_FILE, override: false });
|
|
44
|
+
}
|
|
45
|
+
const repoEnv = resolve2(process.cwd(), ".env");
|
|
46
|
+
if (existsSync2(repoEnv)) {
|
|
47
|
+
dotenv.config({ path: repoEnv, override: false });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function requireEnv(name) {
|
|
51
|
+
const value = process.env[name];
|
|
52
|
+
if (!value) {
|
|
53
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function loadConfig() {
|
|
58
|
+
const ownerUserId = process.env["OWNER_USER_ID"] || void 0;
|
|
59
|
+
const allowedUserIds = new Set(
|
|
60
|
+
(process.env["ALLOWED_USER_IDS"] ?? "").split(",").map((s) => s.trim()).filter(Boolean)
|
|
61
|
+
);
|
|
62
|
+
if (ownerUserId && allowedUserIds.size > 0) {
|
|
63
|
+
allowedUserIds.add(ownerUserId);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
slackBotToken: requireEnv("SLACK_BOT_TOKEN"),
|
|
67
|
+
slackAppToken: requireEnv("SLACK_APP_TOKEN"),
|
|
68
|
+
slackSigningSecret: requireEnv("SLACK_SIGNING_SECRET"),
|
|
69
|
+
claudeCwd: process.env["CLAUDE_CWD"] ?? process.cwd(),
|
|
70
|
+
claudeConfigDir: process.env["CLAUDE_CONFIG_DIR"] || void 0,
|
|
71
|
+
botName: process.env["BOT_NAME"] ?? "Custie",
|
|
72
|
+
allowedUserIds,
|
|
73
|
+
maxTurns: parseInt(process.env["MAX_TURNS"] ?? "10", 10),
|
|
74
|
+
ownerUserId
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/slack/app.ts
|
|
79
|
+
import { App } from "@slack/bolt";
|
|
80
|
+
function createSlackApp(config) {
|
|
81
|
+
return new App({
|
|
82
|
+
token: config.slackBotToken,
|
|
83
|
+
appToken: config.slackAppToken,
|
|
84
|
+
signingSecret: config.slackSigningSecret,
|
|
85
|
+
socketMode: true
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/claude/agent.ts
|
|
90
|
+
import { spawn } from "child_process";
|
|
91
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
92
|
+
import { resolve as resolve3 } from "path";
|
|
93
|
+
var debug = process.env["DEBUG"] === "true";
|
|
94
|
+
function loadSystemPrompt() {
|
|
95
|
+
const customPath = paths.PROMPT_FILE;
|
|
96
|
+
const defaultPath = resolve3(paths.PACKAGE_ROOT, "system.default.md");
|
|
97
|
+
const filePath = existsSync3(customPath) ? customPath : defaultPath;
|
|
98
|
+
return readFileSync(filePath, "utf-8").trim();
|
|
99
|
+
}
|
|
100
|
+
function buildSystemPrompt(botName) {
|
|
101
|
+
return loadSystemPrompt().replaceAll("{{botName}}", botName);
|
|
102
|
+
}
|
|
103
|
+
function buildArgs(prompt, botName, resumeSessionId) {
|
|
104
|
+
const args = [
|
|
105
|
+
"--print",
|
|
106
|
+
"--output-format",
|
|
107
|
+
"json",
|
|
108
|
+
"--dangerously-skip-permissions",
|
|
109
|
+
"--no-chrome",
|
|
110
|
+
"--append-system-prompt",
|
|
111
|
+
buildSystemPrompt(botName),
|
|
112
|
+
"--setting-sources",
|
|
113
|
+
"user,project,local"
|
|
114
|
+
];
|
|
115
|
+
if (resumeSessionId) {
|
|
116
|
+
args.push("--resume", resumeSessionId);
|
|
117
|
+
}
|
|
118
|
+
args.push(prompt);
|
|
119
|
+
return args;
|
|
120
|
+
}
|
|
121
|
+
function runCli(prompt, cwd, botName, claudeConfigDir, resumeSessionId) {
|
|
122
|
+
return new Promise((resolve4, reject) => {
|
|
123
|
+
const args = buildArgs(prompt, botName, resumeSessionId);
|
|
124
|
+
const env = { ...process.env };
|
|
125
|
+
if (claudeConfigDir) {
|
|
126
|
+
env["CLAUDE_CONFIG_DIR"] = claudeConfigDir;
|
|
127
|
+
}
|
|
128
|
+
if (debug) {
|
|
129
|
+
console.log(`[agent] spawning claude CLI in ${cwd}`);
|
|
130
|
+
console.log(`[agent] args: ${args.join(" ")}`);
|
|
131
|
+
}
|
|
132
|
+
const child = spawn("claude", args, {
|
|
133
|
+
cwd,
|
|
134
|
+
env,
|
|
135
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
136
|
+
});
|
|
137
|
+
child.stdin.end();
|
|
138
|
+
let stdout = "";
|
|
139
|
+
let stderr = "";
|
|
140
|
+
child.stdout.on("data", (data) => {
|
|
141
|
+
stdout += data.toString();
|
|
142
|
+
});
|
|
143
|
+
child.stderr.on("data", (data) => {
|
|
144
|
+
stderr += data.toString();
|
|
145
|
+
});
|
|
146
|
+
child.on("error", (err) => {
|
|
147
|
+
reject(new Error(`Failed to spawn claude CLI: ${err.message}`));
|
|
148
|
+
});
|
|
149
|
+
child.on("close", (code) => {
|
|
150
|
+
if (debug && stderr) {
|
|
151
|
+
console.log(`[agent] stderr: ${stderr.trim()}`);
|
|
152
|
+
}
|
|
153
|
+
if (code !== 0 && !stdout.trim()) {
|
|
154
|
+
reject(new Error(`claude CLI exited with code ${code}: ${stderr.trim()}`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const result = JSON.parse(stdout.trim());
|
|
159
|
+
const sessionId = result.session_id ?? resumeSessionId ?? "";
|
|
160
|
+
if (result.type === "result" && result.subtype === "success") {
|
|
161
|
+
resolve4({ sessionId, text: result.result ?? "" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (debug) console.log(`[agent] error result:`, JSON.stringify(result));
|
|
165
|
+
if (result.subtype === "error_max_turns") {
|
|
166
|
+
resolve4({
|
|
167
|
+
sessionId,
|
|
168
|
+
text: `That query was a bit too complex for me to handle here. You can continue this session directly:
|
|
169
|
+
\`claude --resume ${sessionId}\``
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const errors = (result.errors ?? []).filter(Boolean).join(", ") || "Unknown error";
|
|
174
|
+
resolve4({ sessionId, text: `Error: ${errors}` });
|
|
175
|
+
} catch {
|
|
176
|
+
resolve4({ sessionId: resumeSessionId ?? "", text: stdout.trim() || "No response" });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async function askClaude(prompt, cwd, botName, _maxTurns, claudeConfigDir, resumeSessionId) {
|
|
182
|
+
try {
|
|
183
|
+
return await runCli(prompt, cwd, botName, claudeConfigDir, resumeSessionId);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (resumeSessionId) {
|
|
186
|
+
if (debug) console.log(`[agent] session resume failed, starting fresh session`);
|
|
187
|
+
return await runCli(prompt, cwd, botName, claudeConfigDir);
|
|
188
|
+
}
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/queue/message-queue.ts
|
|
194
|
+
var MessageQueue = class {
|
|
195
|
+
chains = /* @__PURE__ */ new Map();
|
|
196
|
+
enqueue(threadKey, task) {
|
|
197
|
+
const prev = this.chains.get(threadKey) ?? Promise.resolve();
|
|
198
|
+
const next = prev.then(task).catch((err) => {
|
|
199
|
+
console.error(`[queue] Error processing ${threadKey}:`, err);
|
|
200
|
+
});
|
|
201
|
+
this.chains.set(threadKey, next);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// src/slack/formatters.ts
|
|
206
|
+
var SLACK_MAX_LENGTH = 2900;
|
|
207
|
+
function toSlackMarkdown(text) {
|
|
208
|
+
let result = text;
|
|
209
|
+
result = result.replace(/```\w*\n/g, "```\n");
|
|
210
|
+
result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
|
|
211
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
|
|
212
|
+
result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
function splitMessage(text) {
|
|
216
|
+
if (text.length <= SLACK_MAX_LENGTH) return [text];
|
|
217
|
+
const chunks = [];
|
|
218
|
+
let remaining = text;
|
|
219
|
+
while (remaining.length > 0) {
|
|
220
|
+
if (remaining.length <= SLACK_MAX_LENGTH) {
|
|
221
|
+
chunks.push(remaining);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
let splitAt = remaining.lastIndexOf("\n", SLACK_MAX_LENGTH);
|
|
225
|
+
if (splitAt < SLACK_MAX_LENGTH / 2) {
|
|
226
|
+
splitAt = remaining.lastIndexOf(" ", SLACK_MAX_LENGTH);
|
|
227
|
+
}
|
|
228
|
+
if (splitAt < SLACK_MAX_LENGTH / 2) {
|
|
229
|
+
splitAt = SLACK_MAX_LENGTH;
|
|
230
|
+
}
|
|
231
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
232
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
233
|
+
}
|
|
234
|
+
return chunks;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/slack/listeners.ts
|
|
238
|
+
var REJECT_MESSAGES = [
|
|
239
|
+
"Sorry, I'm a personal assistant and only respond to my owner. :bow:",
|
|
240
|
+
"I appreciate the interest, but I'm exclusively dedicated to my owner. :lock:",
|
|
241
|
+
"Flattered you'd ask, but I'm a one-person bot. :robot_face:",
|
|
242
|
+
"I'm on a strict guest list, and you're not on it \u2014 yet! :clipboard:",
|
|
243
|
+
"My owner keeps me on a short leash. Nothing personal! :dog:"
|
|
244
|
+
];
|
|
245
|
+
function getRejectMessage() {
|
|
246
|
+
return REJECT_MESSAGES[Math.floor(Math.random() * REJECT_MESSAGES.length)];
|
|
247
|
+
}
|
|
248
|
+
var debug2 = process.env["DEBUG"] === "true";
|
|
249
|
+
var nameCache = /* @__PURE__ */ new Map();
|
|
250
|
+
async function resolveUser(client, userId) {
|
|
251
|
+
const cached = nameCache.get(userId);
|
|
252
|
+
if (cached) return cached;
|
|
253
|
+
try {
|
|
254
|
+
const res = await client.users.info({ user: userId });
|
|
255
|
+
const name = res.user?.real_name || res.user?.name || userId;
|
|
256
|
+
nameCache.set(userId, name);
|
|
257
|
+
return name;
|
|
258
|
+
} catch {
|
|
259
|
+
return userId;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function resolveChannel(client, channelId) {
|
|
263
|
+
const cached = nameCache.get(channelId);
|
|
264
|
+
if (cached) return cached;
|
|
265
|
+
try {
|
|
266
|
+
const res = await client.conversations.info({ channel: channelId });
|
|
267
|
+
const name = res.channel?.name || channelId;
|
|
268
|
+
nameCache.set(channelId, name);
|
|
269
|
+
return name;
|
|
270
|
+
} catch {
|
|
271
|
+
return channelId;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
var SUBTEAM_MENTION_PATTERN = /<!subteam\^(S[A-Z0-9]+)(?:\|[^>]*)?>/g;
|
|
275
|
+
var SESSION_ID_PATTERN = /claude\s+--resume\s+([0-9a-f-]{36})/;
|
|
276
|
+
async function extractSessionFromParent(client, channelId, threadTs) {
|
|
277
|
+
try {
|
|
278
|
+
const res = await client.conversations.replies({
|
|
279
|
+
channel: channelId,
|
|
280
|
+
ts: threadTs,
|
|
281
|
+
limit: 1,
|
|
282
|
+
inclusive: true
|
|
283
|
+
});
|
|
284
|
+
const parentText = res.messages?.[0]?.text ?? "";
|
|
285
|
+
const match = parentText.match(SESSION_ID_PATTERN);
|
|
286
|
+
return match?.[1];
|
|
287
|
+
} catch {
|
|
288
|
+
return void 0;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
var groupMemberCache = /* @__PURE__ */ new Map();
|
|
292
|
+
var GROUP_CACHE_TTL = 10 * 60 * 1e3;
|
|
293
|
+
async function isOwnerInSubteam(client, subteamId, ownerUserId) {
|
|
294
|
+
const cached = groupMemberCache.get(subteamId);
|
|
295
|
+
if (cached && Date.now() - cached.fetchedAt < GROUP_CACHE_TTL) {
|
|
296
|
+
return cached.members.has(ownerUserId);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const res = await client.usergroups.users.list({ usergroup: subteamId });
|
|
300
|
+
const members = new Set(res.users ?? []);
|
|
301
|
+
groupMemberCache.set(subteamId, { members, fetchedAt: Date.now() });
|
|
302
|
+
return members.has(ownerUserId);
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function registerListeners(app, store, config) {
|
|
308
|
+
const { claudeCwd, claudeConfigDir, botName, allowedUserIds, maxTurns, ownerUserId } = config;
|
|
309
|
+
const queue = new MessageQueue();
|
|
310
|
+
let botUserId;
|
|
311
|
+
async function ensureBotUserId(client) {
|
|
312
|
+
if (botUserId) return botUserId;
|
|
313
|
+
const auth = await client.auth.test();
|
|
314
|
+
botUserId = auth.user_id;
|
|
315
|
+
return botUserId;
|
|
316
|
+
}
|
|
317
|
+
async function handleMessage(client, say, channelId, sessionKey, prompt, sessionId, threadTs) {
|
|
318
|
+
const typingMsg = await client.chat.postMessage({
|
|
319
|
+
channel: channelId,
|
|
320
|
+
...threadTs ? { thread_ts: threadTs } : {},
|
|
321
|
+
text: "_Thinking ..._"
|
|
322
|
+
});
|
|
323
|
+
try {
|
|
324
|
+
const response = await askClaude(prompt, claudeCwd, botName, maxTurns, claudeConfigDir, sessionId);
|
|
325
|
+
store.saveSession(channelId, sessionKey, response.sessionId);
|
|
326
|
+
const formatted = toSlackMarkdown(response.text);
|
|
327
|
+
const chunks = splitMessage(formatted);
|
|
328
|
+
if (debug2) {
|
|
329
|
+
console.log(`[response] length=${response.text.length} chunks=${chunks.length} chunkSizes=[${chunks.map((c) => c.length).join(",")}]`);
|
|
330
|
+
}
|
|
331
|
+
await client.chat.update({
|
|
332
|
+
channel: channelId,
|
|
333
|
+
ts: typingMsg.ts,
|
|
334
|
+
text: chunks[0]
|
|
335
|
+
});
|
|
336
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
337
|
+
await say({ text: chunks[i], ...threadTs ? { thread_ts: threadTs } : {} });
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error("[listener] Error handling message:", err);
|
|
341
|
+
await client.chat.update({
|
|
342
|
+
channel: channelId,
|
|
343
|
+
ts: typingMsg.ts,
|
|
344
|
+
text: "Sorry, something went wrong. Please try again."
|
|
345
|
+
});
|
|
346
|
+
} finally {
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
app.event("app_mention", async ({ event, client, say }) => {
|
|
350
|
+
const threadTs = event.thread_ts ?? event.ts;
|
|
351
|
+
const channelId = event.channel;
|
|
352
|
+
const threadKey = `${channelId}:${threadTs}`;
|
|
353
|
+
if (!event.user) return;
|
|
354
|
+
if (allowedUserIds.size > 0 && !allowedUserIds.has(event.user)) {
|
|
355
|
+
await say({ text: getRejectMessage(), thread_ts: threadTs });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const userId = await ensureBotUserId(client);
|
|
359
|
+
const prompt = event.text.replace(new RegExp(`<@${userId}>`, "g"), "").trim();
|
|
360
|
+
if (!prompt) return;
|
|
361
|
+
if (debug2) {
|
|
362
|
+
const [userName, channelName] = await Promise.all([
|
|
363
|
+
resolveUser(client, event.user),
|
|
364
|
+
resolveChannel(client, channelId)
|
|
365
|
+
]);
|
|
366
|
+
console.log(`[mention] user=${userName} channel=#${channelName} thread=${threadTs} prompt="${prompt}"`);
|
|
367
|
+
}
|
|
368
|
+
queue.enqueue(threadKey, async () => {
|
|
369
|
+
let sessionId = store.getSession(channelId, threadTs)?.sessionId;
|
|
370
|
+
if (!sessionId) {
|
|
371
|
+
sessionId = await extractSessionFromParent(client, channelId, threadTs);
|
|
372
|
+
if (debug2 && sessionId) {
|
|
373
|
+
console.log(`[mention] resuming session from parent message: ${sessionId}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
await handleMessage(client, say, channelId, threadTs, prompt, sessionId, threadTs);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
app.event("message", async ({ event, client, say }) => {
|
|
380
|
+
if (!("text" in event) || !event.text) return;
|
|
381
|
+
if ("bot_id" in event && event.bot_id) return;
|
|
382
|
+
if ("subtype" in event && event.subtype) return;
|
|
383
|
+
if (ownerUserId) {
|
|
384
|
+
let ownerMentioned = event.text.includes(`<@${ownerUserId}>`);
|
|
385
|
+
if (!ownerMentioned) {
|
|
386
|
+
const subteamIds = [...event.text.matchAll(SUBTEAM_MENTION_PATTERN)].map((m) => m[1]);
|
|
387
|
+
for (const subteamId of subteamIds) {
|
|
388
|
+
if (await isOwnerInSubteam(client, subteamId, ownerUserId)) {
|
|
389
|
+
ownerMentioned = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (ownerMentioned) {
|
|
395
|
+
try {
|
|
396
|
+
await client.reactions.add({
|
|
397
|
+
channel: event.channel,
|
|
398
|
+
timestamp: event.ts,
|
|
399
|
+
name: "eyes"
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (debug2) console.log("[owner-mention] Failed to add eyes reaction:", err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const senderId = "user" in event ? event.user : void 0;
|
|
407
|
+
if (allowedUserIds.size > 0 && (!senderId || !allowedUserIds.has(senderId))) return;
|
|
408
|
+
const channelId = event.channel;
|
|
409
|
+
const channelType = "channel_type" in event ? event.channel_type : void 0;
|
|
410
|
+
const isDM = channelType === "im";
|
|
411
|
+
if (isDM) {
|
|
412
|
+
const threadTs2 = "thread_ts" in event ? event.thread_ts : void 0;
|
|
413
|
+
const sessionKey = threadTs2 ?? channelId;
|
|
414
|
+
const threadKey2 = `${channelId}:${sessionKey}`;
|
|
415
|
+
const prompt2 = event.text.trim();
|
|
416
|
+
if (!prompt2) return;
|
|
417
|
+
if (debug2) {
|
|
418
|
+
const userName = senderId ? await resolveUser(client, senderId) : "unknown";
|
|
419
|
+
console.log(`[dm] user=${userName} thread=${sessionKey} prompt="${prompt2}"`);
|
|
420
|
+
}
|
|
421
|
+
queue.enqueue(threadKey2, async () => {
|
|
422
|
+
const session2 = store.getSession(channelId, sessionKey);
|
|
423
|
+
await handleMessage(client, say, channelId, sessionKey, prompt2, session2?.sessionId, threadTs2);
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!("thread_ts" in event) || !event.thread_ts) return;
|
|
428
|
+
const threadTs = event.thread_ts;
|
|
429
|
+
const threadKey = `${channelId}:${threadTs}`;
|
|
430
|
+
const session = store.getSession(channelId, threadTs);
|
|
431
|
+
if (!session) return;
|
|
432
|
+
const userId = await ensureBotUserId(client);
|
|
433
|
+
if (event.text.includes(`<@${userId}>`)) return;
|
|
434
|
+
if (/<@U[A-Z0-9]+>/.test(event.text)) return;
|
|
435
|
+
const prompt = event.text.trim();
|
|
436
|
+
if (!prompt) return;
|
|
437
|
+
if (debug2) {
|
|
438
|
+
const [userName, channelName] = await Promise.all([
|
|
439
|
+
senderId ? resolveUser(client, senderId) : Promise.resolve("unknown"),
|
|
440
|
+
resolveChannel(client, channelId)
|
|
441
|
+
]);
|
|
442
|
+
console.log(`[thread] user=${userName} channel=#${channelName} thread=${threadTs} prompt="${prompt}"`);
|
|
443
|
+
}
|
|
444
|
+
queue.enqueue(threadKey, async () => {
|
|
445
|
+
await handleMessage(client, say, channelId, threadTs, prompt, session.sessionId, threadTs);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/store/session-store.ts
|
|
451
|
+
import Database from "better-sqlite3";
|
|
452
|
+
var SessionStore = class {
|
|
453
|
+
db;
|
|
454
|
+
constructor(dbPath) {
|
|
455
|
+
this.db = new Database(dbPath);
|
|
456
|
+
this.db.pragma("journal_mode = WAL");
|
|
457
|
+
this.init();
|
|
458
|
+
}
|
|
459
|
+
init() {
|
|
460
|
+
this.db.exec(`
|
|
461
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
462
|
+
channel_id TEXT NOT NULL,
|
|
463
|
+
thread_ts TEXT NOT NULL,
|
|
464
|
+
session_id TEXT NOT NULL,
|
|
465
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
466
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
467
|
+
PRIMARY KEY (channel_id, thread_ts)
|
|
468
|
+
)
|
|
469
|
+
`);
|
|
470
|
+
}
|
|
471
|
+
getSession(channelId, threadTs) {
|
|
472
|
+
const row = this.db.prepare("SELECT * FROM sessions WHERE channel_id = ? AND thread_ts = ?").get(channelId, threadTs);
|
|
473
|
+
if (!row) return void 0;
|
|
474
|
+
return {
|
|
475
|
+
channelId: row.channel_id,
|
|
476
|
+
threadTs: row.thread_ts,
|
|
477
|
+
sessionId: row.session_id,
|
|
478
|
+
createdAt: row.created_at,
|
|
479
|
+
updatedAt: row.updated_at
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
saveSession(channelId, threadTs, sessionId) {
|
|
483
|
+
this.db.prepare(
|
|
484
|
+
`INSERT INTO sessions (channel_id, thread_ts, session_id)
|
|
485
|
+
VALUES (?, ?, ?)
|
|
486
|
+
ON CONFLICT (channel_id, thread_ts)
|
|
487
|
+
DO UPDATE SET session_id = excluded.session_id, updated_at = datetime('now')`
|
|
488
|
+
).run(channelId, threadTs, sessionId);
|
|
489
|
+
}
|
|
490
|
+
deleteSession(channelId, threadTs) {
|
|
491
|
+
this.db.prepare("DELETE FROM sessions WHERE channel_id = ? AND thread_ts = ?").run(channelId, threadTs);
|
|
492
|
+
}
|
|
493
|
+
close() {
|
|
494
|
+
this.db.close();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// src/index.ts
|
|
499
|
+
async function startServer() {
|
|
500
|
+
ensureDirs();
|
|
501
|
+
const config = loadConfig();
|
|
502
|
+
const store = new SessionStore(paths.DB_FILE);
|
|
503
|
+
const app = createSlackApp(config);
|
|
504
|
+
registerListeners(app, store, config);
|
|
505
|
+
await app.start();
|
|
506
|
+
console.log("[custie] Server is running (Socket Mode)");
|
|
507
|
+
const shutdown = () => {
|
|
508
|
+
console.log("[custie] Shutting down...");
|
|
509
|
+
store.close();
|
|
510
|
+
process.exit(0);
|
|
511
|
+
};
|
|
512
|
+
process.on("SIGINT", shutdown);
|
|
513
|
+
process.on("SIGTERM", shutdown);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export {
|
|
517
|
+
paths,
|
|
518
|
+
ensureDirs,
|
|
519
|
+
loadEnvFiles,
|
|
520
|
+
startServer
|
|
521
|
+
};
|
package/dist/cli.d.ts
ADDED