clearclaw 0.5.0 โ 0.6.1
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 +8 -3
- package/dist/channel/slack.js +111 -23
- package/dist/channel/telegram.d.ts +9 -3
- package/dist/channel/telegram.js +91 -54
- package/dist/config.d.ts +56 -9
- package/dist/config.js +215 -44
- package/dist/engine/claude-code.js +3 -2
- package/dist/format.d.ts +26 -0
- package/dist/format.js +51 -0
- package/dist/index.js +141 -39
- package/dist/orchestrator.d.ts +15 -11
- package/dist/orchestrator.js +399 -155
- package/dist/tool-handlers.d.ts +19 -0
- package/dist/tool-handlers.js +73 -0
- package/dist/types.d.ts +16 -1
- package/package.json +8 -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, SendFileOpts, 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;
|
|
@@ -23,8 +25,11 @@ export declare class SlackChannel extends EventEmitter implements Channel {
|
|
|
23
25
|
updateStatus(chatId: string, text: string): Promise<void>;
|
|
24
26
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
25
27
|
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
28
|
+
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
26
29
|
private slackId;
|
|
27
30
|
/** Download a file from Slack using bot token auth. */
|
|
28
31
|
private downloadFile;
|
|
29
32
|
private resolveUserName;
|
|
33
|
+
/** Fetch the parent message of a thread reply for reply context. */
|
|
34
|
+
private fetchReplyContext;
|
|
30
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,22 @@ 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
|
+
/** Post a chunk with mrkdwn blocks, falling back to plain text on failure. */
|
|
172
|
+
const postChunk = async (chunk, extra = {}) => {
|
|
173
|
+
const blocks = mrkdwnBlocks(chunk);
|
|
174
|
+
try {
|
|
175
|
+
const r = await this.app.client.chat.postMessage({ channel, text: chunk, blocks, ...extra });
|
|
176
|
+
return r.ts;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
log.warn("[channel] postMessage failed with mrkdwn, retrying as plain text");
|
|
180
|
+
const r = await this.app.client.chat.postMessage({ channel, text: chunk, ...extra });
|
|
181
|
+
return r.ts;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
// Consume typing placeholder only when not posting as a thread reply
|
|
185
|
+
const consumeTyping = !threadTs && opts?.consumeTyping !== false;
|
|
145
186
|
const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
|
|
146
187
|
if (typingTs && chunks.length > 0) {
|
|
147
188
|
this.typingMessageTs.delete(chatId);
|
|
@@ -154,21 +195,16 @@ export class SlackChannel extends EventEmitter {
|
|
|
154
195
|
handles.push(typingTs);
|
|
155
196
|
}
|
|
156
197
|
catch {
|
|
157
|
-
// Placeholder was deleted externally
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (result.ts)
|
|
162
|
-
handles.push(result.ts);
|
|
198
|
+
// Placeholder was deleted externally or mrkdwn invalid โ post instead
|
|
199
|
+
const ts = await postChunk(firstChunk);
|
|
200
|
+
if (ts)
|
|
201
|
+
handles.push(ts);
|
|
163
202
|
}
|
|
164
203
|
}
|
|
165
204
|
for (const chunk of chunks) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
});
|
|
170
|
-
if (result.ts)
|
|
171
|
-
handles.push(result.ts);
|
|
205
|
+
const ts = await postChunk(chunk, threadTs ? { thread_ts: threadTs } : {});
|
|
206
|
+
if (ts)
|
|
207
|
+
handles.push(ts);
|
|
172
208
|
}
|
|
173
209
|
return handles;
|
|
174
210
|
}
|
|
@@ -353,6 +389,23 @@ export class SlackChannel extends EventEmitter {
|
|
|
353
389
|
initial_comment: opts?.caption,
|
|
354
390
|
});
|
|
355
391
|
}
|
|
392
|
+
async reactToMessage(chatId, messageId, emoji) {
|
|
393
|
+
const name = SlackChannel.EMOJI_TO_SLACK[emoji];
|
|
394
|
+
if (!name) {
|
|
395
|
+
log.warn("[channel] no Slack emoji name for %s, skipping reaction", emoji);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
await this.app.client.reactions.add({
|
|
400
|
+
channel: this.slackId(chatId),
|
|
401
|
+
timestamp: messageId,
|
|
402
|
+
name,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
log.warn("[channel] failed to add reaction %s: %s", name, err instanceof Error ? err.message : String(err));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
356
409
|
slackId(chatId) {
|
|
357
410
|
return chatId.replace(/^slack:/, "");
|
|
358
411
|
}
|
|
@@ -380,9 +433,44 @@ export class SlackChannel extends EventEmitter {
|
|
|
380
433
|
return undefined;
|
|
381
434
|
}
|
|
382
435
|
}
|
|
436
|
+
/** Fetch the parent message of a thread reply for reply context. */
|
|
437
|
+
async fetchReplyContext(channel, threadTs, ownTs) {
|
|
438
|
+
if (threadTs === ownTs)
|
|
439
|
+
return undefined;
|
|
440
|
+
try {
|
|
441
|
+
const result = await this.app.client.conversations.history({
|
|
442
|
+
channel,
|
|
443
|
+
latest: threadTs,
|
|
444
|
+
limit: 1,
|
|
445
|
+
inclusive: true,
|
|
446
|
+
});
|
|
447
|
+
const parent = result.messages?.[0];
|
|
448
|
+
if (!parent)
|
|
449
|
+
return undefined;
|
|
450
|
+
const senderName = parent.user ? await this.resolveUserName(parent.user) : undefined;
|
|
451
|
+
return {
|
|
452
|
+
messageId: threadTs,
|
|
453
|
+
senderName: senderName ?? parent.user,
|
|
454
|
+
text: parent.text,
|
|
455
|
+
mediaType: parent.files?.[0]?.mimetype,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
log.warn({ err }, "[channel] failed to fetch thread parent %s", threadTs);
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
383
463
|
}
|
|
384
464
|
function mrkdwnBlocks(text) {
|
|
385
|
-
|
|
465
|
+
let rendered;
|
|
466
|
+
try {
|
|
467
|
+
rendered = slackifyMarkdown(text);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
log.warn({ err }, "[channel] slackifyMarkdown failed, sending raw text");
|
|
471
|
+
rendered = text;
|
|
472
|
+
}
|
|
473
|
+
return [{ type: "section", text: { type: "mrkdwn", text: rendered } }];
|
|
386
474
|
}
|
|
387
475
|
// Block Kit section.text has a 3000 char limit โ the tightest Slack constraint.
|
|
388
476
|
// 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, SendFileOpts, 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,11 +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
32
|
private handleFileMessage;
|
|
28
33
|
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
34
|
+
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
29
35
|
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
30
36
|
private downloadFile;
|
|
31
37
|
private numericId;
|
package/dist/channel/telegram.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
3
3
|
import mime from "mime";
|
|
4
|
+
import telegramifyMarkdown from "telegramify-markdown";
|
|
4
5
|
import log from "../logger.js";
|
|
5
6
|
export class TelegramChannel extends EventEmitter {
|
|
6
7
|
name = "telegram";
|
|
7
8
|
bot;
|
|
8
9
|
botToken;
|
|
9
|
-
|
|
10
|
+
isAuthorized;
|
|
11
|
+
onUnauthorizedDM;
|
|
10
12
|
typingIntervals = new Map();
|
|
11
13
|
statusHandles = new Map(); // chatId โ message handle for pinned status
|
|
12
14
|
// Pending interactive responses: callbackId โ resolve function
|
|
13
15
|
pendingCallbacks = new Map();
|
|
14
16
|
// Pending text input: chatId โ resolve function (for requestText follow-ups)
|
|
15
17
|
pendingTextResolvers = new Map();
|
|
16
|
-
constructor(botToken,
|
|
18
|
+
constructor(botToken, isAuthorized, onUnauthorizedDM) {
|
|
17
19
|
super();
|
|
18
20
|
this.botToken = botToken;
|
|
19
|
-
this.
|
|
21
|
+
this.isAuthorized = isAuthorized;
|
|
22
|
+
this.onUnauthorizedDM = onUnauthorizedDM;
|
|
20
23
|
this.bot = new Bot(botToken);
|
|
21
24
|
}
|
|
22
25
|
async connect() {
|
|
@@ -31,7 +34,12 @@ export class TelegramChannel extends EventEmitter {
|
|
|
31
34
|
textResolver(ctx.message.text);
|
|
32
35
|
return;
|
|
33
36
|
}
|
|
34
|
-
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
|
+
});
|
|
35
43
|
});
|
|
36
44
|
// Photos: Telegram sends each photo as an array of PhotoSize objects
|
|
37
45
|
// (same image at different resolutions, sorted smallest โ largest).
|
|
@@ -57,7 +65,12 @@ export class TelegramChannel extends EventEmitter {
|
|
|
57
65
|
const sender = this.extractSender(ctx);
|
|
58
66
|
if (!sender)
|
|
59
67
|
return;
|
|
60
|
-
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
|
+
});
|
|
61
74
|
});
|
|
62
75
|
}
|
|
63
76
|
// Handle inline keyboard button presses
|
|
@@ -97,23 +110,25 @@ export class TelegramChannel extends EventEmitter {
|
|
|
97
110
|
const numId = this.numericId(chatId);
|
|
98
111
|
const chunks = splitMessage(text);
|
|
99
112
|
const handles = [];
|
|
113
|
+
let firstChunk = true;
|
|
100
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");
|
|
101
119
|
try {
|
|
102
|
-
const sent = await this.bot.api.sendMessage(numId,
|
|
103
|
-
parse_mode:
|
|
120
|
+
const sent = await this.bot.api.sendMessage(numId, rendered, {
|
|
121
|
+
parse_mode: "MarkdownV2",
|
|
122
|
+
...replyParams,
|
|
104
123
|
});
|
|
105
124
|
handles.push(String(sent.message_id));
|
|
106
125
|
}
|
|
107
126
|
catch (err) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
handles.push(String(sent.message_id));
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
throw err;
|
|
115
|
-
}
|
|
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));
|
|
116
130
|
}
|
|
131
|
+
firstChunk = false;
|
|
117
132
|
}
|
|
118
133
|
return handles;
|
|
119
134
|
}
|
|
@@ -139,7 +154,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
139
154
|
keyboard.row();
|
|
140
155
|
}
|
|
141
156
|
// Convert universal markdown to MarkdownV2, fall back to plain text on failure
|
|
142
|
-
const mdv2Text =
|
|
157
|
+
const mdv2Text = telegramifyMarkdown(text, "escape");
|
|
143
158
|
let sent;
|
|
144
159
|
try {
|
|
145
160
|
sent = await this.bot.api.sendMessage(numId, mdv2Text, {
|
|
@@ -270,17 +285,53 @@ export class TelegramChannel extends EventEmitter {
|
|
|
270
285
|
}
|
|
271
286
|
}
|
|
272
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
|
+
}
|
|
273
298
|
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
274
299
|
extractSender(ctx) {
|
|
275
|
-
|
|
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
|
+
}
|
|
276
306
|
return null;
|
|
307
|
+
}
|
|
277
308
|
return {
|
|
278
309
|
chatId: `tg:${ctx.chat.id}`,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
284
335
|
};
|
|
285
336
|
}
|
|
286
337
|
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
@@ -288,11 +339,15 @@ export class TelegramChannel extends EventEmitter {
|
|
|
288
339
|
const sender = this.extractSender(ctx);
|
|
289
340
|
if (!sender)
|
|
290
341
|
return;
|
|
342
|
+
const messageId = ctx.message ? String(ctx.message.message_id) : undefined;
|
|
343
|
+
const replyTo = ctx.message ? this.extractReplyContext(ctx.message) : undefined;
|
|
291
344
|
try {
|
|
292
345
|
const buffer = await this.downloadFile(fileId);
|
|
293
346
|
this.emit("message", {
|
|
294
347
|
...sender,
|
|
295
348
|
text: caption ?? "",
|
|
349
|
+
messageId,
|
|
350
|
+
replyTo,
|
|
296
351
|
attachments: [{ buffer, mimeType, filename }],
|
|
297
352
|
});
|
|
298
353
|
}
|
|
@@ -303,6 +358,8 @@ export class TelegramChannel extends EventEmitter {
|
|
|
303
358
|
text: caption
|
|
304
359
|
? `${caption}\n[File download failed: ${filename ?? mimeType}]`
|
|
305
360
|
: `[File download failed: ${filename ?? mimeType}]`,
|
|
361
|
+
messageId,
|
|
362
|
+
replyTo,
|
|
306
363
|
});
|
|
307
364
|
}
|
|
308
365
|
}
|
|
@@ -324,6 +381,18 @@ export class TelegramChannel extends EventEmitter {
|
|
|
324
381
|
await this.bot.api.sendDocument(id, file, { caption });
|
|
325
382
|
}
|
|
326
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
|
+
}
|
|
327
396
|
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
328
397
|
async downloadFile(fileId) {
|
|
329
398
|
const file = await this.bot.api.getFile(fileId);
|
|
@@ -339,38 +408,6 @@ export class TelegramChannel extends EventEmitter {
|
|
|
339
408
|
return Number(chatId.replace(/^tg:/, ""));
|
|
340
409
|
}
|
|
341
410
|
}
|
|
342
|
-
/**
|
|
343
|
-
* Escape for Telegram MarkdownV2.
|
|
344
|
-
* Outside code blocks: all special chars. Inside: only backtick and backslash.
|
|
345
|
-
*/
|
|
346
|
-
function escapeMarkdownV2(text, codeBlock = false) {
|
|
347
|
-
const pattern = codeBlock ? /[`\\]/g : /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
348
|
-
return text.replace(pattern, "\\$&");
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Convert universal markdown (with code fences) to Telegram MarkdownV2.
|
|
352
|
-
* Handles code blocks specially: fence markers stay literal, inner content
|
|
353
|
-
* only escapes ` and \, outer text gets full MarkdownV2 escaping.
|
|
354
|
-
*/
|
|
355
|
-
function convertToMarkdownV2(markdown) {
|
|
356
|
-
const result = [];
|
|
357
|
-
let pos = 0;
|
|
358
|
-
const fenceRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
359
|
-
let match;
|
|
360
|
-
while ((match = fenceRegex.exec(markdown)) !== null) {
|
|
361
|
-
if (match.index > pos) {
|
|
362
|
-
result.push(escapeMarkdownV2(markdown.slice(pos, match.index)));
|
|
363
|
-
}
|
|
364
|
-
result.push("```" + match[1] + "\n");
|
|
365
|
-
result.push(escapeMarkdownV2(match[2], true));
|
|
366
|
-
result.push("```");
|
|
367
|
-
pos = match.index + match[0].length;
|
|
368
|
-
}
|
|
369
|
-
if (pos < markdown.length) {
|
|
370
|
-
result.push(escapeMarkdownV2(markdown.slice(pos)));
|
|
371
|
-
}
|
|
372
|
-
return result.join("");
|
|
373
|
-
}
|
|
374
411
|
const MAX_MSG_LEN = 4096;
|
|
375
412
|
/** Split text into โค4096-char chunks, preferring newline boundaries. */
|
|
376
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;
|