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 +53 -7
- package/dist/channel/slack.d.ts +18 -1
- package/dist/channel/slack.js +227 -27
- package/dist/channel/telegram.d.ts +19 -0
- package/dist/channel/telegram.js +136 -30
- package/dist/config.d.ts +37 -1
- package/dist/config.js +118 -0
- package/dist/engine/acp.d.ts +9 -4
- package/dist/engine/acp.js +301 -29
- package/dist/engine/claude-code.d.ts +19 -2
- package/dist/engine/claude-code.js +121 -33
- package/dist/engine/mcp-http.d.ts +9 -0
- package/dist/engine/mcp-http.js +59 -0
- package/dist/engine/registry.d.ts +10 -7
- package/dist/engine/registry.js +99 -16
- package/dist/engine/session-transcript.d.ts +14 -0
- package/dist/engine/session-transcript.js +38 -0
- package/dist/format.d.ts +1 -1
- package/dist/format.js +42 -7
- package/dist/index.js +18 -23
- package/dist/orchestrator.d.ts +31 -4
- package/dist/orchestrator.js +822 -167
- package/dist/plan-usage.d.ts +6 -0
- package/dist/plan-usage.js +31 -0
- package/dist/prompt.d.ts +6 -2
- package/dist/prompt.js +62 -19
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +82 -0
- package/dist/types.d.ts +77 -5
- package/package.json +11 -4
- package/patches/README.md +13 -0
- package/patches/codex-acp-1.11.0.patch +38 -0
- package/prompts/SYSTEM.md +66 -16
- package/scripts/patch-codex-acp.mjs +32 -0
- package/prompts/ONBOARDING.md +0 -56
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
|
@@ -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;
|
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,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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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:
|
|
520
|
+
channel, text: typingText(0),
|
|
391
521
|
});
|
|
392
|
-
if (result.ts)
|
|
393
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
}
|