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.
@@ -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 allowedUserIds;
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, allowedUserIds: Set<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
  }
@@ -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
- allowedUserIds;
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, allowedUserIds) {
22
+ constructor(botToken, appToken, isAuthorized, onUnauthorizedDM) {
15
23
  super();
16
24
  this.botToken = botToken;
17
- this.allowedUserIds = allowedUserIds;
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 || !this.allowedUserIds.has(`slack:${userId}`)) {
48
- if (userId)
49
- log.info("[channel] ignored message from unauthorized user slack:%s", userId);
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
- user: { id: `slack:${userId}`, name: userName ?? userId },
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.allowedUserIds.has(`slack:${userId}`))
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
- // If a typing placeholder exists, edit the first chunk into it instead of posting new
144
- const consumeTyping = opts?.consumeTyping !== false;
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 โ€” fall back to posting
158
- const result = await this.app.client.chat.postMessage({
159
- channel, text: firstChunk, blocks,
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 blocks = mrkdwnBlocks(chunk);
167
- const result = await this.app.client.chat.postMessage({
168
- channel, text: chunk, blocks,
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
- return [{ type: "section", text: { type: "mrkdwn", text } }];
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 allowedUserIds;
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, allowedUserIds: Set<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;
@@ -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
- allowedUserIds;
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, allowedUserIds) {
18
+ constructor(botToken, isAuthorized, onUnauthorizedDM) {
17
19
  super();
18
20
  this.botToken = botToken;
19
- this.allowedUserIds = allowedUserIds;
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", { ...sender, text: ctx.message.text });
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", { ...sender, text: `[${label} โ€” not supported]` });
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, chunk, {
103
- parse_mode: opts?.parseMode,
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
- if (opts?.parseMode) {
109
- log.warn("[channel] sendMessage failed with %s, retrying as plain text", opts.parseMode);
110
- const sent = await this.bot.api.sendMessage(numId, chunk);
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 = convertToMarkdownV2(text);
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
- if (!ctx.from?.id || !ctx.chat?.id || !this.allowedUserIds.has(`tg:${ctx.from.id}`))
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
- user: {
280
- id: `tg:${ctx.from.id}`,
281
- name: [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(" "),
282
- ...(ctx.from.username && { handle: ctx.from.username }),
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 Config {
13
- channel: ChannelConfig;
14
- allowedUserIds: Set<string>;
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
- defaultPromptPath: string;
17
- workspaceStorePath: string;
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;