clearclaw 0.7.0 → 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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ ClearClaw reuses what the CLI already has:
17
17
 
18
18
  ## What It Does
19
19
 
20
- - Routes messages between your phone and your coding agent (Telegram today, Slack planned)
20
+ - Routes messages between your phone and your coding agent through Telegram or Slack
21
21
  - Permission prompts with diffs, tool status, and feedback — like the terminal, on your phone
22
22
  - Maps chat groups to project workspaces — each group = a working directory
23
23
  - Personal assistant workspace for non-project conversations
@@ -33,19 +33,65 @@ ClearClaw reuses what the CLI already has:
33
33
  ## Quick Start
34
34
 
35
35
  ```bash
36
- export TELEGRAM_BOT_TOKEN="your-bot-token"
37
- export ALLOWED_USER_IDS="tg:your-telegram-id"
38
-
39
- npx clearclaw
36
+ npm install -g clearclaw
37
+ clearclaw setup
38
+ clearclaw daemon
40
39
  ```
41
40
 
42
- Or install globally: `npm install -g clearclaw`
41
+ Setup saves the channel tokens and default engine, then asks you to DM the bot and approve its pairing code. The platform supplies your user and chat IDs; you do not need to copy them into environment variables. Additional pairing requests can be approved with `clearclaw approve <code>`.
42
+
43
+ Home is created automatically and connects to the approved DM. Start chatting immediately. Ask from home or another workspace to create a project and workspace together. If you choose a manual chat, create the group and send `/connect <workspace>` there (Slack: `/cc connect <workspace>`).
44
+
45
+ Existing environment-based installations remain supported: channel token variables and `ALLOWED_USER_IDS` override saved configuration. An authorized user's first root DM binds an unbound home. Existing bindings are preserved.
46
+
47
+ Send `/recap` (Slack: `/cc recap`) to replay the current session's last three prompts and their replies, including any reply still in progress. It reads history from the workspace's engine, so it also includes work done in the CLI. Use `/resume` separately when you want to select a different session.
48
+ If the engine locks history during an active turn, retry after that turn finishes.
43
49
 
44
50
  Optional: `PERMISSION_MODE` (`default` | `acceptEdits` | `bypassPermissions` | `plan`), `CLEARCLAW_HOME` (default `~/.clearclaw`).
45
51
 
52
+ ### Engine executable and config paths
53
+
54
+ Setup stores the selected engine's resolved CLI executable in `~/.clearclaw/config.json` as `engines[].path`. Claude Code passes this path to the Agent SDK. Kiro runs this path with the `acp` argument. Codex starts ClearClaw's pinned, patched `codex-acp` adapter through Node and passes the configured Codex CLI path to it as `CODEX_PATH`.
55
+
56
+ For Codex, setup prefers an installed `codex` CLI; if none is on `PATH`, it omits the override and uses the bundled executable.
57
+
58
+ Without a configured override, Kiro resolves `kiro-cli` from `PATH`. Codex preserves an inherited `CODEX_PATH`, otherwise its adapter uses the bundled Codex executable.
59
+
60
+ Executable and config paths belong to the engine and apply wherever that engine is used. Set optional `engines[].configPath` to an absolute settings-file path for Claude Code or Codex:
61
+
62
+ ```json
63
+ {
64
+ "engines": [
65
+ { "name": "claude-code", "default": true, "configPath": "/path/to/dotfiles/claude/settings.json" },
66
+ { "name": "codex", "configPath": "/path/to/dotfiles/codex/config.json" }
67
+ ]
68
+ }
69
+ ```
70
+
71
+ Keep any existing `path` overrides when adding `configPath`. No shell export or copy to the agent's home directory is required for these files.
72
+
73
+ - **Claude Code:** passes the file through the SDK's `settings` option (`--settings`). User, project, and local settings sources remain enabled. Explicit settings override conflicting scalar values in those sources; most lists merge under Claude's native rules. Existing user settings and symlinks remain the user's choice.
74
+ - **Codex:** reads the JSON object at each adapter launch and supplies it as `CODEX_CONFIG`, adding ClearClaw's assembled developer instructions on top. An explicit file replaces inherited `CODEX_CONFIG` input; omitting `configPath` preserves environment-based behavior. Missing, malformed, or non-object JSON fails the launch. The adapter passes these values as thread config; native Codex still owns its normal configuration layers and startup plugin catalog.
75
+
76
+ This file carries thread settings only (`model`, `model_providers`, `mcp_servers`, `sandbox_*`, and similar). It cannot carry `marketplaces` or `plugins`: those are Codex CLI installation state, applied long before a thread exists, so declaring them installs nothing and an uninstalled marketplace fails every thread. ClearClaw rejects both keys at load and names the `codex plugin` commands that do install them. Skills need no declaration at all, since Codex reads `~/.agents/skills` and `<cwd>/.agents/skills` natively.
77
+
78
+ Restart the daemon after changing `engines` entries. File contents are read again on subsequent launches. Kiro does not support `configPath`. This does not add named-profile support or change native CLI launches outside ClearClaw.
79
+
80
+ For Slack, setup saves the bot and app tokens; environment-based installs can use
81
+ `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and Slack-prefixed `ALLOWED_USER_IDS`. Private peer-channel spawning
82
+ also requires the `groups:write` bot-token scope; reinstall the app after adding
83
+ the scope. Shared project sidebar sections require a paid Slack plan, the
84
+ `usergroups:read` and `usergroups:write` bot-token scopes, and workspace User
85
+ Group permissions set to allow everyone to manage them. Reinstall the app after
86
+ changing scopes.
87
+
88
+ Telegram peer topics require Topics to be enabled manually on a supergroup and
89
+ the bot to be an admin with Manage Topics. Private-chat topics require Threaded
90
+ Mode to be enabled for the bot in BotFather.
91
+
46
92
  ## Architecture
47
93
 
48
- Two interfaces keep the core thin: **Channels** (Telegram, Slack, ...) handle messaging, **Engines** (Claude Code, Codex, Kiro, ...) handle the agent. The orchestrator routes between them — adding a channel or engine means adding a file. Details in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
94
+ Two interfaces keep the core thin: **Channels** (Telegram, Slack, ...) handle messaging, **Engines** (Claude Code, Codex, Kiro, ...) handle the agent. The orchestrator routes between them — adding a channel or engine means adding a file. Details in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Project lifecycle contracts and design decisions are in [Projects and peer spawning](docs/specs/peer-spawning.md).
49
95
 
50
96
  ## Prior Art
51
97
 
@@ -2,20 +2,31 @@ import { EventEmitter } from "node:events";
2
2
  import type { Channel, Button, ButtonResponse, SendFileOpts, MessageOpts, UserInfo } from "../types.js";
3
3
  export declare class SlackChannel extends EventEmitter implements Channel {
4
4
  name: string;
5
+ statusMaxLength: number;
5
6
  private static readonly EMOJI_TO_SLACK;
6
7
  private app;
7
8
  private botToken;
9
+ private appToken;
8
10
  private botUserId;
9
11
  private isAuthorized;
12
+ private authorizedUserIds;
10
13
  private onUnauthorizedDM?;
11
14
  private pendingButtonCallbacks;
12
15
  private pendingModalCallbacks;
13
16
  private typingMessageTs;
17
+ private typingTimers;
14
18
  private userNameCache;
15
- constructor(botToken: string, appToken: string, isAuthorized: (userId: string) => boolean, onUnauthorizedDM?: (chatId: string, user: UserInfo) => void);
19
+ constructor(botToken: string, appToken: string, isAuthorized: (userId: string) => boolean, onUnauthorizedDM?: (chatId: string, user: UserInfo) => void, authorizedUserIds?: () => string[]);
16
20
  connect(): Promise<void>;
17
21
  disconnect(): Promise<void>;
18
22
  ownsId(chatId: string): boolean;
23
+ /** Slack DM conversation ids start with D; channels start with C or G. */
24
+ isRootDM(chatId: string, userId: string): boolean;
25
+ groupProjectChat(projectName: string, chatId: string): Promise<string | undefined>;
26
+ createProjectChat(_projectName: string, _anchor: string, title: string): Promise<string>;
27
+ closeProjectChat(chatId: string, projectName?: string): Promise<void>;
28
+ /** Apply one lifecycle change to live Slack preferences; never restore a config snapshot. */
29
+ private updateProjectSection;
19
30
  sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
20
31
  sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
21
32
  editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
@@ -23,10 +34,16 @@ export declare class SlackChannel extends EventEmitter implements Channel {
23
34
  pinMessage(chatId: string, handle: string): Promise<void>;
24
35
  unpinAllMessages(chatId: string): Promise<void>;
25
36
  updateStatus(chatId: string, text: string): Promise<void>;
37
+ /**
38
+ * Stop refreshing a placeholder. Every path that drops the ts must call this,
39
+ * or the timer keeps editing a message that is gone.
40
+ */
41
+ private stopTypingHeartbeat;
26
42
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
27
43
  sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
28
44
  reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
29
45
  private slackId;
46
+ private authorizedSlackUsers;
30
47
  /** Download a file from Slack using bot token auth. */
31
48
  private downloadFile;
32
49
  private resolveUserName;
@@ -1,9 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { EventEmitter } from "node:events";
2
3
  import { App, LogLevel } from "@slack/bolt";
3
- import { slackifyMarkdown } from "slackify-markdown";
4
4
  import log from "../logger.js";
5
5
  export class SlackChannel extends EventEmitter {
6
6
  name = "slack";
7
+ statusMaxLength = 250;
7
8
  static EMOJI_TO_SLACK = {
8
9
  "👍": "+1", "👎": "-1", "❤️": "heart", "🔥": "fire",
9
10
  "😂": "joy", "🎉": "tada", "✅": "white_check_mark", "🚀": "rocket",
@@ -12,23 +13,28 @@ export class SlackChannel extends EventEmitter {
12
13
  };
13
14
  app;
14
15
  botToken;
16
+ appToken;
15
17
  botUserId;
16
18
  isAuthorized;
19
+ authorizedUserIds;
17
20
  onUnauthorizedDM;
18
21
  pendingButtonCallbacks = new Map(); // actionId → resolve
19
22
  pendingModalCallbacks = new Map(); // modal callbackId → resolve
20
23
  typingMessageTs = new Map(); // chatId → ts of bot's "typing…" placeholder
24
+ typingTimers = new Map(); // chatId → placeholder refresher
21
25
  userNameCache = new Map();
22
- constructor(botToken, appToken, isAuthorized, onUnauthorizedDM) {
26
+ constructor(botToken, appToken, isAuthorized, onUnauthorizedDM, authorizedUserIds = () => []) {
23
27
  super();
24
28
  this.botToken = botToken;
29
+ this.appToken = appToken;
25
30
  this.isAuthorized = isAuthorized;
31
+ this.authorizedUserIds = authorizedUserIds;
26
32
  this.onUnauthorizedDM = onUnauthorizedDM;
27
- this.app = new App({
28
- token: botToken, appToken, socketMode: true, logLevel: LogLevel.ERROR,
29
- });
30
33
  }
31
34
  async connect() {
35
+ this.app = new App({
36
+ token: this.botToken, appToken: this.appToken, socketMode: true, logLevel: LogLevel.ERROR,
37
+ });
32
38
  this.app.event("message", async ({ event }) => {
33
39
  const subtype = event.subtype;
34
40
  // Suppress topic-change system messages from our setTopic calls
@@ -96,7 +102,7 @@ export class SlackChannel extends EventEmitter {
96
102
  this.emit("message", {
97
103
  chatId,
98
104
  chatType,
99
- user: { id: prefixedId, name: userName ?? userId },
105
+ origin: { kind: "user", user: { id: prefixedId, name: userName ?? userId } },
100
106
  text: msg.text ?? "",
101
107
  messageId: msg.ts,
102
108
  replyTo,
@@ -127,7 +133,7 @@ export class SlackChannel extends EventEmitter {
127
133
  this.emit("message", {
128
134
  chatId,
129
135
  chatType,
130
- user: { id: `slack:${userId}`, name: userName ?? userId },
136
+ origin: { kind: "user", user: { id: `slack:${userId}`, name: userName ?? userId } },
131
137
  text: `/${subcommand}`,
132
138
  });
133
139
  });
@@ -154,6 +160,7 @@ export class SlackChannel extends EventEmitter {
154
160
  async disconnect() {
155
161
  // Delete lingering typing placeholder messages
156
162
  for (const [chatId, ts] of this.typingMessageTs) {
163
+ this.stopTypingHeartbeat(chatId);
157
164
  try {
158
165
  await this.app.client.chat.delete({ channel: this.slackId(chatId), ts });
159
166
  }
@@ -163,6 +170,117 @@ export class SlackChannel extends EventEmitter {
163
170
  await this.app.stop();
164
171
  }
165
172
  ownsId(chatId) { return chatId.startsWith("slack:"); }
173
+ /** Slack DM conversation ids start with D; channels start with C or G. */
174
+ isRootDM(chatId, userId) {
175
+ return /^slack:D[A-Z0-9]+$/.test(chatId) && userId.startsWith("slack:");
176
+ }
177
+ async groupProjectChat(projectName, chatId) {
178
+ return this.updateProjectSection(projectName, chatId, "attach");
179
+ }
180
+ async createProjectChat(_projectName, _anchor, title) {
181
+ const users = this.authorizedSlackUsers();
182
+ if (users.length === 0) {
183
+ throw new Error("Cannot create Slack peer: no authorized Slack users to invite");
184
+ }
185
+ const result = await this.app.client.conversations.create({
186
+ name: slackChannelName(title),
187
+ is_private: true,
188
+ });
189
+ const channel = result.channel?.id;
190
+ if (!channel)
191
+ throw new Error("Slack created a peer channel without returning its ID");
192
+ try {
193
+ await this.app.client.conversations.invite({
194
+ channel,
195
+ users: users.join(","),
196
+ });
197
+ }
198
+ catch (err) {
199
+ await this.app.client.conversations.archive({ channel }).catch((archiveErr) => {
200
+ log.warn({ err: archiveErr }, "[channel] failed to archive Slack peer after invite failure");
201
+ });
202
+ throw err;
203
+ }
204
+ return `slack:${channel}`;
205
+ }
206
+ async closeProjectChat(chatId, projectName) {
207
+ try {
208
+ await this.app.client.conversations.archive({
209
+ channel: this.slackId(chatId),
210
+ });
211
+ }
212
+ catch (err) {
213
+ const code = slackErrorCode(err);
214
+ if (code !== "already_archived" && code !== "channel_not_found")
215
+ throw err;
216
+ }
217
+ if (projectName)
218
+ await this.updateProjectSection(projectName, chatId, "remove");
219
+ }
220
+ /** Apply one lifecycle change to live Slack preferences; never restore a config snapshot. */
221
+ async updateProjectSection(projectName, chatId, action) {
222
+ const channel = this.slackId(chatId);
223
+ // A DM cannot belong to a sidebar section; its Project groups its channel peers instead.
224
+ if (!channel.startsWith("C") && !channel.startsWith("G"))
225
+ return;
226
+ try {
227
+ const result = await this.app.client.apiCall("usergroups.list", {
228
+ include_disabled: true,
229
+ });
230
+ const groups = result.usergroups ?? [];
231
+ const description = slackUserGroupDescription(projectName);
232
+ // The marker survives manual name/handle changes. Without it, leave the group alone.
233
+ const group = groups.find((candidate) => candidate.description === description);
234
+ if (group) {
235
+ if (!group.id || group.date_delete)
236
+ return;
237
+ const current = [...new Set([...(group.prefs?.channels ?? []), ...(group.prefs?.groups ?? [])])];
238
+ const channels = action === "remove"
239
+ ? current.filter((id) => id !== channel)
240
+ : [...new Set([...current, channel])];
241
+ if (channels.length === current.length && channels.every((id, i) => id === current[i]))
242
+ return;
243
+ await this.app.client.apiCall("usergroups.update", {
244
+ usergroup: group.id,
245
+ channels: channels.join(","),
246
+ });
247
+ return;
248
+ }
249
+ // No owned group means this Project was never sectioned — attaching creates it. A group the
250
+ // user deleted is caught by the date_delete branch above, so this never resurrects one.
251
+ if (action === "remove")
252
+ return;
253
+ const users = this.authorizedSlackUsers();
254
+ if (users.length === 0)
255
+ throw new Error("Cannot create Slack project section: no authorized Slack users");
256
+ const baseHandle = slackUserGroupHandle(projectName);
257
+ const handle = groups.some((candidate) => candidate.handle === baseHandle)
258
+ ? slackUserGroupCollisionHandle(projectName)
259
+ : baseHandle;
260
+ if (groups.some((candidate) => candidate.handle === handle)) {
261
+ throw new Error(`Slack User Group handle "${handle}" is owned by another group`);
262
+ }
263
+ const created = await this.app.client.apiCall("usergroups.create", {
264
+ name: projectName,
265
+ handle,
266
+ description,
267
+ channels: channel,
268
+ enable_section: true,
269
+ });
270
+ if (!created.usergroup?.id)
271
+ throw new Error("Slack created a project section without returning its User Group ID");
272
+ await this.app.client.apiCall("usergroups.users.update", {
273
+ usergroup: created.usergroup.id,
274
+ users: users.join(","),
275
+ });
276
+ }
277
+ catch (err) {
278
+ log.warn({ err }, "[channel] failed to %s Slack project section for %s", action, projectName);
279
+ const reason = slackErrorCode(err) ?? (err instanceof Error ? err.message : String(err));
280
+ return `Slack could not ${action} the "${projectName}" sidebar section: ${reason}.`
281
+ + (SECTION_HINTS[reason] ? ` ${SECTION_HINTS[reason]}` : "");
282
+ }
283
+ }
166
284
  async sendMessage(chatId, text, opts) {
167
285
  const channel = this.slackId(chatId);
168
286
  const chunks = splitMessage(text);
@@ -175,7 +293,7 @@ export class SlackChannel extends EventEmitter {
175
293
  const r = await this.app.client.chat.postMessage({ channel, text: chunk, ...extra });
176
294
  return r.ts;
177
295
  }
178
- const blocks = mrkdwnBlocks(chunk);
296
+ const blocks = markdownBlock(chunk);
179
297
  try {
180
298
  const r = await this.app.client.chat.postMessage({ channel, text: chunk, blocks, ...extra });
181
299
  return r.ts;
@@ -190,6 +308,7 @@ export class SlackChannel extends EventEmitter {
190
308
  const consumeTyping = !threadTs && opts?.consumeTyping !== false;
191
309
  const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
192
310
  if (typingTs && chunks.length > 0) {
311
+ this.stopTypingHeartbeat(chatId);
193
312
  this.typingMessageTs.delete(chatId);
194
313
  const firstChunk = chunks.shift();
195
314
  if (plain) {
@@ -204,7 +323,7 @@ export class SlackChannel extends EventEmitter {
204
323
  }
205
324
  }
206
325
  else {
207
- const blocks = mrkdwnBlocks(firstChunk);
326
+ const blocks = markdownBlock(firstChunk);
208
327
  try {
209
328
  await this.app.client.chat.update({
210
329
  channel, ts: typingTs, text: firstChunk, blocks,
@@ -237,7 +356,7 @@ export class SlackChannel extends EventEmitter {
237
356
  }
238
357
  }
239
358
  const blocks = [
240
- mrkdwnBlocks(splitMessage(text)[0])[0],
359
+ markdownBlock(splitMessage(text)[0])[0],
241
360
  {
242
361
  type: "actions",
243
362
  elements: actionEntries.map(({ btn, actionId }) => ({
@@ -342,7 +461,7 @@ export class SlackChannel extends EventEmitter {
342
461
  channel: this.slackId(chatId),
343
462
  ts: handle,
344
463
  text: chunk,
345
- blocks: mrkdwnBlocks(chunk),
464
+ blocks: markdownBlock(chunk),
346
465
  });
347
466
  }
348
467
  }
@@ -380,6 +499,17 @@ export class SlackChannel extends EventEmitter {
380
499
  topic: text,
381
500
  });
382
501
  }
502
+ /**
503
+ * Stop refreshing a placeholder. Every path that drops the ts must call this,
504
+ * or the timer keeps editing a message that is gone.
505
+ */
506
+ stopTypingHeartbeat(chatId) {
507
+ const timer = this.typingTimers.get(chatId);
508
+ if (timer) {
509
+ clearInterval(timer);
510
+ this.typingTimers.delete(chatId);
511
+ }
512
+ }
383
513
  async setTyping(chatId, isTyping) {
384
514
  const channel = this.slackId(chatId);
385
515
  if (isTyping) {
@@ -387,15 +517,29 @@ export class SlackChannel extends EventEmitter {
387
517
  return;
388
518
  try {
389
519
  const result = await this.app.client.chat.postMessage({
390
- channel, text: "_typing…_",
520
+ channel, text: typingText(0),
391
521
  });
392
- if (result.ts)
393
- this.typingMessageTs.set(chatId, result.ts);
522
+ if (!result.ts)
523
+ return;
524
+ const ts = result.ts;
525
+ this.typingMessageTs.set(chatId, ts);
526
+ // Slack has no typing indicator for bots, so this placeholder stands in for one.
527
+ // Telegram's real indicator expires unless refreshed, which makes a dead process
528
+ // visibly stop typing; a posted message has no such property. Refreshing it with a
529
+ // running clock restores that signal: if the clock stops, so did the engine.
530
+ const startedAt = Date.now();
531
+ const timer = setInterval(() => {
532
+ this.app.client.chat.update({
533
+ channel, ts, text: typingText(Date.now() - startedAt),
534
+ }).catch(() => { });
535
+ }, TYPING_REFRESH_MS);
536
+ this.typingTimers.set(chatId, timer);
394
537
  }
395
538
  catch { /* best-effort */ }
396
539
  }
397
540
  else {
398
541
  // Delete placeholder if it wasn't consumed by sendMessage
542
+ this.stopTypingHeartbeat(chatId);
399
543
  const ts = this.typingMessageTs.get(chatId);
400
544
  if (ts) {
401
545
  this.typingMessageTs.delete(chatId);
@@ -435,6 +579,12 @@ export class SlackChannel extends EventEmitter {
435
579
  slackId(chatId) {
436
580
  return chatId.replace(/^slack:/, "");
437
581
  }
582
+ authorizedSlackUsers() {
583
+ return [...new Set(this.authorizedUserIds()
584
+ .filter((id) => id.startsWith("slack:"))
585
+ .map((id) => id.slice("slack:".length))
586
+ .filter(Boolean))];
587
+ }
438
588
  /** Download a file from Slack using bot token auth. */
439
589
  async downloadFile(url) {
440
590
  const resp = await fetch(url, {
@@ -487,20 +637,70 @@ export class SlackChannel extends EventEmitter {
487
637
  }
488
638
  }
489
639
  }
490
- function mrkdwnBlocks(text) {
491
- let rendered;
492
- try {
493
- rendered = slackifyMarkdown(text);
494
- }
495
- catch (err) {
496
- log.warn({ err }, "[channel] slackifyMarkdown failed, sending raw text");
497
- rendered = text;
498
- }
499
- return [{ type: "section", text: { type: "mrkdwn", text: rendered } }];
640
+ // Slack's markdown block renders standard markdown (headers, lists, tables) natively:
641
+ // Slack owns the translation, so we hand it the raw markdown with no pre-conversion.
642
+ function markdownBlock(text) {
643
+ return [{ type: "markdown", text }];
644
+ }
645
+ // The markdown block caps at 12,000 chars. We split at this limit so each chunk
646
+ // fits the block; the text fallback (notification preview) can safely be longer.
647
+ const MAX_TEXT = 12000;
648
+ function slackChannelName(title) {
649
+ const name = title
650
+ .normalize("NFKD")
651
+ .replace(/[\u0300-\u036f]/g, "")
652
+ .toLowerCase()
653
+ .replace(/[^a-z0-9_-]+/g, "-")
654
+ .replace(/^[-_]+|[-_]+$/g, "")
655
+ .slice(0, 80)
656
+ .replace(/[-_]+$/g, "");
657
+ if (!name)
658
+ throw new Error(`Cannot derive a valid Slack channel name from "${title}"`);
659
+ return name;
660
+ }
661
+ const TYPING_REFRESH_MS = 5000;
662
+ /** Elapsed time makes the placeholder a liveness signal: a stopped clock means a stopped engine. */
663
+ function typingText(elapsedMs) {
664
+ const seconds = Math.floor(elapsedMs / 1000);
665
+ if (seconds < 1)
666
+ return "_typing…_";
667
+ const label = seconds < 60
668
+ ? `${seconds}s`
669
+ : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
670
+ return `_typing… ${label}_`;
671
+ }
672
+ function slackUserGroupHandle(projectName) {
673
+ const slug = slackChannelName(projectName)
674
+ .slice(0, 77)
675
+ .replace(/[-_]+$/g, "");
676
+ return `cc-${slug}`;
677
+ }
678
+ function slackUserGroupCollisionHandle(projectName) {
679
+ const hash = createHash("sha256").update(projectName).digest("hex").slice(0, 6);
680
+ const slug = slackChannelName(projectName)
681
+ .slice(0, 70)
682
+ .replace(/[-_]+$/g, "");
683
+ return `cc-${slug}-${hash}`;
684
+ }
685
+ function slackUserGroupDescription(projectName) {
686
+ const hash = createHash("sha256").update(projectName).digest("hex").slice(0, 16);
687
+ return `Managed by ClearClaw (${hash})`;
688
+ }
689
+ // Sidebar sections fail for reasons no code change can fix, so name the fix instead of the code.
690
+ const SECTION_HINTS = {
691
+ paid_teams_only: "Slack User Groups need a paid plan.",
692
+ missing_scope: "Grant the app the usergroups:read and usergroups:write scopes and reinstall it.",
693
+ permission_denied: "Allow the app to manage User Groups in Workspace settings → Permissions.",
694
+ };
695
+ function slackErrorCode(err) {
696
+ if (typeof err !== "object" || err === null || !("data" in err))
697
+ return undefined;
698
+ const data = err.data;
699
+ if (typeof data !== "object" || data === null || !("error" in data))
700
+ return undefined;
701
+ const code = data.error;
702
+ return typeof code === "string" ? code : undefined;
500
703
  }
501
- // Block Kit section.text has a 3000 char limit — the tightest Slack constraint.
502
- // We split at this limit so each chunk fits both the text fallback and the block.
503
- const MAX_TEXT = 3000;
504
704
  function splitMessage(text) {
505
705
  if (text.length <= MAX_TEXT)
506
706
  return [text];
@@ -10,15 +10,28 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
10
10
  private statusHandles;
11
11
  private pendingCallbacks;
12
12
  private pendingTextResolvers;
13
+ private botUsername?;
13
14
  constructor(botToken: string, isAuthorized: (userId: string) => boolean, onUnauthorizedDM?: (chatId: string, user: UserInfo) => void);
15
+ /**
16
+ * Strip the `@<botUsername>` suffix Telegram appends to commands in group
17
+ * chats (`/context@mybot args` → `/context args`), so downstream command
18
+ * handling — ClearClaw's own `/new` etc. and slash-command pass-through —
19
+ * sees the bare `/command`. No-op for non-command text and in DMs (no suffix).
20
+ * Telegram usernames are `[A-Za-z0-9_]`, so they need no regex escaping.
21
+ */
22
+ private stripCommandMention;
23
+ private telegramCall;
14
24
  connect(): Promise<void>;
15
25
  disconnect(): Promise<void>;
16
26
  ownsId(chatId: string): boolean;
27
+ /** A private chat is the user's own id; a ":thread" suffix means a topic inside it. */
28
+ isRootDM(chatId: string, userId: string): boolean;
17
29
  sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
18
30
  sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
19
31
  editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
20
32
  deleteMessage(chatId: string, handle: string): Promise<void>;
21
33
  pinMessage(chatId: string, handle: string): Promise<void>;
34
+ /** Unpin everything in this chat's scope: the forum topic for a topic chat, else the whole chat. */
22
35
  unpinAllMessages(chatId: string): Promise<void>;
23
36
  updateStatus(chatId: string, text: string): Promise<void>;
24
37
  setTyping(chatId: string, isTyping: boolean): Promise<void>;
@@ -32,7 +45,13 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
32
45
  private handleFileMessage;
33
46
  sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
34
47
  reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
48
+ createProjectChat(projectName: string, anchor: string, title: string): Promise<string>;
49
+ closeProjectChat(chatId: string): Promise<void>;
50
+ private validateProjectChat;
35
51
  /** Download a Telegram file by file_id, returning the raw buffer. */
36
52
  private downloadFile;
53
+ /** "tg:123" → 123; "tg:123:45" (forum topic) → 123 */
37
54
  private numericId;
55
+ /** Topic part of a composite chat id, as spreadable send options. */
56
+ private threadOpts;
38
57
  }