clankie 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -13
- package/dist/cli.js +301851 -0
- package/dist/koffi-216xhpes.node +0 -0
- package/dist/koffi-2erktc37.node +0 -0
- package/dist/koffi-2rrez93a.node +0 -0
- package/dist/koffi-2wv0r22g.node +0 -0
- package/dist/koffi-3kae4xj3.node +0 -0
- package/dist/koffi-3rkr2zqv.node +0 -0
- package/dist/koffi-abxfktv9.node +0 -0
- package/dist/koffi-c67c0c5b.node +0 -0
- package/dist/koffi-cnf0q0dx.node +0 -0
- package/dist/koffi-df38sqz5.node +0 -0
- package/dist/koffi-gfbqb3a0.node +0 -0
- package/dist/koffi-kjemmmem.node +0 -0
- package/dist/koffi-kkrfq9yv.node +0 -0
- package/dist/koffi-mzaqwwqy.node +0 -0
- package/dist/koffi-q49fgkeq.node +0 -0
- package/dist/koffi-q54bk8bf.node +0 -0
- package/dist/koffi-x1790w0j.node +0 -0
- package/dist/koffi-yxvjwcj6.node +0 -0
- package/package.json +17 -7
- package/web-ui-dist/_shell.html +2 -2
- package/web-ui-dist/assets/{card-kSKmECr1.js → card-BUP-xovx.js} +1 -1
- package/web-ui-dist/assets/extensions-DC620Nmx.js +1 -0
- package/web-ui-dist/assets/{index-CXJ3n5rE.js → index-DurjG9O_.js} +1 -1
- package/web-ui-dist/assets/{loader-circle-C5ib508E.js → loader-circle-DbOtKfCA.js} +1 -1
- package/web-ui-dist/assets/{main-cBOaKYCP.js → main-B2sRcuyZ.js} +8 -8
- package/web-ui-dist/assets/{sessions._sessionId-BIeINoSQ.js → sessions._sessionId-BJazw9EJ.js} +1 -1
- package/web-ui-dist/assets/{settings-CO37Obvo.js → settings-Bv8oeIho.js} +1 -1
- package/web-ui-dist/assets/styles-D2oHO1JL.css +1 -0
- package/src/agent.ts +0 -107
- package/src/channels/channel.ts +0 -57
- package/src/channels/slack.ts +0 -374
- package/src/channels/web.ts +0 -1362
- package/src/cli.ts +0 -505
- package/src/config.ts +0 -257
- package/src/daemon.ts +0 -380
- package/src/service.ts +0 -372
- package/src/sessions.ts +0 -251
- package/web-ui-dist/assets/extensions-CFPfugfg.js +0 -1
- package/web-ui-dist/assets/styles-BQfA8H-l.css +0 -1
package/src/channels/slack.ts
DELETED
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Slack channel — uses Socket Mode (WebSocket-based, no public URL needed).
|
|
3
|
-
*
|
|
4
|
-
* Requires:
|
|
5
|
-
* - Slack app with Socket Mode enabled
|
|
6
|
-
* - App token (xapp-...) for Socket Mode connection
|
|
7
|
-
* - Bot token (xoxb-...) for API calls
|
|
8
|
-
* - Bot scopes: app_mentions:read, chat:write, files:read, im:history, mpim:history,
|
|
9
|
-
* channels:history, channels:read, groups:history, groups:read
|
|
10
|
-
* - Event subscriptions: app_mention, message.channels, message.groups, message.im, message.mpim
|
|
11
|
-
*
|
|
12
|
-
* Responds to:
|
|
13
|
-
* - @mentions in channels and private channels (starts a conversation thread)
|
|
14
|
-
* - Messages in threads where bot was @mentioned (continues conversation)
|
|
15
|
-
* - Direct messages (1:1 and multi-party DMs)
|
|
16
|
-
*
|
|
17
|
-
* Features:
|
|
18
|
-
* - File attachments (downloads from Slack, converts to base64)
|
|
19
|
-
* - Thread persistence (survives daemon restarts, 7-day TTL)
|
|
20
|
-
* - Channel allowlisting (optional)
|
|
21
|
-
* - User allowlisting (required)
|
|
22
|
-
* - Link unfurling disabled (keeps responses clean)
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
-
import { homedir } from "node:os";
|
|
27
|
-
import { join } from "node:path";
|
|
28
|
-
import { SocketModeClient } from "@slack/socket-mode";
|
|
29
|
-
import { WebClient } from "@slack/web-api";
|
|
30
|
-
import type { Attachment, Channel, InboundMessage, MessageHandler, SendOptions } from "./channel.ts";
|
|
31
|
-
|
|
32
|
-
const SLACK_MAX_LENGTH = 4000; // Slack's actual limit is ~40k, but chunk conservatively
|
|
33
|
-
const ACTIVE_THREADS_FILE = join(homedir(), ".clankie", "slack-active-threads.json");
|
|
34
|
-
const THREAD_TTL_DAYS = 7; // Threads older than this are cleaned up
|
|
35
|
-
|
|
36
|
-
export interface SlackChannelOptions {
|
|
37
|
-
/** App token from Slack app settings (xapp-...) */
|
|
38
|
-
appToken: string;
|
|
39
|
-
/** Bot token from Slack app settings (xoxb-...) */
|
|
40
|
-
botToken: string;
|
|
41
|
-
/** Allowed Slack user IDs. Empty = deny all. */
|
|
42
|
-
allowedUsers: string[];
|
|
43
|
-
/** Allowed Slack channel IDs. Empty = allow all. */
|
|
44
|
-
allowedChannelIds?: string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export class SlackChannel implements Channel {
|
|
48
|
-
readonly name = "slack";
|
|
49
|
-
private socketClient: SocketModeClient;
|
|
50
|
-
private webClient: WebClient;
|
|
51
|
-
private allowedUsers: Set<string>;
|
|
52
|
-
private allowedChannelIds: Set<string> | null;
|
|
53
|
-
private handler: MessageHandler | undefined;
|
|
54
|
-
private botUserId: string | null = null;
|
|
55
|
-
/** Threads where bot has been @mentioned - Map<threadId, timestamp> for TTL cleanup */
|
|
56
|
-
private activeThreads: Map<string, number> = new Map();
|
|
57
|
-
|
|
58
|
-
constructor(private options: SlackChannelOptions) {
|
|
59
|
-
this.socketClient = new SocketModeClient({
|
|
60
|
-
appToken: options.appToken,
|
|
61
|
-
// biome-ignore lint/suspicious/noExplicitAny: Slack SDK logLevel type is not exported
|
|
62
|
-
logLevel: "ERROR" as any, // Suppress noisy internal logging
|
|
63
|
-
});
|
|
64
|
-
this.webClient = new WebClient(options.botToken);
|
|
65
|
-
this.allowedUsers = new Set(options.allowedUsers);
|
|
66
|
-
// null = allow all channels; Set = filter by channel ID
|
|
67
|
-
this.allowedChannelIds = options.allowedChannelIds?.length ? new Set(options.allowedChannelIds) : null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async start(handler: MessageHandler): Promise<void> {
|
|
71
|
-
this.handler = handler;
|
|
72
|
-
|
|
73
|
-
// Load persisted active threads
|
|
74
|
-
this.loadActiveThreads();
|
|
75
|
-
|
|
76
|
-
// Get bot user ID
|
|
77
|
-
const auth = await this.webClient.auth.test();
|
|
78
|
-
this.botUserId = auth.user_id as string;
|
|
79
|
-
|
|
80
|
-
this.setupEventHandlers();
|
|
81
|
-
await this.socketClient.start();
|
|
82
|
-
|
|
83
|
-
console.log(`[slack] Connected as ${auth.user} (${this.botUserId})`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async send(chatId: string, text: string, options?: SendOptions): Promise<void> {
|
|
87
|
-
if (text.length <= SLACK_MAX_LENGTH) {
|
|
88
|
-
await this.webClient.chat.postMessage({
|
|
89
|
-
channel: chatId,
|
|
90
|
-
text,
|
|
91
|
-
thread_ts: options?.threadId,
|
|
92
|
-
unfurl_links: false,
|
|
93
|
-
unfurl_media: false,
|
|
94
|
-
});
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Split long messages
|
|
99
|
-
const chunks = this.splitMessage(text, SLACK_MAX_LENGTH);
|
|
100
|
-
for (const chunk of chunks) {
|
|
101
|
-
await this.webClient.chat.postMessage({
|
|
102
|
-
channel: chatId,
|
|
103
|
-
text: chunk,
|
|
104
|
-
thread_ts: options?.threadId,
|
|
105
|
-
unfurl_links: false,
|
|
106
|
-
unfurl_media: false,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async stop(): Promise<void> {
|
|
112
|
-
await this.socketClient.disconnect();
|
|
113
|
-
console.log("[slack] Disconnected");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ─── Private helpers ──────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
/** Check if a channel is allowed (null allowedChannelIds = allow all) */
|
|
119
|
-
private isChannelAllowed(channelId: string): boolean {
|
|
120
|
-
return this.allowedChannelIds === null || this.allowedChannelIds.has(channelId);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Load active threads from disk (with TTL cleanup) */
|
|
124
|
-
private loadActiveThreads(): void {
|
|
125
|
-
if (!existsSync(ACTIVE_THREADS_FILE)) return;
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const raw = readFileSync(ACTIVE_THREADS_FILE, "utf-8");
|
|
129
|
-
const data = JSON.parse(raw) as Record<string, number>;
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
const ttlMs = THREAD_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
132
|
-
|
|
133
|
-
let loaded = 0;
|
|
134
|
-
let expired = 0;
|
|
135
|
-
|
|
136
|
-
for (const [threadId, timestamp] of Object.entries(data)) {
|
|
137
|
-
if (now - timestamp > ttlMs) {
|
|
138
|
-
expired++;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
this.activeThreads.set(threadId, timestamp);
|
|
142
|
-
loaded++;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (loaded > 0) {
|
|
146
|
-
console.log(`[slack] Loaded ${loaded} active thread(s) from disk${expired > 0 ? ` (${expired} expired)` : ""}`);
|
|
147
|
-
}
|
|
148
|
-
} catch (err) {
|
|
149
|
-
console.warn(`[slack] Failed to load active threads: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Save active threads to disk */
|
|
154
|
-
private saveActiveThreads(): void {
|
|
155
|
-
try {
|
|
156
|
-
const dir = join(homedir(), ".clankie");
|
|
157
|
-
if (!existsSync(dir)) {
|
|
158
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const data: Record<string, number> = {};
|
|
162
|
-
for (const [threadId, timestamp] of this.activeThreads.entries()) {
|
|
163
|
-
data[threadId] = timestamp;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
writeFileSync(ACTIVE_THREADS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
167
|
-
} catch (err) {
|
|
168
|
-
console.warn(`[slack] Failed to save active threads: ${err instanceof Error ? err.message : String(err)}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private setupEventHandlers(): void {
|
|
173
|
-
// Handle @mentions in channels
|
|
174
|
-
this.socketClient.on("app_mention", async ({ event, ack }) => {
|
|
175
|
-
try {
|
|
176
|
-
await ack();
|
|
177
|
-
|
|
178
|
-
const e = event as {
|
|
179
|
-
text: string;
|
|
180
|
-
channel: string;
|
|
181
|
-
user: string;
|
|
182
|
-
ts: string;
|
|
183
|
-
thread_ts?: string;
|
|
184
|
-
files?: Array<{
|
|
185
|
-
id: string;
|
|
186
|
-
name?: string;
|
|
187
|
-
mimetype?: string;
|
|
188
|
-
url_private_download?: string;
|
|
189
|
-
}>;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Check channel allowlist
|
|
193
|
-
if (!this.isChannelAllowed(e.channel)) {
|
|
194
|
-
console.log(`[slack] Ignoring mention in disallowed channel: ${e.channel}`);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (!this.allowedUsers.has(e.user)) {
|
|
199
|
-
console.log(`[slack] Ignoring mention from unauthorized user: ${e.user}`);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Track this thread as active for future conversation
|
|
204
|
-
const threadId = e.thread_ts || e.ts;
|
|
205
|
-
this.activeThreads.set(threadId, Date.now());
|
|
206
|
-
this.saveActiveThreads();
|
|
207
|
-
console.log(`[slack] Thread ${threadId} is now active (${this.activeThreads.size} total)`);
|
|
208
|
-
|
|
209
|
-
// Strip bot mention from text
|
|
210
|
-
const text = e.text.replace(/<@[A-Z0-9]+>/gi, "").trim();
|
|
211
|
-
|
|
212
|
-
const message: InboundMessage = {
|
|
213
|
-
id: e.ts,
|
|
214
|
-
channel: this.name,
|
|
215
|
-
senderId: e.user,
|
|
216
|
-
chatId: e.channel,
|
|
217
|
-
threadId: e.thread_ts,
|
|
218
|
-
text,
|
|
219
|
-
timestamp: parseFloat(e.ts) * 1000,
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
// Download attachments
|
|
223
|
-
if (e.files && e.files.length > 0) {
|
|
224
|
-
message.attachments = await this.downloadFiles(e.files);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
await this.handler?.(message);
|
|
228
|
-
} catch (err) {
|
|
229
|
-
console.error("[slack] Error in app_mention handler:", err);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
// Handle direct messages and thread replies
|
|
234
|
-
this.socketClient.on("message", async ({ event, ack }) => {
|
|
235
|
-
try {
|
|
236
|
-
await ack();
|
|
237
|
-
|
|
238
|
-
const e = event as {
|
|
239
|
-
text?: string;
|
|
240
|
-
channel: string;
|
|
241
|
-
user?: string;
|
|
242
|
-
ts: string;
|
|
243
|
-
channel_type?: string;
|
|
244
|
-
subtype?: string;
|
|
245
|
-
bot_id?: string;
|
|
246
|
-
thread_ts?: string;
|
|
247
|
-
files?: Array<{
|
|
248
|
-
id: string;
|
|
249
|
-
name?: string;
|
|
250
|
-
mimetype?: string;
|
|
251
|
-
url_private_download?: string;
|
|
252
|
-
}>;
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
// Skip bot messages, message edits, etc.
|
|
256
|
-
if (e.bot_id || !e.user || e.user === this.botUserId) return;
|
|
257
|
-
if (e.subtype !== undefined && e.subtype !== "file_share") return;
|
|
258
|
-
if (!e.text && (!e.files || e.files.length === 0)) return;
|
|
259
|
-
|
|
260
|
-
// Check channel allowlist
|
|
261
|
-
if (!this.isChannelAllowed(e.channel)) return;
|
|
262
|
-
|
|
263
|
-
const isDM = e.channel_type === "im";
|
|
264
|
-
const isMpim = e.channel_type === "mpim"; // Multi-party DM
|
|
265
|
-
const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
|
|
266
|
-
const isInActiveThread = e.thread_ts && this.activeThreads.has(e.thread_ts);
|
|
267
|
-
|
|
268
|
-
// Skip channel/group @mentions (handled by app_mention event)
|
|
269
|
-
// Note: mpim (multi-party DMs) don't fire app_mention, so we must NOT skip those
|
|
270
|
-
if (!isDM && !isMpim && isBotMention) return;
|
|
271
|
-
|
|
272
|
-
// Only process: DMs, multi-party DMs, OR messages in active threads
|
|
273
|
-
if (!isDM && !isMpim && !isInActiveThread) return;
|
|
274
|
-
|
|
275
|
-
if (!this.allowedUsers.has(e.user)) {
|
|
276
|
-
console.log(`[slack] Ignoring message from unauthorized user: ${e.user}`);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const message: InboundMessage = {
|
|
281
|
-
id: e.ts,
|
|
282
|
-
channel: this.name,
|
|
283
|
-
senderId: e.user,
|
|
284
|
-
chatId: e.channel,
|
|
285
|
-
threadId: e.thread_ts,
|
|
286
|
-
text: e.text || "",
|
|
287
|
-
timestamp: parseFloat(e.ts) * 1000,
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
// Download attachments
|
|
291
|
-
if (e.files && e.files.length > 0) {
|
|
292
|
-
message.attachments = await this.downloadFiles(e.files);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
await this.handler?.(message);
|
|
296
|
-
} catch (err) {
|
|
297
|
-
console.error("[slack] Error in message handler:", err);
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Download files from Slack and convert to base64 attachments.
|
|
304
|
-
*/
|
|
305
|
-
private async downloadFiles(
|
|
306
|
-
files: Array<{
|
|
307
|
-
id: string;
|
|
308
|
-
name?: string;
|
|
309
|
-
mimetype?: string;
|
|
310
|
-
url_private_download?: string;
|
|
311
|
-
}>,
|
|
312
|
-
): Promise<Attachment[]> {
|
|
313
|
-
const attachments: Attachment[] = [];
|
|
314
|
-
|
|
315
|
-
for (const file of files) {
|
|
316
|
-
const url = file.url_private_download;
|
|
317
|
-
if (!url) {
|
|
318
|
-
console.warn(`[slack] File ${file.id} has no download URL, skipping`);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
try {
|
|
323
|
-
const response = await fetch(url, {
|
|
324
|
-
headers: {
|
|
325
|
-
Authorization: `Bearer ${this.options.botToken}`,
|
|
326
|
-
},
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
if (!response.ok) {
|
|
330
|
-
console.warn(`[slack] Failed to download file ${file.id}: ${response.status} ${response.statusText}`);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
335
|
-
attachments.push({
|
|
336
|
-
data: buffer.toString("base64"),
|
|
337
|
-
mimeType: file.mimetype || "application/octet-stream",
|
|
338
|
-
fileName: file.name || file.id,
|
|
339
|
-
});
|
|
340
|
-
} catch (err) {
|
|
341
|
-
console.error(`[slack] Error downloading file ${file.id}:`, err);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return attachments;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Split a long message into chunks, preferring newline boundaries.
|
|
350
|
-
*/
|
|
351
|
-
private splitMessage(text: string, maxLen: number): string[] {
|
|
352
|
-
const chunks: string[] = [];
|
|
353
|
-
let remaining = text;
|
|
354
|
-
|
|
355
|
-
while (remaining.length > 0) {
|
|
356
|
-
if (remaining.length <= maxLen) {
|
|
357
|
-
chunks.push(remaining);
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Find last newline within the limit
|
|
362
|
-
let splitAt = remaining.lastIndexOf("\n", maxLen);
|
|
363
|
-
if (splitAt <= 0) {
|
|
364
|
-
// No good newline — hard-split
|
|
365
|
-
splitAt = maxLen;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
369
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, ""); // trim leading newline
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return chunks;
|
|
373
|
-
}
|
|
374
|
-
}
|