clearclaw 0.4.0 โ 0.6.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/dist/channel/slack.d.ts +9 -3
- package/dist/channel/slack.js +101 -11
- package/dist/channel/telegram.d.ts +11 -4
- package/dist/channel/telegram.js +114 -58
- package/dist/config.d.ts +56 -9
- package/dist/config.js +215 -44
- package/dist/engine/claude-code.js +5 -3
- package/dist/files.js +2 -11
- package/dist/format.d.ts +26 -0
- package/dist/format.js +51 -0
- package/dist/index.js +141 -39
- package/dist/logger.js +1 -1
- package/dist/orchestrator.d.ts +15 -11
- package/dist/orchestrator.js +401 -129
- package/dist/tool-handlers.d.ts +19 -0
- package/dist/tool-handlers.js +73 -0
- package/dist/types.d.ts +23 -2
- package/package.json +9 -6
- package/dist/db.d.ts +0 -6
- package/dist/db.js +0 -42
- package/dist/workspace-store.d.ts +0 -11
- package/dist/workspace-store.js +0 -45
package/dist/channel/slack.d.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.js";
|
|
2
|
+
import type { Channel, Button, ButtonResponse, SendFileOpts, SendMessageOpts, UserInfo } from "../types.js";
|
|
3
3
|
export declare class SlackChannel extends EventEmitter implements Channel {
|
|
4
4
|
name: string;
|
|
5
|
+
private static readonly EMOJI_TO_SLACK;
|
|
5
6
|
private app;
|
|
6
7
|
private botToken;
|
|
7
8
|
private botUserId;
|
|
8
|
-
private
|
|
9
|
+
private isAuthorized;
|
|
10
|
+
private onUnauthorizedDM?;
|
|
9
11
|
private pendingButtonCallbacks;
|
|
10
12
|
private pendingModalCallbacks;
|
|
11
13
|
private typingMessageTs;
|
|
12
14
|
private userNameCache;
|
|
13
|
-
constructor(botToken: string, appToken: string,
|
|
15
|
+
constructor(botToken: string, appToken: string, isAuthorized: (userId: string) => boolean, onUnauthorizedDM?: (chatId: string, user: UserInfo) => void);
|
|
14
16
|
connect(): Promise<void>;
|
|
15
17
|
disconnect(): Promise<void>;
|
|
16
18
|
ownsId(chatId: string): boolean;
|
|
@@ -22,8 +24,12 @@ export declare class SlackChannel extends EventEmitter implements Channel {
|
|
|
22
24
|
unpinAllMessages(chatId: string): Promise<void>;
|
|
23
25
|
updateStatus(chatId: string, text: string): Promise<void>;
|
|
24
26
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
27
|
+
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
28
|
+
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
25
29
|
private slackId;
|
|
26
30
|
/** Download a file from Slack using bot token auth. */
|
|
27
31
|
private downloadFile;
|
|
28
32
|
private resolveUserName;
|
|
33
|
+
/** Fetch the parent message of a thread reply for reply context. */
|
|
34
|
+
private fetchReplyContext;
|
|
29
35
|
}
|
package/dist/channel/slack.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { App, LogLevel } from "@slack/bolt";
|
|
3
|
+
import { slackifyMarkdown } from "slackify-markdown";
|
|
3
4
|
import log from "../logger.js";
|
|
4
5
|
export class SlackChannel extends EventEmitter {
|
|
5
6
|
name = "slack";
|
|
7
|
+
static EMOJI_TO_SLACK = {
|
|
8
|
+
"๐": "+1", "๐": "-1", "โค๏ธ": "heart", "๐ฅ": "fire",
|
|
9
|
+
"๐": "joy", "๐": "tada", "โ
": "white_check_mark", "๐": "rocket",
|
|
10
|
+
"๐": "eyes", "๐ฏ": "100", "๐ค": "thinking_face", "๐ฎ": "open_mouth",
|
|
11
|
+
"๐": "pray", "๐ช": "muscle", "โก": "zap", "๐": "star2",
|
|
12
|
+
};
|
|
6
13
|
app;
|
|
7
14
|
botToken;
|
|
8
15
|
botUserId;
|
|
9
|
-
|
|
16
|
+
isAuthorized;
|
|
17
|
+
onUnauthorizedDM;
|
|
10
18
|
pendingButtonCallbacks = new Map(); // actionId โ resolve
|
|
11
19
|
pendingModalCallbacks = new Map(); // modal callbackId โ resolve
|
|
12
20
|
typingMessageTs = new Map(); // chatId โ ts of bot's "typingโฆ" placeholder
|
|
13
21
|
userNameCache = new Map();
|
|
14
|
-
constructor(botToken, appToken,
|
|
22
|
+
constructor(botToken, appToken, isAuthorized, onUnauthorizedDM) {
|
|
15
23
|
super();
|
|
16
24
|
this.botToken = botToken;
|
|
17
|
-
this.
|
|
25
|
+
this.isAuthorized = isAuthorized;
|
|
26
|
+
this.onUnauthorizedDM = onUnauthorizedDM;
|
|
18
27
|
this.app = new App({
|
|
19
28
|
token: botToken, appToken, socketMode: true, logLevel: LogLevel.ERROR,
|
|
20
29
|
});
|
|
@@ -44,9 +53,18 @@ export class SlackChannel extends EventEmitter {
|
|
|
44
53
|
if (!msg.text && (!msg.files || msg.files.length === 0))
|
|
45
54
|
return;
|
|
46
55
|
const userId = msg.user;
|
|
47
|
-
if (!userId
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
if (!userId)
|
|
57
|
+
return;
|
|
58
|
+
const prefixedId = `slack:${userId}`;
|
|
59
|
+
if (!this.isAuthorized(prefixedId)) {
|
|
60
|
+
// Slack DM channel IDs start with "D"
|
|
61
|
+
if (msg.channel.startsWith("D") && this.onUnauthorizedDM) {
|
|
62
|
+
const userName = await this.resolveUserName(userId);
|
|
63
|
+
this.onUnauthorizedDM(chatId, { id: prefixedId, name: userName ?? userId });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
log.info("[channel] ignored message from unauthorized user %s", prefixedId);
|
|
67
|
+
}
|
|
50
68
|
return;
|
|
51
69
|
}
|
|
52
70
|
// Download attached files in parallel
|
|
@@ -70,11 +88,18 @@ export class SlackChannel extends EventEmitter {
|
|
|
70
88
|
attachments = succeeded;
|
|
71
89
|
}
|
|
72
90
|
}
|
|
91
|
+
const replyTo = msg.thread_ts
|
|
92
|
+
? await this.fetchReplyContext(msg.channel, msg.thread_ts, msg.ts)
|
|
93
|
+
: undefined;
|
|
94
|
+
const chatType = msg.channel.startsWith("D") ? "dm" : "group";
|
|
73
95
|
const userName = await this.resolveUserName(userId);
|
|
74
96
|
this.emit("message", {
|
|
75
97
|
chatId,
|
|
76
|
-
|
|
98
|
+
chatType,
|
|
99
|
+
user: { id: prefixedId, name: userName ?? userId },
|
|
77
100
|
text: msg.text ?? "",
|
|
101
|
+
messageId: msg.ts,
|
|
102
|
+
replyTo,
|
|
78
103
|
...(attachments && { attachments }),
|
|
79
104
|
});
|
|
80
105
|
});
|
|
@@ -95,11 +120,13 @@ export class SlackChannel extends EventEmitter {
|
|
|
95
120
|
return;
|
|
96
121
|
const chatId = `slack:${command.channel_id}`;
|
|
97
122
|
const userId = command.user_id;
|
|
98
|
-
if (!this.
|
|
123
|
+
if (!this.isAuthorized(`slack:${userId}`))
|
|
99
124
|
return;
|
|
125
|
+
const chatType = command.channel_id.startsWith("D") ? "dm" : "group";
|
|
100
126
|
const userName = await this.resolveUserName(userId);
|
|
101
127
|
this.emit("message", {
|
|
102
128
|
chatId,
|
|
129
|
+
chatType,
|
|
103
130
|
user: { id: `slack:${userId}`, name: userName ?? userId },
|
|
104
131
|
text: `/${subcommand}`,
|
|
105
132
|
});
|
|
@@ -140,8 +167,9 @@ export class SlackChannel extends EventEmitter {
|
|
|
140
167
|
const channel = this.slackId(chatId);
|
|
141
168
|
const chunks = splitMessage(text);
|
|
142
169
|
const handles = [];
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
const threadTs = opts?.replyToMessageId;
|
|
171
|
+
// Consume typing placeholder only when not posting as a thread reply
|
|
172
|
+
const consumeTyping = !threadTs && opts?.consumeTyping !== false;
|
|
145
173
|
const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
|
|
146
174
|
if (typingTs && chunks.length > 0) {
|
|
147
175
|
this.typingMessageTs.delete(chatId);
|
|
@@ -166,6 +194,7 @@ export class SlackChannel extends EventEmitter {
|
|
|
166
194
|
const blocks = mrkdwnBlocks(chunk);
|
|
167
195
|
const result = await this.app.client.chat.postMessage({
|
|
168
196
|
channel, text: chunk, blocks,
|
|
197
|
+
...(threadTs && { thread_ts: threadTs }),
|
|
169
198
|
});
|
|
170
199
|
if (result.ts)
|
|
171
200
|
handles.push(result.ts);
|
|
@@ -344,6 +373,32 @@ export class SlackChannel extends EventEmitter {
|
|
|
344
373
|
}
|
|
345
374
|
}
|
|
346
375
|
}
|
|
376
|
+
async sendFile(chatId, buffer, filename, opts) {
|
|
377
|
+
const channel = this.slackId(chatId);
|
|
378
|
+
await this.app.client.files.uploadV2({
|
|
379
|
+
channel_id: channel,
|
|
380
|
+
file: buffer,
|
|
381
|
+
filename,
|
|
382
|
+
initial_comment: opts?.caption,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async reactToMessage(chatId, messageId, emoji) {
|
|
386
|
+
const name = SlackChannel.EMOJI_TO_SLACK[emoji];
|
|
387
|
+
if (!name) {
|
|
388
|
+
log.warn("[channel] no Slack emoji name for %s, skipping reaction", emoji);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await this.app.client.reactions.add({
|
|
393
|
+
channel: this.slackId(chatId),
|
|
394
|
+
timestamp: messageId,
|
|
395
|
+
name,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
log.warn("[channel] failed to add reaction %s: %s", name, err instanceof Error ? err.message : String(err));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
347
402
|
slackId(chatId) {
|
|
348
403
|
return chatId.replace(/^slack:/, "");
|
|
349
404
|
}
|
|
@@ -371,9 +426,44 @@ export class SlackChannel extends EventEmitter {
|
|
|
371
426
|
return undefined;
|
|
372
427
|
}
|
|
373
428
|
}
|
|
429
|
+
/** Fetch the parent message of a thread reply for reply context. */
|
|
430
|
+
async fetchReplyContext(channel, threadTs, ownTs) {
|
|
431
|
+
if (threadTs === ownTs)
|
|
432
|
+
return undefined;
|
|
433
|
+
try {
|
|
434
|
+
const result = await this.app.client.conversations.history({
|
|
435
|
+
channel,
|
|
436
|
+
latest: threadTs,
|
|
437
|
+
limit: 1,
|
|
438
|
+
inclusive: true,
|
|
439
|
+
});
|
|
440
|
+
const parent = result.messages?.[0];
|
|
441
|
+
if (!parent)
|
|
442
|
+
return undefined;
|
|
443
|
+
const senderName = parent.user ? await this.resolveUserName(parent.user) : undefined;
|
|
444
|
+
return {
|
|
445
|
+
messageId: threadTs,
|
|
446
|
+
senderName: senderName ?? parent.user,
|
|
447
|
+
text: parent.text,
|
|
448
|
+
mediaType: parent.files?.[0]?.mimetype,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
log.warn({ err }, "[channel] failed to fetch thread parent %s", threadTs);
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
374
456
|
}
|
|
375
457
|
function mrkdwnBlocks(text) {
|
|
376
|
-
|
|
458
|
+
let rendered;
|
|
459
|
+
try {
|
|
460
|
+
rendered = slackifyMarkdown(text);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
log.warn({ err }, "[channel] slackifyMarkdown failed, sending raw text");
|
|
464
|
+
rendered = text;
|
|
465
|
+
}
|
|
466
|
+
return [{ type: "section", text: { type: "mrkdwn", text: rendered } }];
|
|
377
467
|
}
|
|
378
468
|
// Block Kit section.text has a 3000 char limit โ the tightest Slack constraint.
|
|
379
469
|
// We split at this limit so each chunk fits both the text fallback and the block.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.js";
|
|
2
|
+
import type { Channel, Button, ButtonResponse, SendFileOpts, SendMessageOpts, UserInfo } from "../types.js";
|
|
3
3
|
export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
4
4
|
name: string;
|
|
5
5
|
private bot;
|
|
6
6
|
private botToken;
|
|
7
|
-
private
|
|
7
|
+
private isAuthorized;
|
|
8
|
+
private onUnauthorizedDM?;
|
|
8
9
|
private typingIntervals;
|
|
9
10
|
private statusHandles;
|
|
10
11
|
private pendingCallbacks;
|
|
11
12
|
private pendingTextResolvers;
|
|
12
|
-
constructor(botToken: string,
|
|
13
|
+
constructor(botToken: string, isAuthorized: (userId: string) => boolean, onUnauthorizedDM?: (chatId: string, user: UserInfo) => void);
|
|
13
14
|
connect(): Promise<void>;
|
|
14
15
|
disconnect(): Promise<void>;
|
|
15
16
|
ownsId(chatId: string): boolean;
|
|
@@ -21,10 +22,16 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
|
21
22
|
unpinAllMessages(chatId: string): Promise<void>;
|
|
22
23
|
updateStatus(chatId: string, text: string): Promise<void>;
|
|
23
24
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
25
|
+
/** Build user info from a grammY context (no auth check). */
|
|
26
|
+
private buildUserInfo;
|
|
24
27
|
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
25
28
|
private extractSender;
|
|
29
|
+
/** Extract reply context from a Telegram message, if it's a reply. */
|
|
30
|
+
private extractReplyContext;
|
|
26
31
|
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
27
|
-
private
|
|
32
|
+
private handleFileMessage;
|
|
33
|
+
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
34
|
+
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
28
35
|
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
29
36
|
private downloadFile;
|
|
30
37
|
private numericId;
|
package/dist/channel/telegram.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import { Bot, InlineKeyboard } from "grammy";
|
|
2
|
+
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
3
|
+
import mime from "mime";
|
|
4
|
+
import telegramifyMarkdown from "telegramify-markdown";
|
|
3
5
|
import log from "../logger.js";
|
|
4
6
|
export class TelegramChannel extends EventEmitter {
|
|
5
7
|
name = "telegram";
|
|
6
8
|
bot;
|
|
7
9
|
botToken;
|
|
8
|
-
|
|
10
|
+
isAuthorized;
|
|
11
|
+
onUnauthorizedDM;
|
|
9
12
|
typingIntervals = new Map();
|
|
10
13
|
statusHandles = new Map(); // chatId โ message handle for pinned status
|
|
11
14
|
// Pending interactive responses: callbackId โ resolve function
|
|
12
15
|
pendingCallbacks = new Map();
|
|
13
16
|
// Pending text input: chatId โ resolve function (for requestText follow-ups)
|
|
14
17
|
pendingTextResolvers = new Map();
|
|
15
|
-
constructor(botToken,
|
|
18
|
+
constructor(botToken, isAuthorized, onUnauthorizedDM) {
|
|
16
19
|
super();
|
|
17
20
|
this.botToken = botToken;
|
|
18
|
-
this.
|
|
21
|
+
this.isAuthorized = isAuthorized;
|
|
22
|
+
this.onUnauthorizedDM = onUnauthorizedDM;
|
|
19
23
|
this.bot = new Bot(botToken);
|
|
20
24
|
}
|
|
21
25
|
async connect() {
|
|
@@ -30,7 +34,12 @@ export class TelegramChannel extends EventEmitter {
|
|
|
30
34
|
textResolver(ctx.message.text);
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
|
-
this.emit("message", {
|
|
37
|
+
this.emit("message", {
|
|
38
|
+
...sender,
|
|
39
|
+
text: ctx.message.text,
|
|
40
|
+
messageId: String(ctx.message.message_id),
|
|
41
|
+
replyTo: this.extractReplyContext(ctx.message),
|
|
42
|
+
});
|
|
34
43
|
});
|
|
35
44
|
// Photos: Telegram sends each photo as an array of PhotoSize objects
|
|
36
45
|
// (same image at different resolutions, sorted smallest โ largest).
|
|
@@ -38,12 +47,12 @@ export class TelegramChannel extends EventEmitter {
|
|
|
38
47
|
this.bot.on("message:photo", async (ctx) => {
|
|
39
48
|
const photo = ctx.message.photo;
|
|
40
49
|
const largest = photo[photo.length - 1];
|
|
41
|
-
await this.
|
|
50
|
+
await this.handleFileMessage(ctx, largest.file_id, "image/jpeg", undefined, ctx.message.caption);
|
|
42
51
|
});
|
|
43
52
|
// Documents: download with original MIME + filename
|
|
44
53
|
this.bot.on("message:document", async (ctx) => {
|
|
45
54
|
const doc = ctx.message.document;
|
|
46
|
-
await this.
|
|
55
|
+
await this.handleFileMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
|
|
47
56
|
});
|
|
48
57
|
// Placeholder handlers for unsupported media types
|
|
49
58
|
for (const [filter, label] of [
|
|
@@ -56,7 +65,12 @@ export class TelegramChannel extends EventEmitter {
|
|
|
56
65
|
const sender = this.extractSender(ctx);
|
|
57
66
|
if (!sender)
|
|
58
67
|
return;
|
|
59
|
-
this.emit("message", {
|
|
68
|
+
this.emit("message", {
|
|
69
|
+
...sender,
|
|
70
|
+
text: `[${label} โ not supported]`,
|
|
71
|
+
messageId: ctx.message ? String(ctx.message.message_id) : undefined,
|
|
72
|
+
replyTo: ctx.message ? this.extractReplyContext(ctx.message) : undefined,
|
|
73
|
+
});
|
|
60
74
|
});
|
|
61
75
|
}
|
|
62
76
|
// Handle inline keyboard button presses
|
|
@@ -96,23 +110,25 @@ export class TelegramChannel extends EventEmitter {
|
|
|
96
110
|
const numId = this.numericId(chatId);
|
|
97
111
|
const chunks = splitMessage(text);
|
|
98
112
|
const handles = [];
|
|
113
|
+
let firstChunk = true;
|
|
99
114
|
for (const chunk of chunks) {
|
|
115
|
+
const replyParams = firstChunk && opts?.replyToMessageId
|
|
116
|
+
? { reply_parameters: { message_id: Number(opts.replyToMessageId) } }
|
|
117
|
+
: {};
|
|
118
|
+
const rendered = telegramifyMarkdown(chunk, "escape");
|
|
100
119
|
try {
|
|
101
|
-
const sent = await this.bot.api.sendMessage(numId,
|
|
102
|
-
parse_mode:
|
|
120
|
+
const sent = await this.bot.api.sendMessage(numId, rendered, {
|
|
121
|
+
parse_mode: "MarkdownV2",
|
|
122
|
+
...replyParams,
|
|
103
123
|
});
|
|
104
124
|
handles.push(String(sent.message_id));
|
|
105
125
|
}
|
|
106
126
|
catch (err) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
handles.push(String(sent.message_id));
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
throw err;
|
|
114
|
-
}
|
|
127
|
+
log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
|
|
128
|
+
const sent = await this.bot.api.sendMessage(numId, chunk, replyParams);
|
|
129
|
+
handles.push(String(sent.message_id));
|
|
115
130
|
}
|
|
131
|
+
firstChunk = false;
|
|
116
132
|
}
|
|
117
133
|
return handles;
|
|
118
134
|
}
|
|
@@ -138,7 +154,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
138
154
|
keyboard.row();
|
|
139
155
|
}
|
|
140
156
|
// Convert universal markdown to MarkdownV2, fall back to plain text on failure
|
|
141
|
-
const mdv2Text =
|
|
157
|
+
const mdv2Text = telegramifyMarkdown(text, "escape");
|
|
142
158
|
let sent;
|
|
143
159
|
try {
|
|
144
160
|
sent = await this.bot.api.sendMessage(numId, mdv2Text, {
|
|
@@ -269,29 +285,69 @@ export class TelegramChannel extends EventEmitter {
|
|
|
269
285
|
}
|
|
270
286
|
}
|
|
271
287
|
}
|
|
288
|
+
/** Build user info from a grammY context (no auth check). */
|
|
289
|
+
buildUserInfo(ctx) {
|
|
290
|
+
if (!ctx.from?.id)
|
|
291
|
+
return null;
|
|
292
|
+
return {
|
|
293
|
+
id: `tg:${ctx.from.id}`,
|
|
294
|
+
name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
|
|
295
|
+
...(ctx.from.username && { handle: ctx.from.username }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
272
298
|
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
273
299
|
extractSender(ctx) {
|
|
274
|
-
|
|
300
|
+
const user = this.buildUserInfo(ctx);
|
|
301
|
+
if (!user || !ctx.chat?.id || !this.isAuthorized(user.id)) {
|
|
302
|
+
// Notify unauthorized DM senders with their user ID (for pairing)
|
|
303
|
+
if (user && ctx.chat?.type === "private" && this.onUnauthorizedDM) {
|
|
304
|
+
this.onUnauthorizedDM(`tg:${ctx.chat.id}`, user);
|
|
305
|
+
}
|
|
275
306
|
return null;
|
|
307
|
+
}
|
|
276
308
|
return {
|
|
277
309
|
chatId: `tg:${ctx.chat.id}`,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
310
|
+
chatType: ctx.chat.type === "private" ? "dm" : "group",
|
|
311
|
+
user,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** Extract reply context from a Telegram message, if it's a reply. */
|
|
315
|
+
extractReplyContext(msg) {
|
|
316
|
+
const reply = msg.reply_to_message;
|
|
317
|
+
if (!reply)
|
|
318
|
+
return undefined;
|
|
319
|
+
const mediaType = reply.photo ? "photo"
|
|
320
|
+
: reply.document ? "document"
|
|
321
|
+
: reply.video ? "video"
|
|
322
|
+
: reply.voice ? "voice"
|
|
323
|
+
: reply.audio ? "audio"
|
|
324
|
+
: reply.sticker ? "sticker"
|
|
325
|
+
: reply.animation ? "animation"
|
|
326
|
+
: undefined;
|
|
327
|
+
const senderName = reply.from
|
|
328
|
+
? [reply.from.first_name, reply.from.last_name].filter(Boolean).join(" ")
|
|
329
|
+
: undefined;
|
|
330
|
+
return {
|
|
331
|
+
messageId: String(reply.message_id),
|
|
332
|
+
senderName,
|
|
333
|
+
text: reply.text ?? reply.caption,
|
|
334
|
+
mediaType,
|
|
283
335
|
};
|
|
284
336
|
}
|
|
285
337
|
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
286
|
-
async
|
|
338
|
+
async handleFileMessage(ctx, fileId, mimeType, filename, caption) {
|
|
287
339
|
const sender = this.extractSender(ctx);
|
|
288
340
|
if (!sender)
|
|
289
341
|
return;
|
|
342
|
+
const messageId = ctx.message ? String(ctx.message.message_id) : undefined;
|
|
343
|
+
const replyTo = ctx.message ? this.extractReplyContext(ctx.message) : undefined;
|
|
290
344
|
try {
|
|
291
345
|
const buffer = await this.downloadFile(fileId);
|
|
292
346
|
this.emit("message", {
|
|
293
347
|
...sender,
|
|
294
348
|
text: caption ?? "",
|
|
349
|
+
messageId,
|
|
350
|
+
replyTo,
|
|
295
351
|
attachments: [{ buffer, mimeType, filename }],
|
|
296
352
|
});
|
|
297
353
|
}
|
|
@@ -302,9 +358,41 @@ export class TelegramChannel extends EventEmitter {
|
|
|
302
358
|
text: caption
|
|
303
359
|
? `${caption}\n[File download failed: ${filename ?? mimeType}]`
|
|
304
360
|
: `[File download failed: ${filename ?? mimeType}]`,
|
|
361
|
+
messageId,
|
|
362
|
+
replyTo,
|
|
305
363
|
});
|
|
306
364
|
}
|
|
307
365
|
}
|
|
366
|
+
async sendFile(chatId, buffer, filename, opts) {
|
|
367
|
+
const id = this.numericId(chatId);
|
|
368
|
+
const mimeType = opts?.mimeType ?? mime.getType(filename) ?? "application/octet-stream";
|
|
369
|
+
const file = new InputFile(buffer, filename);
|
|
370
|
+
const caption = opts?.caption;
|
|
371
|
+
if (mimeType.startsWith("image/")) {
|
|
372
|
+
await this.bot.api.sendPhoto(id, file, { caption });
|
|
373
|
+
}
|
|
374
|
+
else if (mimeType.startsWith("video/")) {
|
|
375
|
+
await this.bot.api.sendVideo(id, file, { caption });
|
|
376
|
+
}
|
|
377
|
+
else if (mimeType.startsWith("audio/")) {
|
|
378
|
+
await this.bot.api.sendAudio(id, file, { caption });
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
await this.bot.api.sendDocument(id, file, { caption });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async reactToMessage(chatId, messageId, emoji) {
|
|
385
|
+
const numId = this.numericId(chatId);
|
|
386
|
+
try {
|
|
387
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
388
|
+
await this.bot.api.setMessageReaction(numId, Number(messageId), [
|
|
389
|
+
{ type: "emoji", emoji: emoji },
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
log.warn("[channel] failed to set reaction on %s: %s", messageId, err instanceof Error ? err.message : String(err));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
308
396
|
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
309
397
|
async downloadFile(fileId) {
|
|
310
398
|
const file = await this.bot.api.getFile(fileId);
|
|
@@ -320,38 +408,6 @@ export class TelegramChannel extends EventEmitter {
|
|
|
320
408
|
return Number(chatId.replace(/^tg:/, ""));
|
|
321
409
|
}
|
|
322
410
|
}
|
|
323
|
-
/**
|
|
324
|
-
* Escape for Telegram MarkdownV2.
|
|
325
|
-
* Outside code blocks: all special chars. Inside: only backtick and backslash.
|
|
326
|
-
*/
|
|
327
|
-
function escapeMarkdownV2(text, codeBlock = false) {
|
|
328
|
-
const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
329
|
-
return text.replace(pattern, "\\$&");
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Convert universal markdown (with code fences) to Telegram MarkdownV2.
|
|
333
|
-
* Handles code blocks specially: fence markers stay literal, inner content
|
|
334
|
-
* only escapes ` and \, outer text gets full MarkdownV2 escaping.
|
|
335
|
-
*/
|
|
336
|
-
function convertToMarkdownV2(markdown) {
|
|
337
|
-
const result = [];
|
|
338
|
-
let pos = 0;
|
|
339
|
-
const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
340
|
-
let match;
|
|
341
|
-
while ((match = fenceRegex.exec(markdown)) !== null) {
|
|
342
|
-
if (match.index > pos) {
|
|
343
|
-
result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
|
|
344
|
-
}
|
|
345
|
-
result.push("```" + match[1] + "\n");
|
|
346
|
-
result.push(escapeMarkdownV2(match[2], true));
|
|
347
|
-
result.push("```");
|
|
348
|
-
pos = match.index + match[0].length;
|
|
349
|
-
}
|
|
350
|
-
if (pos < markdown.length) {
|
|
351
|
-
result.push(escapeMarkdownV2(markdown.slice(pos)));
|
|
352
|
-
}
|
|
353
|
-
return result.join("");
|
|
354
|
-
}
|
|
355
411
|
const MAX_MSG_LEN = 4096;
|
|
356
412
|
/** Split text into โค4096-char chunks, preferring newline boundaries. */
|
|
357
413
|
function splitMessage(text) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionMode } from "./types.js";
|
|
1
|
+
import type { PermissionMode, Workspace } from "./types.js";
|
|
2
2
|
export interface TelegramConfig {
|
|
3
3
|
type: "telegram";
|
|
4
4
|
botToken: string;
|
|
@@ -9,13 +9,60 @@ export interface SlackConfig {
|
|
|
9
9
|
appToken: string;
|
|
10
10
|
}
|
|
11
11
|
export type ChannelConfig = TelegramConfig | SlackConfig;
|
|
12
|
-
export interface
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
export interface AuthorizedUser {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
approvedAt: number;
|
|
16
|
+
}
|
|
17
|
+
export interface PendingPairing {
|
|
18
|
+
code: string;
|
|
19
|
+
userId: string;
|
|
20
|
+
userName: string;
|
|
21
|
+
chatId: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveDataDir(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Unified config: env var resolution + persistent store backed by config.json.
|
|
27
|
+
*
|
|
28
|
+
* - `new Config(dataDir)` gives store access (read/write methods)
|
|
29
|
+
* - `config.resolve()` adds env var settings (channel, envUserIds, permissionMode)
|
|
30
|
+
*
|
|
31
|
+
* File is re-read on every store call (hot-reload without file watching).
|
|
32
|
+
* Writes use 0o600 permissions (file contains bot token).
|
|
33
|
+
*/
|
|
34
|
+
export declare class Config {
|
|
35
|
+
channel?: ChannelConfig;
|
|
15
36
|
permissionMode: PermissionMode;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
filesPath: string;
|
|
19
|
-
logPath: string;
|
|
37
|
+
private envUserIds;
|
|
38
|
+
readonly defaultPromptPath: string;
|
|
39
|
+
readonly filesPath: string;
|
|
40
|
+
readonly logPath: string;
|
|
41
|
+
private readonly dataDir;
|
|
42
|
+
private readonly filePath;
|
|
43
|
+
constructor();
|
|
44
|
+
/** Resolve channel + env vars. Throws if no channel config found. */
|
|
45
|
+
resolve(): this;
|
|
46
|
+
private read;
|
|
47
|
+
private write;
|
|
48
|
+
getChannel(): ChannelConfig | undefined;
|
|
49
|
+
setChannel(config: ChannelConfig): void;
|
|
50
|
+
hasUser(userId: string): boolean;
|
|
51
|
+
addUser(user: AuthorizedUser): void;
|
|
52
|
+
listUsers(): AuthorizedUser[];
|
|
53
|
+
/** env var IDs (in-memory) + pairing-approved users (hot-reload from disk) */
|
|
54
|
+
isAuthorized(userId: string): boolean;
|
|
55
|
+
createPairing(userId: string, userName: string, chatId: string): string;
|
|
56
|
+
consumePairing(code: string): PendingPairing | null;
|
|
57
|
+
private generateCode;
|
|
58
|
+
workspaceByChat(chatId: string): Workspace | undefined;
|
|
59
|
+
workspaceByName(name: string): Workspace | undefined;
|
|
60
|
+
upsertWorkspace(ws: Workspace): void;
|
|
61
|
+
setSession(name: string, sessionId: string): void;
|
|
62
|
+
clearSession(name: string): void;
|
|
63
|
+
setBehavior(name: string, behavior: "assistant" | "relay"): void;
|
|
64
|
+
ensureDefaultWorkspace(chatId: string): void;
|
|
65
|
+
/** Copy bundled skills from the repo's skills/ directory to the home workspace's .claude/skills/. */
|
|
66
|
+
syncSkills(): void;
|
|
67
|
+
approveUser(userId: string, userName: string, chatId: string): void;
|
|
20
68
|
}
|
|
21
|
-
export declare function loadConfig(): Config;
|