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.
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
 
@@ -1,32 +1,49 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { Channel, Button, ButtonResponse, SendFileOpts, SendMessageOpts, UserInfo } from "../types.js";
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;
19
- sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
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;
30
+ sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
20
31
  sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
21
- editMessage(chatId: string, handle: string, text: string): Promise<void>;
32
+ editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
22
33
  deleteMessage(chatId: string, handle: string): Promise<void>;
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,14 +170,130 @@ 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);
169
287
  const handles = [];
170
288
  const threadTs = opts?.replyToMessageId;
171
- /** Post a chunk with mrkdwn blocks, falling back to plain text on failure. */
289
+ const plain = opts?.format === "plain";
290
+ /** Post a chunk, with mrkdwn blocks unless plain format requested. */
172
291
  const postChunk = async (chunk, extra = {}) => {
173
- const blocks = mrkdwnBlocks(chunk);
292
+ if (plain) {
293
+ const r = await this.app.client.chat.postMessage({ channel, text: chunk, ...extra });
294
+ return r.ts;
295
+ }
296
+ const blocks = markdownBlock(chunk);
174
297
  try {
175
298
  const r = await this.app.client.chat.postMessage({ channel, text: chunk, blocks, ...extra });
176
299
  return r.ts;
@@ -185,20 +308,33 @@ export class SlackChannel extends EventEmitter {
185
308
  const consumeTyping = !threadTs && opts?.consumeTyping !== false;
186
309
  const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
187
310
  if (typingTs && chunks.length > 0) {
311
+ this.stopTypingHeartbeat(chatId);
188
312
  this.typingMessageTs.delete(chatId);
189
313
  const firstChunk = chunks.shift();
190
- const blocks = mrkdwnBlocks(firstChunk);
191
- try {
192
- await this.app.client.chat.update({
193
- channel, ts: typingTs, text: firstChunk, blocks,
194
- });
195
- handles.push(typingTs);
314
+ if (plain) {
315
+ try {
316
+ await this.app.client.chat.update({ channel, ts: typingTs, text: firstChunk });
317
+ handles.push(typingTs);
318
+ }
319
+ catch {
320
+ const ts = await postChunk(firstChunk);
321
+ if (ts)
322
+ handles.push(ts);
323
+ }
196
324
  }
197
- catch {
198
- // Placeholder was deleted externally or mrkdwn invalid — post instead
199
- const ts = await postChunk(firstChunk);
200
- if (ts)
201
- handles.push(ts);
325
+ else {
326
+ const blocks = markdownBlock(firstChunk);
327
+ try {
328
+ await this.app.client.chat.update({
329
+ channel, ts: typingTs, text: firstChunk, blocks,
330
+ });
331
+ handles.push(typingTs);
332
+ }
333
+ catch {
334
+ const ts = await postChunk(firstChunk);
335
+ if (ts)
336
+ handles.push(ts);
337
+ }
202
338
  }
203
339
  }
204
340
  for (const chunk of chunks) {
@@ -220,7 +356,7 @@ export class SlackChannel extends EventEmitter {
220
356
  }
221
357
  }
222
358
  const blocks = [
223
- mrkdwnBlocks(splitMessage(text)[0])[0],
359
+ markdownBlock(splitMessage(text)[0])[0],
224
360
  {
225
361
  type: "actions",
226
362
  elements: actionEntries.map(({ btn, actionId }) => ({
@@ -311,14 +447,23 @@ export class SlackChannel extends EventEmitter {
311
447
  }
312
448
  return { value: pressed.value };
313
449
  }
314
- async editMessage(chatId, handle, text) {
450
+ async editMessage(chatId, handle, text, opts) {
315
451
  const chunk = splitMessage(text)[0];
316
- await this.app.client.chat.update({
317
- channel: this.slackId(chatId),
318
- ts: handle,
319
- text: chunk,
320
- blocks: mrkdwnBlocks(chunk),
321
- });
452
+ if (opts?.format === "plain") {
453
+ await this.app.client.chat.update({
454
+ channel: this.slackId(chatId),
455
+ ts: handle,
456
+ text: chunk,
457
+ });
458
+ }
459
+ else {
460
+ await this.app.client.chat.update({
461
+ channel: this.slackId(chatId),
462
+ ts: handle,
463
+ text: chunk,
464
+ blocks: markdownBlock(chunk),
465
+ });
466
+ }
322
467
  }
323
468
  async deleteMessage(chatId, handle) {
324
469
  await this.app.client.chat.delete({
@@ -354,6 +499,17 @@ export class SlackChannel extends EventEmitter {
354
499
  topic: text,
355
500
  });
356
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
+ }
357
513
  async setTyping(chatId, isTyping) {
358
514
  const channel = this.slackId(chatId);
359
515
  if (isTyping) {
@@ -361,15 +517,29 @@ export class SlackChannel extends EventEmitter {
361
517
  return;
362
518
  try {
363
519
  const result = await this.app.client.chat.postMessage({
364
- channel, text: "_typing…_",
520
+ channel, text: typingText(0),
365
521
  });
366
- if (result.ts)
367
- 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);
368
537
  }
369
538
  catch { /* best-effort */ }
370
539
  }
371
540
  else {
372
541
  // Delete placeholder if it wasn't consumed by sendMessage
542
+ this.stopTypingHeartbeat(chatId);
373
543
  const ts = this.typingMessageTs.get(chatId);
374
544
  if (ts) {
375
545
  this.typingMessageTs.delete(chatId);
@@ -409,6 +579,12 @@ export class SlackChannel extends EventEmitter {
409
579
  slackId(chatId) {
410
580
  return chatId.replace(/^slack:/, "");
411
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
+ }
412
588
  /** Download a file from Slack using bot token auth. */
413
589
  async downloadFile(url) {
414
590
  const resp = await fetch(url, {
@@ -461,20 +637,70 @@ export class SlackChannel extends EventEmitter {
461
637
  }
462
638
  }
463
639
  }
464
- function mrkdwnBlocks(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 } }];
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;
474
703
  }
475
- // Block Kit section.text has a 3000 char limit — the tightest Slack constraint.
476
- // We split at this limit so each chunk fits both the text fallback and the block.
477
- const MAX_TEXT = 3000;
478
704
  function splitMessage(text) {
479
705
  if (text.length <= MAX_TEXT)
480
706
  return [text];
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { Channel, Button, ButtonResponse, SendFileOpts, SendMessageOpts, UserInfo } from "../types.js";
2
+ import type { Channel, Button, ButtonResponse, SendFileOpts, MessageOpts, UserInfo } from "../types.js";
3
3
  export declare class TelegramChannel extends EventEmitter implements Channel {
4
4
  name: string;
5
5
  private bot;
@@ -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;
17
- sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
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;
29
+ sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
18
30
  sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
19
- editMessage(chatId: string, handle: string, text: string): Promise<void>;
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
  }