clearclaw 0.6.1 → 0.8.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.
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { Bot, InlineKeyboard, InputFile } from "grammy";
2
+ import { Bot, GrammyError, InlineKeyboard, InputFile } from "grammy";
3
3
  import mime from "mime";
4
4
  import telegramifyMarkdown from "telegramify-markdown";
5
5
  import log from "../logger.js";
@@ -15,6 +15,9 @@ export class TelegramChannel extends EventEmitter {
15
15
  pendingCallbacks = new Map();
16
16
  // Pending text input: chatId → resolve function (for requestText follow-ups)
17
17
  pendingTextResolvers = new Map();
18
+ // Our bot's @username, captured at connect. Used to strip the `@bot` suffix
19
+ // Telegram appends to commands in group chats.
20
+ botUsername;
18
21
  constructor(botToken, isAuthorized, onUnauthorizedDM) {
19
22
  super();
20
23
  this.botToken = botToken;
@@ -22,6 +25,31 @@ export class TelegramChannel extends EventEmitter {
22
25
  this.onUnauthorizedDM = onUnauthorizedDM;
23
26
  this.bot = new Bot(botToken);
24
27
  }
28
+ /**
29
+ * Strip the `@<botUsername>` suffix Telegram appends to commands in group
30
+ * chats (`/context@mybot args` → `/context args`), so downstream command
31
+ * handling — ClearClaw's own `/new` etc. and slash-command pass-through —
32
+ * sees the bare `/command`. No-op for non-command text and in DMs (no suffix).
33
+ * Telegram usernames are `[A-Za-z0-9_]`, so they need no regex escaping.
34
+ */
35
+ stripCommandMention(text) {
36
+ if (!this.botUsername || !text.startsWith("/"))
37
+ return text;
38
+ return text.replace(new RegExp(`^(/[^\\s@]+)@${this.botUsername}(?=\\s|$)`, "i"), "$1");
39
+ }
40
+ async telegramCall(call, label) {
41
+ try {
42
+ return await call();
43
+ }
44
+ catch (err) {
45
+ const retryAfter = telegramRetryAfterSeconds(err);
46
+ if (retryAfter === null)
47
+ throw err;
48
+ log.warn("[channel] Telegram rate limited during %s, retrying after %ss", label, retryAfter);
49
+ await sleep((retryAfter + 1) * 1000);
50
+ return await call();
51
+ }
52
+ }
25
53
  async connect() {
26
54
  this.bot.on("message:text", (ctx) => {
27
55
  const sender = this.extractSender(ctx);
@@ -36,7 +64,7 @@ export class TelegramChannel extends EventEmitter {
36
64
  }
37
65
  this.emit("message", {
38
66
  ...sender,
39
- text: ctx.message.text,
67
+ text: this.stripCommandMention(ctx.message.text),
40
68
  messageId: String(ctx.message.message_id),
41
69
  replyTo: this.extractReplyContext(ctx.message),
42
70
  });
@@ -89,6 +117,7 @@ export class TelegramChannel extends EventEmitter {
89
117
  await new Promise((resolve) => {
90
118
  this.bot.start({
91
119
  onStart: (botInfo) => {
120
+ this.botUsername = botInfo.username;
92
121
  log.info(`[channel] Telegram bot: @${botInfo.username}`);
93
122
  resolve();
94
123
  },
@@ -106,27 +135,39 @@ export class TelegramChannel extends EventEmitter {
106
135
  ownsId(chatId) {
107
136
  return chatId.startsWith("tg:");
108
137
  }
138
+ /** A private chat is the user's own id; a ":thread" suffix means a topic inside it. */
139
+ isRootDM(chatId, userId) {
140
+ return /^tg:[1-9]\d*$/.test(chatId) && chatId === userId;
141
+ }
109
142
  async sendMessage(chatId, text, opts) {
110
143
  const numId = this.numericId(chatId);
111
144
  const chunks = splitMessage(text);
112
145
  const handles = [];
146
+ const plain = opts?.format === "plain";
113
147
  let firstChunk = true;
114
148
  for (const chunk of chunks) {
115
149
  const replyParams = firstChunk && opts?.replyToMessageId
116
150
  ? { reply_parameters: { message_id: Number(opts.replyToMessageId) } }
117
151
  : {};
118
- const rendered = telegramifyMarkdown(chunk, "escape");
119
- try {
120
- const sent = await this.bot.api.sendMessage(numId, rendered, {
121
- parse_mode: "MarkdownV2",
122
- ...replyParams,
123
- });
152
+ if (plain) {
153
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
124
154
  handles.push(String(sent.message_id));
125
155
  }
126
- catch (err) {
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));
156
+ else {
157
+ const rendered = telegramifyMarkdown(chunk, "escape");
158
+ try {
159
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, rendered, {
160
+ ...this.threadOpts(chatId),
161
+ parse_mode: "MarkdownV2",
162
+ ...replyParams,
163
+ }), "sendMessage");
164
+ handles.push(String(sent.message_id));
165
+ }
166
+ catch (err) {
167
+ log.warn("[channel] sendMessage failed with MarkdownV2, retrying as plain text");
168
+ const sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, chunk, { ...this.threadOpts(chatId), ...replyParams }), "sendMessage");
169
+ handles.push(String(sent.message_id));
170
+ }
130
171
  }
131
172
  firstChunk = false;
132
173
  }
@@ -157,16 +198,18 @@ export class TelegramChannel extends EventEmitter {
157
198
  const mdv2Text = telegramifyMarkdown(text, "escape");
158
199
  let sent;
159
200
  try {
160
- sent = await this.bot.api.sendMessage(numId, mdv2Text, {
201
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, mdv2Text, {
202
+ ...this.threadOpts(chatId),
161
203
  reply_markup: keyboard,
162
204
  parse_mode: "MarkdownV2",
163
- });
205
+ }), "sendInteractive");
164
206
  }
165
207
  catch {
166
208
  log.warn("[channel] sendInteractive failed with MarkdownV2, retrying as plain text");
167
- sent = await this.bot.api.sendMessage(numId, text, {
209
+ sent = await this.telegramCall(() => this.bot.api.sendMessage(numId, text, {
210
+ ...this.threadOpts(chatId),
168
211
  reply_markup: keyboard,
169
- });
212
+ }), "sendInteractive");
170
213
  }
171
214
  const cleanupAll = () => {
172
215
  for (const { cbData } of cbEntries) {
@@ -194,17 +237,18 @@ export class TelegramChannel extends EventEmitter {
194
237
  feedbackKeyboard.row();
195
238
  }
196
239
  try {
197
- await this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
240
+ await this.telegramCall(() => this.bot.api.editMessageReplyMarkup(numId, sent.message_id, {
198
241
  reply_markup: feedbackKeyboard,
199
- });
242
+ }), "editMessageReplyMarkup");
200
243
  }
201
244
  catch { /* best-effort */ }
202
245
  // If the button requests text, prompt for follow-up input
203
246
  if (pressed.requestText) {
204
- await this.bot.api.sendMessage(numId, "Add your feedback:", {
247
+ await this.telegramCall(() => this.bot.api.sendMessage(numId, "Add your feedback:", {
248
+ ...this.threadOpts(chatId),
205
249
  reply_markup: { force_reply: true },
206
250
  reply_parameters: { message_id: sent.message_id },
207
- });
251
+ }), "sendInteractiveFollowUp");
208
252
  const followUpText = await new Promise((resolve) => {
209
253
  this.pendingTextResolvers.set(chatId, resolve);
210
254
  });
@@ -212,9 +256,20 @@ export class TelegramChannel extends EventEmitter {
212
256
  }
213
257
  return { value: pressed.value };
214
258
  }
215
- async editMessage(chatId, handle, text) {
259
+ async editMessage(chatId, handle, text, opts) {
216
260
  const numId = this.numericId(chatId);
217
- await this.bot.api.editMessageText(numId, Number(handle), text);
261
+ if (opts?.format === "plain") {
262
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
263
+ }
264
+ else {
265
+ const rendered = telegramifyMarkdown(text, "escape");
266
+ try {
267
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), rendered, { parse_mode: "MarkdownV2" }), "editMessage");
268
+ }
269
+ catch {
270
+ await this.telegramCall(() => this.bot.api.editMessageText(numId, Number(handle), text), "editMessage");
271
+ }
272
+ }
218
273
  }
219
274
  async deleteMessage(chatId, handle) {
220
275
  const numId = this.numericId(chatId);
@@ -222,13 +277,18 @@ export class TelegramChannel extends EventEmitter {
222
277
  }
223
278
  async pinMessage(chatId, handle) {
224
279
  const numId = this.numericId(chatId);
225
- await this.bot.api.pinChatMessage(numId, Number(handle), {
280
+ await this.telegramCall(() => this.bot.api.pinChatMessage(numId, Number(handle), {
226
281
  disable_notification: true,
227
- });
282
+ }), "pinMessage");
228
283
  }
284
+ /** Unpin everything in this chat's scope: the forum topic for a topic chat, else the whole chat. */
229
285
  async unpinAllMessages(chatId) {
230
286
  const numId = this.numericId(chatId);
231
- await this.bot.api.unpinAllChatMessages(numId);
287
+ const { message_thread_id } = this.threadOpts(chatId);
288
+ if (message_thread_id)
289
+ await this.bot.api.unpinAllForumTopicMessages(numId, message_thread_id);
290
+ else
291
+ await this.bot.api.unpinAllChatMessages(numId);
232
292
  }
233
293
  async updateStatus(chatId, text) {
234
294
  const existing = this.statusHandles.get(chatId);
@@ -248,7 +308,9 @@ export class TelegramChannel extends EventEmitter {
248
308
  }
249
309
  }
250
310
  }
251
- // No existing handle, or edit failed — create new pinned message
311
+ // No existing handle, or edit failed — (re)create the pinned status. The handle lives only in
312
+ // memory, so after a restart the previous status is unknown; clear every pin in scope instead
313
+ // (topic-scoped for forum topics, so peers in other topics keep theirs).
252
314
  try {
253
315
  await this.unpinAllMessages(chatId);
254
316
  }
@@ -270,10 +332,10 @@ export class TelegramChannel extends EventEmitter {
270
332
  const numId = this.numericId(chatId);
271
333
  // Send immediately, then repeat every 4s
272
334
  await this.bot.api
273
- .sendChatAction(numId, "typing")
335
+ .sendChatAction(numId, "typing", this.threadOpts(chatId))
274
336
  .catch(() => { });
275
337
  const interval = setInterval(() => {
276
- this.bot.api.sendChatAction(numId, "typing").catch(() => { });
338
+ this.bot.api.sendChatAction(numId, "typing", this.threadOpts(chatId)).catch(() => { });
277
339
  }, 4000);
278
340
  this.typingIntervals.set(chatId, interval);
279
341
  }
@@ -305,10 +367,13 @@ export class TelegramChannel extends EventEmitter {
305
367
  }
306
368
  return null;
307
369
  }
370
+ const topicSuffix = ctx.message?.is_topic_message && ctx.message.message_thread_id
371
+ ? `:${ctx.message.message_thread_id}`
372
+ : "";
308
373
  return {
309
- chatId: `tg:${ctx.chat.id}`,
374
+ chatId: `tg:${ctx.chat.id}${topicSuffix}`,
310
375
  chatType: ctx.chat.type === "private" ? "dm" : "group",
311
- user,
376
+ origin: { kind: "user", user },
312
377
  };
313
378
  }
314
379
  /** Extract reply context from a Telegram message, if it's a reply. */
@@ -369,16 +434,16 @@ export class TelegramChannel extends EventEmitter {
369
434
  const file = new InputFile(buffer, filename);
370
435
  const caption = opts?.caption;
371
436
  if (mimeType.startsWith("image/")) {
372
- await this.bot.api.sendPhoto(id, file, { caption });
437
+ await this.bot.api.sendPhoto(id, file, { caption, ...this.threadOpts(chatId) });
373
438
  }
374
439
  else if (mimeType.startsWith("video/")) {
375
- await this.bot.api.sendVideo(id, file, { caption });
440
+ await this.bot.api.sendVideo(id, file, { caption, ...this.threadOpts(chatId) });
376
441
  }
377
442
  else if (mimeType.startsWith("audio/")) {
378
- await this.bot.api.sendAudio(id, file, { caption });
443
+ await this.bot.api.sendAudio(id, file, { caption, ...this.threadOpts(chatId) });
379
444
  }
380
445
  else {
381
- await this.bot.api.sendDocument(id, file, { caption });
446
+ await this.bot.api.sendDocument(id, file, { caption, ...this.threadOpts(chatId) });
382
447
  }
383
448
  }
384
449
  async reactToMessage(chatId, messageId, emoji) {
@@ -393,6 +458,45 @@ export class TelegramChannel extends EventEmitter {
393
458
  log.warn("[channel] failed to set reaction on %s: %s", messageId, err instanceof Error ? err.message : String(err));
394
459
  }
395
460
  }
461
+ async createProjectChat(projectName, anchor, title) {
462
+ await this.validateProjectChat(projectName, anchor);
463
+ const topic = await this.bot.api.createForumTopic(this.numericId(anchor), title);
464
+ return `tg:${this.numericId(anchor)}:${topic.message_thread_id}`;
465
+ }
466
+ async closeProjectChat(chatId) {
467
+ const thread = chatId.split(":")[2];
468
+ if (!thread)
469
+ throw new Error("Telegram cannot close a whole chat; only topics can be archived");
470
+ const parentId = this.numericId(chatId);
471
+ if (parentId > 0) {
472
+ await this.bot.api.deleteForumTopic(parentId, Number(thread));
473
+ return;
474
+ }
475
+ try {
476
+ await this.bot.api.closeForumTopic(parentId, Number(thread));
477
+ }
478
+ catch (err) {
479
+ if (err instanceof GrammyError && err.description === "Bad Request: TOPIC_NOT_MODIFIED")
480
+ return;
481
+ throw err;
482
+ }
483
+ }
484
+ async validateProjectChat(projectName, anchor) {
485
+ const parentId = this.numericId(anchor);
486
+ if (!Number.isFinite(parentId)) {
487
+ throw new Error(`Telegram project "${projectName}" must use a valid chat ID`);
488
+ }
489
+ const chat = await this.bot.api.getChat(parentId);
490
+ if (chat.type === "private") {
491
+ const botInfo = await this.bot.api.getMe();
492
+ if (botInfo.has_topics_enabled)
493
+ return;
494
+ throw new Error(`Telegram project "${projectName}" requires Threaded Mode for this bot. Enable it in BotFather, then retry.`);
495
+ }
496
+ if (chat.type === "supergroup" && chat.is_forum)
497
+ return;
498
+ throw new Error(`Telegram project "${projectName}" requires a forum supergroup. Enable Topics manually in Telegram, then retry.`);
499
+ }
396
500
  /** Download a Telegram file by file_id, returning the raw buffer. */
397
501
  async downloadFile(fileId) {
398
502
  const file = await this.bot.api.getFile(fileId);
@@ -404,8 +508,14 @@ export class TelegramChannel extends EventEmitter {
404
508
  throw new Error(`File download failed: ${resp.status}`);
405
509
  return Buffer.from(await resp.arrayBuffer());
406
510
  }
511
+ /** "tg:123" → 123; "tg:123:45" (forum topic) → 123 */
407
512
  numericId(chatId) {
408
- return Number(chatId.replace(/^tg:/, ""));
513
+ return Number(chatId.split(":")[1]);
514
+ }
515
+ /** Topic part of a composite chat id, as spreadable send options. */
516
+ threadOpts(chatId) {
517
+ const thread = chatId.split(":")[2];
518
+ return thread ? { message_thread_id: Number(thread) } : {};
409
519
  }
410
520
  }
411
521
  const MAX_MSG_LEN = 4096;
@@ -432,4 +542,18 @@ function splitMessage(text) {
432
542
  chunks.push(remaining);
433
543
  return chunks;
434
544
  }
545
+ function telegramRetryAfterSeconds(err) {
546
+ const candidate = err;
547
+ const retryAfter = candidate?.parameters?.retry_after;
548
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter))
549
+ return retryAfter;
550
+ const message = err instanceof Error ? err.message : String(err);
551
+ const match = message.match(/retry after (\d+)/i);
552
+ if (!match)
553
+ return null;
554
+ return Number(match[1]);
555
+ }
556
+ function sleep(ms) {
557
+ return new Promise((resolve) => setTimeout(resolve, ms));
558
+ }
435
559
  //# sourceMappingURL=telegram.js.map
package/dist/config.d.ts CHANGED
@@ -21,6 +21,25 @@ export interface PendingPairing {
21
21
  chatId: string;
22
22
  expiresAt: number;
23
23
  }
24
+ export interface EngineEntry {
25
+ name: string;
26
+ default?: boolean;
27
+ path?: string;
28
+ configPath?: string;
29
+ }
30
+ export interface ScheduleEntry {
31
+ id: string;
32
+ cron: string;
33
+ prompt: string;
34
+ enabled: boolean;
35
+ timezone?: string;
36
+ createdAt: number;
37
+ }
38
+ export interface Project {
39
+ name: string;
40
+ description: string;
41
+ main_workspace: string;
42
+ }
24
43
  export declare function resolveDataDir(): string;
25
44
  /**
26
45
  * Unified config: env var resolution + persistent store backed by config.json.
@@ -33,36 +52,61 @@ export declare function resolveDataDir(): string;
33
52
  */
34
53
  export declare class Config {
35
54
  channel?: ChannelConfig;
55
+ engines: Record<string, EngineEntry>;
56
+ defaultEngine: string;
36
57
  permissionMode: PermissionMode;
37
58
  private envUserIds;
38
- readonly defaultPromptPath: string;
59
+ readonly homeWorkspacePath: string;
39
60
  readonly filesPath: string;
40
61
  readonly logPath: string;
62
+ readonly instructionsDir: string;
63
+ readonly frameworkPromptDir: string;
41
64
  private readonly dataDir;
42
65
  private readonly filePath;
43
66
  constructor();
44
67
  /** Resolve channel + env vars. Throws if no channel config found. */
45
68
  resolve(): this;
69
+ private readRaw;
46
70
  private read;
47
71
  private write;
48
72
  getChannel(): ChannelConfig | undefined;
49
73
  setChannel(config: ChannelConfig): void;
74
+ setEngines(engines: EngineEntry[]): void;
50
75
  hasUser(userId: string): boolean;
51
76
  addUser(user: AuthorizedUser): void;
52
77
  listUsers(): AuthorizedUser[];
78
+ /** All authorized IDs, including environment-configured and pairing-approved users. */
79
+ listAuthorizedUserIds(): string[];
53
80
  /** env var IDs (in-memory) + pairing-approved users (hot-reload from disk) */
54
81
  isAuthorized(userId: string): boolean;
55
82
  createPairing(userId: string, userName: string, chatId: string): string;
56
83
  consumePairing(code: string): PendingPairing | null;
57
84
  private generateCode;
85
+ /** Home exists before a chat is paired. Never replace an existing home's settings. */
86
+ ensureHomeWorkspace(): Workspace;
87
+ /**
88
+ * ensureHomeWorkspace creates home's records at startup, before any chat is known.
89
+ * This gives it its destination, once: an existing binding is never replaced.
90
+ * Callers decide what counts as home's chat — see Channel.isRootDM.
91
+ */
92
+ connectHomeWorkspace(chatId: string): boolean;
58
93
  workspaceByChat(chatId: string): Workspace | undefined;
59
94
  workspaceByName(name: string): Workspace | undefined;
95
+ listWorkspaces(): Workspace[];
60
96
  upsertWorkspace(ws: Workspace): void;
97
+ removeWorkspace(name: string): Workspace | undefined;
98
+ /** Persist the session ID without changing the saved model setting. */
61
99
  setSession(name: string, sessionId: string): void;
62
100
  clearSession(name: string): void;
101
+ setModel(name: string, model: string | undefined): void;
63
102
  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;
103
+ addProject(project: Project): void;
104
+ listProjects(): Project[];
105
+ projectByName(name: string): Project | undefined;
106
+ removeProject(name: string): void;
107
+ listSchedules(): ScheduleEntry[];
108
+ addSchedule(entry: ScheduleEntry): void;
109
+ removeSchedule(id: string): void;
110
+ updateSchedule(id: string, update: Partial<Pick<ScheduleEntry, "enabled" | "cron" | "prompt" | "timezone">>): void;
111
+ approveUser(userId: string, userName: string): void;
68
112
  }