clankie 0.2.2 → 0.2.4
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 +17 -16
- package/dist/cli.js +301862 -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 +8 -7
- package/web-ui-dist/_shell.html +2 -2
- package/web-ui-dist/assets/{card-BUP-xovx.js → card-Ce8RCN8-.js} +1 -1
- package/web-ui-dist/assets/{extensions-DC620Nmx.js → extensions-D-3Wl_TA.js} +1 -1
- package/web-ui-dist/assets/{index-DurjG9O_.js → index-ClDMn-6f.js} +1 -1
- package/web-ui-dist/assets/{loader-circle-DbOtKfCA.js → loader-circle-CpT1_nns.js} +1 -1
- package/web-ui-dist/assets/{main-B2sRcuyZ.js → main-J9rrgTOF.js} +4 -4
- package/web-ui-dist/assets/{sessions._sessionId-BJazw9EJ.js → sessions._sessionId-D6gfJDaW.js} +2 -2
- package/web-ui-dist/assets/{settings-Bv8oeIho.js → settings-ZDTymG3K.js} +1 -1
- package/web-ui-dist/manifest.json +23 -23
- package/src/agent.ts +0 -118
- package/src/channels/channel.ts +0 -57
- package/src/channels/slack.ts +0 -376
- package/src/channels/web.ts +0 -1375
- package/src/cli.ts +0 -505
- package/src/config.ts +0 -261
- package/src/daemon.ts +0 -380
- package/src/extensions/workspace-jail.ts +0 -171
- package/src/service.ts +0 -374
- package/src/sessions.ts +0 -262
package/src/agent.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* clankie agent session wrapper
|
|
3
|
-
*
|
|
4
|
-
* Creates an AgentSession using pi's SDK with full DefaultResourceLoader
|
|
5
|
-
* discovery — skills, extensions, prompt templates, context files all
|
|
6
|
-
* load from the standard pi directories (~/.pi/agent/, .pi/, etc.).
|
|
7
|
-
*
|
|
8
|
-
* Model is resolved from ~/.clankie/clankie.json → agent.model.primary (provider/model format).
|
|
9
|
-
* If not set, falls back to pi's default resolution (settings → first available).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
AuthStorage,
|
|
14
|
-
type CreateAgentSessionResult,
|
|
15
|
-
createAgentSession,
|
|
16
|
-
DefaultResourceLoader,
|
|
17
|
-
type ExtensionFactory,
|
|
18
|
-
ModelRegistry,
|
|
19
|
-
SessionManager,
|
|
20
|
-
} from "@mariozechner/pi-coding-agent";
|
|
21
|
-
import { getAgentDir, getAuthPath, getWorkspace, loadConfig } from "./config.ts";
|
|
22
|
-
import { createWorkspaceJailExtension } from "./extensions/workspace-jail.ts";
|
|
23
|
-
|
|
24
|
-
export interface SessionOptions {
|
|
25
|
-
/**
|
|
26
|
-
* Working directory for the agent.
|
|
27
|
-
* Defaults to config.workspace, then process.cwd().
|
|
28
|
-
*/
|
|
29
|
-
cwd?: string;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* If true, session is NOT persisted to disk (ephemeral in-memory session).
|
|
33
|
-
* Default: false — creates a new persistent session under ~/.pi/agent/sessions/.
|
|
34
|
-
*/
|
|
35
|
-
ephemeral?: boolean;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* If true, continue the most recent session instead of starting a new one.
|
|
39
|
-
*/
|
|
40
|
-
continueRecent?: boolean;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Path to a specific session file to open.
|
|
44
|
-
*/
|
|
45
|
-
sessionFile?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Create a pi agent session with the app's configuration.
|
|
50
|
-
*
|
|
51
|
-
* Uses pi's DefaultResourceLoader so the entire pi extension ecosystem
|
|
52
|
-
* (~/.pi/agent/extensions/, ~/.agents/skills/, AGENTS.md, etc.) is
|
|
53
|
-
* automatically available.
|
|
54
|
-
*/
|
|
55
|
-
export async function createSession(options: SessionOptions = {}): Promise<CreateAgentSessionResult> {
|
|
56
|
-
const config = loadConfig();
|
|
57
|
-
const agentDir = getAgentDir(config);
|
|
58
|
-
const cwd = options.cwd ?? getWorkspace(config);
|
|
59
|
-
|
|
60
|
-
// Auth stored in ~/.clankie/auth.json (separate from pi's ~/.pi/agent/auth.json)
|
|
61
|
-
const authStorage = AuthStorage.create(getAuthPath());
|
|
62
|
-
const modelRegistry = new ModelRegistry(authStorage);
|
|
63
|
-
|
|
64
|
-
// Build extension factories (workspace jail if enabled)
|
|
65
|
-
const extensionFactories: ExtensionFactory[] = [];
|
|
66
|
-
const restrictToWorkspace = config.agent?.restrictToWorkspace ?? true; // default: enabled
|
|
67
|
-
if (restrictToWorkspace) {
|
|
68
|
-
const allowedPaths = config.agent?.allowedPaths ?? [];
|
|
69
|
-
extensionFactories.push(createWorkspaceJailExtension(cwd, allowedPaths));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// DefaultResourceLoader with standard pi discovery
|
|
73
|
-
const loader = new DefaultResourceLoader({
|
|
74
|
-
cwd,
|
|
75
|
-
agentDir,
|
|
76
|
-
extensionFactories,
|
|
77
|
-
});
|
|
78
|
-
await loader.reload();
|
|
79
|
-
|
|
80
|
-
// Session management
|
|
81
|
-
let sessionManager: SessionManager;
|
|
82
|
-
if (options.ephemeral) {
|
|
83
|
-
sessionManager = SessionManager.inMemory();
|
|
84
|
-
} else if (options.sessionFile) {
|
|
85
|
-
sessionManager = SessionManager.open(options.sessionFile);
|
|
86
|
-
} else if (options.continueRecent) {
|
|
87
|
-
sessionManager = SessionManager.continueRecent(cwd);
|
|
88
|
-
} else {
|
|
89
|
-
sessionManager = SessionManager.create(cwd);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Resolve model from config → pi auto-detection
|
|
93
|
-
const modelSpec = config.agent?.model?.primary;
|
|
94
|
-
let model: ReturnType<typeof modelRegistry.find> | undefined;
|
|
95
|
-
if (modelSpec) {
|
|
96
|
-
const slash = modelSpec.indexOf("/");
|
|
97
|
-
if (slash !== -1) {
|
|
98
|
-
const provider = modelSpec.substring(0, slash);
|
|
99
|
-
const modelId = modelSpec.substring(slash + 1);
|
|
100
|
-
model = modelRegistry.find(provider, modelId);
|
|
101
|
-
if (!model) {
|
|
102
|
-
console.warn(`Warning: model "${modelSpec}" from config not found in registry, falling back to auto-detection`);
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
console.warn(`Warning: model should be "provider/model" format (got "${modelSpec}")`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return createAgentSession({
|
|
110
|
-
cwd,
|
|
111
|
-
agentDir,
|
|
112
|
-
authStorage,
|
|
113
|
-
modelRegistry,
|
|
114
|
-
resourceLoader: loader,
|
|
115
|
-
sessionManager,
|
|
116
|
-
model,
|
|
117
|
-
});
|
|
118
|
-
}
|
package/src/channels/channel.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Channel abstraction — a messaging surface that can receive and send messages.
|
|
3
|
-
*
|
|
4
|
-
* Each channel (Slack, etc.) implements this interface.
|
|
5
|
-
* The daemon routes inbound messages to the agent and delivers responses back.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface Attachment {
|
|
9
|
-
/** Base64-encoded file content */
|
|
10
|
-
data: string;
|
|
11
|
-
/** MIME type (e.g. "image/jpeg", "application/pdf") */
|
|
12
|
-
mimeType: string;
|
|
13
|
-
/** Original file name, if available */
|
|
14
|
-
fileName?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface InboundMessage {
|
|
18
|
-
/** Unique ID for this message (channel-specific) */
|
|
19
|
-
id: string;
|
|
20
|
-
/** Channel type (e.g. "slack") */
|
|
21
|
-
channel: string;
|
|
22
|
-
/** Sender identifier (channel-specific user ID) */
|
|
23
|
-
senderId: string;
|
|
24
|
-
/** Sender display name (if available) */
|
|
25
|
-
senderName?: string;
|
|
26
|
-
/** Chat/conversation identifier (for per-chat sessions) */
|
|
27
|
-
chatId: string;
|
|
28
|
-
/** Thread/topic ID (e.g., Slack thread) */
|
|
29
|
-
threadId?: string;
|
|
30
|
-
/** Message text */
|
|
31
|
-
text: string;
|
|
32
|
-
/** File attachments (images, documents, audio, etc.) */
|
|
33
|
-
attachments?: Attachment[];
|
|
34
|
-
/** Unix timestamp (ms) */
|
|
35
|
-
timestamp: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type MessageHandler = (message: InboundMessage) => Promise<void>;
|
|
39
|
-
|
|
40
|
-
export interface SendOptions {
|
|
41
|
-
/** Thread/topic ID for sending to a specific thread */
|
|
42
|
-
threadId?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface Channel {
|
|
46
|
-
/** Channel type identifier */
|
|
47
|
-
readonly name: string;
|
|
48
|
-
|
|
49
|
-
/** Start receiving messages. Calls handler for each inbound message. */
|
|
50
|
-
start(handler: MessageHandler): Promise<void>;
|
|
51
|
-
|
|
52
|
-
/** Send a text message to a chat */
|
|
53
|
-
send(chatId: string, text: string, options?: SendOptions): Promise<void>;
|
|
54
|
-
|
|
55
|
-
/** Gracefully stop the channel */
|
|
56
|
-
stop(): Promise<void>;
|
|
57
|
-
}
|
package/src/channels/slack.ts
DELETED
|
@@ -1,376 +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 options: SlackChannelOptions;
|
|
50
|
-
private socketClient: SocketModeClient;
|
|
51
|
-
private webClient: WebClient;
|
|
52
|
-
private allowedUsers: Set<string>;
|
|
53
|
-
private allowedChannelIds: Set<string> | null;
|
|
54
|
-
private handler: MessageHandler | undefined;
|
|
55
|
-
private botUserId: string | null = null;
|
|
56
|
-
/** Threads where bot has been @mentioned - Map<threadId, timestamp> for TTL cleanup */
|
|
57
|
-
private activeThreads: Map<string, number> = new Map();
|
|
58
|
-
|
|
59
|
-
constructor(options: SlackChannelOptions) {
|
|
60
|
-
this.options = options;
|
|
61
|
-
this.socketClient = new SocketModeClient({
|
|
62
|
-
appToken: options.appToken,
|
|
63
|
-
// biome-ignore lint/suspicious/noExplicitAny: Slack SDK logLevel type is not exported
|
|
64
|
-
logLevel: "ERROR" as any, // Suppress noisy internal logging
|
|
65
|
-
});
|
|
66
|
-
this.webClient = new WebClient(options.botToken);
|
|
67
|
-
this.allowedUsers = new Set(options.allowedUsers);
|
|
68
|
-
// null = allow all channels; Set = filter by channel ID
|
|
69
|
-
this.allowedChannelIds = options.allowedChannelIds?.length ? new Set(options.allowedChannelIds) : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async start(handler: MessageHandler): Promise<void> {
|
|
73
|
-
this.handler = handler;
|
|
74
|
-
|
|
75
|
-
// Load persisted active threads
|
|
76
|
-
this.loadActiveThreads();
|
|
77
|
-
|
|
78
|
-
// Get bot user ID
|
|
79
|
-
const auth = await this.webClient.auth.test();
|
|
80
|
-
this.botUserId = auth.user_id as string;
|
|
81
|
-
|
|
82
|
-
this.setupEventHandlers();
|
|
83
|
-
await this.socketClient.start();
|
|
84
|
-
|
|
85
|
-
console.log(`[slack] Connected as ${auth.user} (${this.botUserId})`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async send(chatId: string, text: string, options?: SendOptions): Promise<void> {
|
|
89
|
-
if (text.length <= SLACK_MAX_LENGTH) {
|
|
90
|
-
await this.webClient.chat.postMessage({
|
|
91
|
-
channel: chatId,
|
|
92
|
-
text,
|
|
93
|
-
thread_ts: options?.threadId,
|
|
94
|
-
unfurl_links: false,
|
|
95
|
-
unfurl_media: false,
|
|
96
|
-
});
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Split long messages
|
|
101
|
-
const chunks = this.splitMessage(text, SLACK_MAX_LENGTH);
|
|
102
|
-
for (const chunk of chunks) {
|
|
103
|
-
await this.webClient.chat.postMessage({
|
|
104
|
-
channel: chatId,
|
|
105
|
-
text: chunk,
|
|
106
|
-
thread_ts: options?.threadId,
|
|
107
|
-
unfurl_links: false,
|
|
108
|
-
unfurl_media: false,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async stop(): Promise<void> {
|
|
114
|
-
await this.socketClient.disconnect();
|
|
115
|
-
console.log("[slack] Disconnected");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ─── Private helpers ──────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
/** Check if a channel is allowed (null allowedChannelIds = allow all) */
|
|
121
|
-
private isChannelAllowed(channelId: string): boolean {
|
|
122
|
-
return this.allowedChannelIds === null || this.allowedChannelIds.has(channelId);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Load active threads from disk (with TTL cleanup) */
|
|
126
|
-
private loadActiveThreads(): void {
|
|
127
|
-
if (!existsSync(ACTIVE_THREADS_FILE)) return;
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const raw = readFileSync(ACTIVE_THREADS_FILE, "utf-8");
|
|
131
|
-
const data = JSON.parse(raw) as Record<string, number>;
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
const ttlMs = THREAD_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
134
|
-
|
|
135
|
-
let loaded = 0;
|
|
136
|
-
let expired = 0;
|
|
137
|
-
|
|
138
|
-
for (const [threadId, timestamp] of Object.entries(data)) {
|
|
139
|
-
if (now - timestamp > ttlMs) {
|
|
140
|
-
expired++;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
this.activeThreads.set(threadId, timestamp);
|
|
144
|
-
loaded++;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (loaded > 0) {
|
|
148
|
-
console.log(`[slack] Loaded ${loaded} active thread(s) from disk${expired > 0 ? ` (${expired} expired)` : ""}`);
|
|
149
|
-
}
|
|
150
|
-
} catch (err) {
|
|
151
|
-
console.warn(`[slack] Failed to load active threads: ${err instanceof Error ? err.message : String(err)}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Save active threads to disk */
|
|
156
|
-
private saveActiveThreads(): void {
|
|
157
|
-
try {
|
|
158
|
-
const dir = join(homedir(), ".clankie");
|
|
159
|
-
if (!existsSync(dir)) {
|
|
160
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const data: Record<string, number> = {};
|
|
164
|
-
for (const [threadId, timestamp] of this.activeThreads.entries()) {
|
|
165
|
-
data[threadId] = timestamp;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
writeFileSync(ACTIVE_THREADS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
169
|
-
} catch (err) {
|
|
170
|
-
console.warn(`[slack] Failed to save active threads: ${err instanceof Error ? err.message : String(err)}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private setupEventHandlers(): void {
|
|
175
|
-
// Handle @mentions in channels
|
|
176
|
-
this.socketClient.on("app_mention", async ({ event, ack }) => {
|
|
177
|
-
try {
|
|
178
|
-
await ack();
|
|
179
|
-
|
|
180
|
-
const e = event as {
|
|
181
|
-
text: string;
|
|
182
|
-
channel: string;
|
|
183
|
-
user: string;
|
|
184
|
-
ts: string;
|
|
185
|
-
thread_ts?: string;
|
|
186
|
-
files?: Array<{
|
|
187
|
-
id: string;
|
|
188
|
-
name?: string;
|
|
189
|
-
mimetype?: string;
|
|
190
|
-
url_private_download?: string;
|
|
191
|
-
}>;
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
// Check channel allowlist
|
|
195
|
-
if (!this.isChannelAllowed(e.channel)) {
|
|
196
|
-
console.log(`[slack] Ignoring mention in disallowed channel: ${e.channel}`);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!this.allowedUsers.has(e.user)) {
|
|
201
|
-
console.log(`[slack] Ignoring mention from unauthorized user: ${e.user}`);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Track this thread as active for future conversation
|
|
206
|
-
const threadId = e.thread_ts || e.ts;
|
|
207
|
-
this.activeThreads.set(threadId, Date.now());
|
|
208
|
-
this.saveActiveThreads();
|
|
209
|
-
console.log(`[slack] Thread ${threadId} is now active (${this.activeThreads.size} total)`);
|
|
210
|
-
|
|
211
|
-
// Strip bot mention from text
|
|
212
|
-
const text = e.text.replace(/<@[A-Z0-9]+>/gi, "").trim();
|
|
213
|
-
|
|
214
|
-
const message: InboundMessage = {
|
|
215
|
-
id: e.ts,
|
|
216
|
-
channel: this.name,
|
|
217
|
-
senderId: e.user,
|
|
218
|
-
chatId: e.channel,
|
|
219
|
-
threadId: e.thread_ts,
|
|
220
|
-
text,
|
|
221
|
-
timestamp: parseFloat(e.ts) * 1000,
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
// Download attachments
|
|
225
|
-
if (e.files && e.files.length > 0) {
|
|
226
|
-
message.attachments = await this.downloadFiles(e.files);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
await this.handler?.(message);
|
|
230
|
-
} catch (err) {
|
|
231
|
-
console.error("[slack] Error in app_mention handler:", err);
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// Handle direct messages and thread replies
|
|
236
|
-
this.socketClient.on("message", async ({ event, ack }) => {
|
|
237
|
-
try {
|
|
238
|
-
await ack();
|
|
239
|
-
|
|
240
|
-
const e = event as {
|
|
241
|
-
text?: string;
|
|
242
|
-
channel: string;
|
|
243
|
-
user?: string;
|
|
244
|
-
ts: string;
|
|
245
|
-
channel_type?: string;
|
|
246
|
-
subtype?: string;
|
|
247
|
-
bot_id?: string;
|
|
248
|
-
thread_ts?: string;
|
|
249
|
-
files?: Array<{
|
|
250
|
-
id: string;
|
|
251
|
-
name?: string;
|
|
252
|
-
mimetype?: string;
|
|
253
|
-
url_private_download?: string;
|
|
254
|
-
}>;
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
// Skip bot messages, message edits, etc.
|
|
258
|
-
if (e.bot_id || !e.user || e.user === this.botUserId) return;
|
|
259
|
-
if (e.subtype !== undefined && e.subtype !== "file_share") return;
|
|
260
|
-
if (!e.text && (!e.files || e.files.length === 0)) return;
|
|
261
|
-
|
|
262
|
-
// Check channel allowlist
|
|
263
|
-
if (!this.isChannelAllowed(e.channel)) return;
|
|
264
|
-
|
|
265
|
-
const isDM = e.channel_type === "im";
|
|
266
|
-
const isMpim = e.channel_type === "mpim"; // Multi-party DM
|
|
267
|
-
const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
|
|
268
|
-
const isInActiveThread = e.thread_ts && this.activeThreads.has(e.thread_ts);
|
|
269
|
-
|
|
270
|
-
// Skip channel/group @mentions (handled by app_mention event)
|
|
271
|
-
// Note: mpim (multi-party DMs) don't fire app_mention, so we must NOT skip those
|
|
272
|
-
if (!isDM && !isMpim && isBotMention) return;
|
|
273
|
-
|
|
274
|
-
// Only process: DMs, multi-party DMs, OR messages in active threads
|
|
275
|
-
if (!isDM && !isMpim && !isInActiveThread) return;
|
|
276
|
-
|
|
277
|
-
if (!this.allowedUsers.has(e.user)) {
|
|
278
|
-
console.log(`[slack] Ignoring message from unauthorized user: ${e.user}`);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const message: InboundMessage = {
|
|
283
|
-
id: e.ts,
|
|
284
|
-
channel: this.name,
|
|
285
|
-
senderId: e.user,
|
|
286
|
-
chatId: e.channel,
|
|
287
|
-
threadId: e.thread_ts,
|
|
288
|
-
text: e.text || "",
|
|
289
|
-
timestamp: parseFloat(e.ts) * 1000,
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Download attachments
|
|
293
|
-
if (e.files && e.files.length > 0) {
|
|
294
|
-
message.attachments = await this.downloadFiles(e.files);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await this.handler?.(message);
|
|
298
|
-
} catch (err) {
|
|
299
|
-
console.error("[slack] Error in message handler:", err);
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Download files from Slack and convert to base64 attachments.
|
|
306
|
-
*/
|
|
307
|
-
private async downloadFiles(
|
|
308
|
-
files: Array<{
|
|
309
|
-
id: string;
|
|
310
|
-
name?: string;
|
|
311
|
-
mimetype?: string;
|
|
312
|
-
url_private_download?: string;
|
|
313
|
-
}>,
|
|
314
|
-
): Promise<Attachment[]> {
|
|
315
|
-
const attachments: Attachment[] = [];
|
|
316
|
-
|
|
317
|
-
for (const file of files) {
|
|
318
|
-
const url = file.url_private_download;
|
|
319
|
-
if (!url) {
|
|
320
|
-
console.warn(`[slack] File ${file.id} has no download URL, skipping`);
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
const response = await fetch(url, {
|
|
326
|
-
headers: {
|
|
327
|
-
Authorization: `Bearer ${this.options.botToken}`,
|
|
328
|
-
},
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
if (!response.ok) {
|
|
332
|
-
console.warn(`[slack] Failed to download file ${file.id}: ${response.status} ${response.statusText}`);
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
337
|
-
attachments.push({
|
|
338
|
-
data: buffer.toString("base64"),
|
|
339
|
-
mimeType: file.mimetype || "application/octet-stream",
|
|
340
|
-
fileName: file.name || file.id,
|
|
341
|
-
});
|
|
342
|
-
} catch (err) {
|
|
343
|
-
console.error(`[slack] Error downloading file ${file.id}:`, err);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return attachments;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Split a long message into chunks, preferring newline boundaries.
|
|
352
|
-
*/
|
|
353
|
-
private splitMessage(text: string, maxLen: number): string[] {
|
|
354
|
-
const chunks: string[] = [];
|
|
355
|
-
let remaining = text;
|
|
356
|
-
|
|
357
|
-
while (remaining.length > 0) {
|
|
358
|
-
if (remaining.length <= maxLen) {
|
|
359
|
-
chunks.push(remaining);
|
|
360
|
-
break;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Find last newline within the limit
|
|
364
|
-
let splitAt = remaining.lastIndexOf("\n", maxLen);
|
|
365
|
-
if (splitAt <= 0) {
|
|
366
|
-
// No good newline — hard-split
|
|
367
|
-
splitAt = maxLen;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
371
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, ""); // trim leading newline
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return chunks;
|
|
375
|
-
}
|
|
376
|
-
}
|