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 +53 -7
- package/dist/channel/slack.d.ts +21 -4
- package/dist/channel/slack.js +270 -44
- package/dist/channel/telegram.d.ts +22 -3
- package/dist/channel/telegram.js +159 -35
- package/dist/config.d.ts +49 -5
- package/dist/config.js +159 -33
- package/dist/engine/acp.d.ts +14 -0
- package/dist/engine/acp.js +519 -0
- package/dist/engine/async-queue.d.ts +14 -0
- package/dist/engine/async-queue.js +45 -0
- package/dist/engine/claude-code.d.ts +20 -1
- package/dist/engine/claude-code.js +174 -44
- package/dist/engine/mcp-http.d.ts +9 -0
- package/dist/engine/mcp-http.js +59 -0
- package/dist/engine/registry.d.ts +19 -0
- package/dist/engine/registry.js +115 -0
- package/dist/engine/session-transcript.d.ts +14 -0
- package/dist/engine/session-transcript.js +38 -0
- package/dist/format.d.ts +8 -10
- package/dist/format.js +106 -82
- package/dist/index.js +33 -9
- package/dist/orchestrator.d.ts +34 -8
- package/dist/orchestrator.js +866 -153
- package/dist/plan-usage.d.ts +6 -0
- package/dist/plan-usage.js +31 -0
- package/dist/prompt.d.ts +10 -0
- package/dist/prompt.js +78 -0
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +82 -0
- package/dist/tool-handlers.d.ts +1 -1
- package/dist/tool-handlers.js +2 -2
- package/dist/types.d.ts +128 -17
- package/dist/types.js +12 -1
- package/package.json +13 -4
- package/patches/README.md +13 -0
- package/patches/codex-acp-1.11.0.patch +38 -0
- package/prompts/SYSTEM.md +214 -0
- package/scripts/patch-codex-acp.mjs +32 -0
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
npx clearclaw
|
|
36
|
+
npm install -g clearclaw
|
|
37
|
+
clearclaw setup
|
|
38
|
+
clearclaw daemon
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
|
|
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
|
|
package/dist/channel/slack.d.ts
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type { Channel, Button, ButtonResponse, SendFileOpts,
|
|
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
|
-
|
|
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;
|
package/dist/channel/slack.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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:
|
|
520
|
+
channel, text: typingText(0),
|
|
365
521
|
});
|
|
366
|
-
if (result.ts)
|
|
367
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|