clearclaw 0.2.0 → 0.4.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/channel/slack.d.ts +29 -0
- package/dist/channel/slack.js +402 -0
- package/dist/channel/telegram.d.ts +15 -2
- package/dist/channel/telegram.js +223 -33
- package/dist/config.d.ts +13 -3
- package/dist/config.js +18 -4
- package/dist/engine/claude-code.d.ts +2 -1
- package/dist/engine/claude-code.js +111 -12
- package/dist/files.d.ts +3 -0
- package/dist/files.js +33 -0
- package/dist/format.d.ts +18 -9
- package/dist/format.js +86 -49
- package/dist/index.js +10 -2
- package/dist/orchestrator.d.ts +3 -0
- package/dist/orchestrator.js +205 -47
- package/dist/types.d.ts +33 -2
- package/package.json +22 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alleriasun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ClearClaw
|
|
2
|
+
|
|
3
|
+
Your coding agent, from your phone — personal assistant + project workspaces on your machine, built on the CLI's permissions, config, and memory instead of replacing them.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
You want to talk to your coding agent from your phone. Not a chatbot — the actual agent on your actual machine, with your files, your tools, your everything.
|
|
8
|
+
|
|
9
|
+
Most projects in this space add their own permission system, config format, plugin model. You end up managing two systems: the CLI you already configured, and the middleware on top.
|
|
10
|
+
|
|
11
|
+
ClearClaw reuses what the CLI already has:
|
|
12
|
+
|
|
13
|
+
- **No duplicate permissions.** The CLI has `settings.json` with allow/deny rules. ClearClaw relays prompts as buttons — the CLI's rules apply as-is.
|
|
14
|
+
- **No duplicate config.** Your settings, MCP servers, tool allowlists — all loaded automatically. Nothing to re-configure.
|
|
15
|
+
- **No duplicate memory.** `CLAUDE.md` lives in your project. The CLI loads it. ClearClaw doesn't touch it.
|
|
16
|
+
- **Small and auditable.** ~1500 lines, 7 files. No plugin system, no eval, no embedded runtime.
|
|
17
|
+
|
|
18
|
+
## What It Does
|
|
19
|
+
|
|
20
|
+
- Routes messages between your phone and your coding agent (Telegram today, Slack planned)
|
|
21
|
+
- Permission prompts with diffs, tool status, and feedback — like the terminal, on your phone
|
|
22
|
+
- Maps chat groups to project workspaces — each group = a working directory
|
|
23
|
+
- Personal assistant workspace for non-project conversations
|
|
24
|
+
- Session continuity between terminal and phone
|
|
25
|
+
|
|
26
|
+
## What It Doesn't Do
|
|
27
|
+
|
|
28
|
+
- Own permissions, config, memory, or system prompts — the CLI owns those
|
|
29
|
+
- Run inference or embed an agent runtime
|
|
30
|
+
- Require containers, VMs, or cloud services
|
|
31
|
+
- Provide a plugin or extension system
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export TELEGRAM_BOT_TOKEN="your-bot-token"
|
|
37
|
+
export ALLOWED_USER_IDS="tg:your-telegram-id"
|
|
38
|
+
|
|
39
|
+
npx clearclaw
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or install globally: `npm install -g clearclaw`
|
|
43
|
+
|
|
44
|
+
Optional: `PERMISSION_MODE` (`default` | `acceptEdits` | `bypassPermissions` | `plan`), `CLEARCLAW_HOME` (default `~/.clearclaw`).
|
|
45
|
+
|
|
46
|
+
## Architecture
|
|
47
|
+
|
|
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).
|
|
49
|
+
|
|
50
|
+
## Prior Art
|
|
51
|
+
|
|
52
|
+
| Project | Description |
|
|
53
|
+
|---------|-------------|
|
|
54
|
+
| [Claude Code Remote Control](https://docs.anthropic.com/en/docs/claude-code/remote-control) | Anthropic's first-party `/rc`. Max-only, single session, no daemon. |
|
|
55
|
+
| [OpenClaw](https://github.com/openclaw/openclaw) | Full monolith with plugin system. [Security concerns](https://github.com/openclaw/openclaw/security). |
|
|
56
|
+
| [NanoClaw](https://github.com/qwibitai/nanoclaw) | Container-isolated, multi-channel. |
|
|
57
|
+
| [remotecode](https://github.com/kcisoul/remotecode) | Telegram relay with HITL permissions. |
|
|
58
|
+
| [Claude-Code-Remote](https://github.com/JessyTsui/Claude-Code-Remote) | Multi-channel: Telegram, Discord, Email, LINE. |
|
|
59
|
+
| [claude-telegram-relay](https://github.com/godagoo/claude-telegram-relay) | Minimal Telegram daemon. |
|
|
60
|
+
| [Happy Coder](https://happy.engineering/) | Native apps, E2EE, multi-session. Polished, closed-source. |
|
|
61
|
+
| [Paseo](https://paseo.sh/) | Agent-agnostic daemon (Claude Code, Codex, OpenCode). E2EE. |
|
|
62
|
+
| [yottoCode](https://yottocode.com/) | Native macOS + Telegram, voice I/O. |
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.js";
|
|
3
|
+
export declare class SlackChannel extends EventEmitter implements Channel {
|
|
4
|
+
name: string;
|
|
5
|
+
private app;
|
|
6
|
+
private botToken;
|
|
7
|
+
private botUserId;
|
|
8
|
+
private allowedUserIds;
|
|
9
|
+
private pendingButtonCallbacks;
|
|
10
|
+
private pendingModalCallbacks;
|
|
11
|
+
private typingMessageTs;
|
|
12
|
+
private userNameCache;
|
|
13
|
+
constructor(botToken: string, appToken: string, allowedUserIds: Set<string>);
|
|
14
|
+
connect(): Promise<void>;
|
|
15
|
+
disconnect(): Promise<void>;
|
|
16
|
+
ownsId(chatId: string): boolean;
|
|
17
|
+
sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
|
|
18
|
+
sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
|
|
19
|
+
editMessage(chatId: string, handle: string, text: string): Promise<void>;
|
|
20
|
+
deleteMessage(chatId: string, handle: string): Promise<void>;
|
|
21
|
+
pinMessage(chatId: string, handle: string): Promise<void>;
|
|
22
|
+
unpinAllMessages(chatId: string): Promise<void>;
|
|
23
|
+
updateStatus(chatId: string, text: string): Promise<void>;
|
|
24
|
+
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
25
|
+
private slackId;
|
|
26
|
+
/** Download a file from Slack using bot token auth. */
|
|
27
|
+
private downloadFile;
|
|
28
|
+
private resolveUserName;
|
|
29
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { App, LogLevel } from "@slack/bolt";
|
|
3
|
+
import log from "../logger.js";
|
|
4
|
+
export class SlackChannel extends EventEmitter {
|
|
5
|
+
name = "slack";
|
|
6
|
+
app;
|
|
7
|
+
botToken;
|
|
8
|
+
botUserId;
|
|
9
|
+
allowedUserIds;
|
|
10
|
+
pendingButtonCallbacks = new Map(); // actionId → resolve
|
|
11
|
+
pendingModalCallbacks = new Map(); // modal callbackId → resolve
|
|
12
|
+
typingMessageTs = new Map(); // chatId → ts of bot's "typing…" placeholder
|
|
13
|
+
userNameCache = new Map();
|
|
14
|
+
constructor(botToken, appToken, allowedUserIds) {
|
|
15
|
+
super();
|
|
16
|
+
this.botToken = botToken;
|
|
17
|
+
this.allowedUserIds = allowedUserIds;
|
|
18
|
+
this.app = new App({
|
|
19
|
+
token: botToken, appToken, socketMode: true, logLevel: LogLevel.ERROR,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async connect() {
|
|
23
|
+
this.app.event("message", async ({ event }) => {
|
|
24
|
+
const subtype = event.subtype;
|
|
25
|
+
// Suppress topic-change system messages from our setTopic calls
|
|
26
|
+
if (subtype === "channel_topic" || subtype === "group_topic") {
|
|
27
|
+
const ts = event.ts;
|
|
28
|
+
try {
|
|
29
|
+
await this.app.client.chat.delete({ channel: event.channel, ts });
|
|
30
|
+
}
|
|
31
|
+
catch { /* may lack permission */ }
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (subtype && subtype !== "bot_message" && subtype !== "file_share")
|
|
35
|
+
return;
|
|
36
|
+
// Bolt's message event is a union of many subtypes with different shapes;
|
|
37
|
+
// inline type is simpler than importing and narrowing the full union.
|
|
38
|
+
const msg = event;
|
|
39
|
+
const chatId = `slack:${msg.channel}`;
|
|
40
|
+
const isBotMessage = !!msg.bot_id || msg.user === this.botUserId;
|
|
41
|
+
if (isBotMessage)
|
|
42
|
+
return;
|
|
43
|
+
// Need either text or files to proceed
|
|
44
|
+
if (!msg.text && (!msg.files || msg.files.length === 0))
|
|
45
|
+
return;
|
|
46
|
+
const userId = msg.user;
|
|
47
|
+
if (!userId || !this.allowedUserIds.has(`slack:${userId}`)) {
|
|
48
|
+
if (userId)
|
|
49
|
+
log.info("[channel] ignored message from unauthorized user slack:%s", userId);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Download attached files in parallel
|
|
53
|
+
let attachments;
|
|
54
|
+
if (msg.files?.length) {
|
|
55
|
+
const downloadable = msg.files.filter((f) => f.url_private_download);
|
|
56
|
+
if (downloadable.length > 0) {
|
|
57
|
+
const results = await Promise.allSettled(downloadable.map(async (file) => {
|
|
58
|
+
const buffer = await this.downloadFile(file.url_private_download);
|
|
59
|
+
return { buffer, mimeType: file.mimetype ?? "application/octet-stream", filename: file.name };
|
|
60
|
+
}));
|
|
61
|
+
const succeeded = results
|
|
62
|
+
.filter((r) => r.status === "fulfilled")
|
|
63
|
+
.map((r) => r.value);
|
|
64
|
+
for (const r of results) {
|
|
65
|
+
if (r.status === "rejected") {
|
|
66
|
+
log.warn({ err: r.reason }, "[channel] failed to download Slack file");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (succeeded.length > 0)
|
|
70
|
+
attachments = succeeded;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const userName = await this.resolveUserName(userId);
|
|
74
|
+
this.emit("message", {
|
|
75
|
+
chatId,
|
|
76
|
+
user: { id: `slack:${userId}`, name: userName ?? userId },
|
|
77
|
+
text: msg.text ?? "",
|
|
78
|
+
...(attachments && { attachments }),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
// Handle button presses from sendInteractive
|
|
82
|
+
this.app.action(/^ccb:/, async ({ action, body, ack }) => {
|
|
83
|
+
await ack();
|
|
84
|
+
const actionId = action.action_id;
|
|
85
|
+
const triggerId = body.trigger_id ?? "";
|
|
86
|
+
const resolve = this.pendingButtonCallbacks.get(actionId);
|
|
87
|
+
if (resolve)
|
|
88
|
+
resolve(triggerId);
|
|
89
|
+
});
|
|
90
|
+
// Handle /cc slash command — emit as /<subcommand> so orchestrator handles it
|
|
91
|
+
this.app.command("/cc", async ({ command, ack }) => {
|
|
92
|
+
await ack();
|
|
93
|
+
const subcommand = command.text.trim();
|
|
94
|
+
if (!subcommand)
|
|
95
|
+
return;
|
|
96
|
+
const chatId = `slack:${command.channel_id}`;
|
|
97
|
+
const userId = command.user_id;
|
|
98
|
+
if (!this.allowedUserIds.has(`slack:${userId}`))
|
|
99
|
+
return;
|
|
100
|
+
const userName = await this.resolveUserName(userId);
|
|
101
|
+
this.emit("message", {
|
|
102
|
+
chatId,
|
|
103
|
+
user: { id: `slack:${userId}`, name: userName ?? userId },
|
|
104
|
+
text: `/${subcommand}`,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// Handle modal submissions from "Deny + Note" feedback
|
|
108
|
+
this.app.view(/^ccv:/, async ({ view, ack }) => {
|
|
109
|
+
await ack();
|
|
110
|
+
const feedbackValue = view.state?.values?.feedback_block?.feedback_input?.value ?? "";
|
|
111
|
+
const resolve = this.pendingModalCallbacks.get(view.callback_id);
|
|
112
|
+
if (resolve) {
|
|
113
|
+
this.pendingModalCallbacks.delete(view.callback_id);
|
|
114
|
+
resolve(feedbackValue);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
await this.app.start();
|
|
118
|
+
try {
|
|
119
|
+
const auth = await this.app.client.auth.test();
|
|
120
|
+
this.botUserId = auth.user_id;
|
|
121
|
+
log.info("[channel] Slack bot: %s", this.botUserId);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
log.warn("[channel] Connected to Slack but failed to get bot user ID");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async disconnect() {
|
|
128
|
+
// Delete lingering typing placeholder messages
|
|
129
|
+
for (const [chatId, ts] of this.typingMessageTs) {
|
|
130
|
+
try {
|
|
131
|
+
await this.app.client.chat.delete({ channel: this.slackId(chatId), ts });
|
|
132
|
+
}
|
|
133
|
+
catch { /* already deleted or no permission */ }
|
|
134
|
+
}
|
|
135
|
+
this.typingMessageTs.clear();
|
|
136
|
+
await this.app.stop();
|
|
137
|
+
}
|
|
138
|
+
ownsId(chatId) { return chatId.startsWith("slack:"); }
|
|
139
|
+
async sendMessage(chatId, text, opts) {
|
|
140
|
+
const channel = this.slackId(chatId);
|
|
141
|
+
const chunks = splitMessage(text);
|
|
142
|
+
const handles = [];
|
|
143
|
+
// If a typing placeholder exists, edit the first chunk into it instead of posting new
|
|
144
|
+
const consumeTyping = opts?.consumeTyping !== false;
|
|
145
|
+
const typingTs = consumeTyping ? this.typingMessageTs.get(chatId) : undefined;
|
|
146
|
+
if (typingTs && chunks.length > 0) {
|
|
147
|
+
this.typingMessageTs.delete(chatId);
|
|
148
|
+
const firstChunk = chunks.shift();
|
|
149
|
+
const blocks = mrkdwnBlocks(firstChunk);
|
|
150
|
+
try {
|
|
151
|
+
await this.app.client.chat.update({
|
|
152
|
+
channel, ts: typingTs, text: firstChunk, blocks,
|
|
153
|
+
});
|
|
154
|
+
handles.push(typingTs);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Placeholder was deleted externally — fall back to posting
|
|
158
|
+
const result = await this.app.client.chat.postMessage({
|
|
159
|
+
channel, text: firstChunk, blocks,
|
|
160
|
+
});
|
|
161
|
+
if (result.ts)
|
|
162
|
+
handles.push(result.ts);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const chunk of chunks) {
|
|
166
|
+
const blocks = mrkdwnBlocks(chunk);
|
|
167
|
+
const result = await this.app.client.chat.postMessage({
|
|
168
|
+
channel, text: chunk, blocks,
|
|
169
|
+
});
|
|
170
|
+
if (result.ts)
|
|
171
|
+
handles.push(result.ts);
|
|
172
|
+
}
|
|
173
|
+
return handles;
|
|
174
|
+
}
|
|
175
|
+
async sendInteractive(chatId, text, buttons) {
|
|
176
|
+
const channel = this.slackId(chatId);
|
|
177
|
+
const callbackId = crypto.randomUUID().slice(0, 8);
|
|
178
|
+
// Pre-compute action IDs mapped to buttons
|
|
179
|
+
const actionEntries = [];
|
|
180
|
+
let idx = 0;
|
|
181
|
+
for (const row of buttons) {
|
|
182
|
+
for (const btn of row) {
|
|
183
|
+
actionEntries.push({ btn, actionId: `ccb:${callbackId}:${idx++}` });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const blocks = [
|
|
187
|
+
mrkdwnBlocks(splitMessage(text)[0])[0],
|
|
188
|
+
{
|
|
189
|
+
type: "actions",
|
|
190
|
+
elements: actionEntries.map(({ btn, actionId }) => ({
|
|
191
|
+
type: "button",
|
|
192
|
+
text: { type: "plain_text", text: btn.label },
|
|
193
|
+
action_id: actionId,
|
|
194
|
+
value: btn.value,
|
|
195
|
+
})),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
const result = await this.app.client.chat.postMessage({
|
|
199
|
+
channel, text, blocks,
|
|
200
|
+
});
|
|
201
|
+
const cleanupAll = () => {
|
|
202
|
+
for (const { actionId } of actionEntries) {
|
|
203
|
+
this.pendingButtonCallbacks.delete(actionId);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
// Wait for button press (no timeout — matches CLI behavior)
|
|
207
|
+
const { button: pressed, triggerId } = await new Promise((resolve) => {
|
|
208
|
+
for (const entry of actionEntries) {
|
|
209
|
+
this.pendingButtonCallbacks.set(entry.actionId, (triggerId) => {
|
|
210
|
+
cleanupAll();
|
|
211
|
+
resolve({ button: entry.btn, triggerId });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// Update message to highlight the selected button (others become inert — no callbacks)
|
|
216
|
+
if (result.ts) {
|
|
217
|
+
const feedbackBlocks = [
|
|
218
|
+
blocks[0],
|
|
219
|
+
{
|
|
220
|
+
type: "actions",
|
|
221
|
+
elements: actionEntries.map(({ btn, actionId }) => ({
|
|
222
|
+
type: "button",
|
|
223
|
+
text: { type: "plain_text", text: btn === pressed ? `✅ ${btn.label}` : btn.label },
|
|
224
|
+
action_id: actionId,
|
|
225
|
+
value: btn.value,
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
try {
|
|
230
|
+
await this.app.client.chat.update({
|
|
231
|
+
channel, ts: result.ts, text, blocks: feedbackBlocks,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch { /* best-effort */ }
|
|
235
|
+
}
|
|
236
|
+
if (pressed.requestText && triggerId) {
|
|
237
|
+
// Open a modal for feedback input (Slack equivalent of Telegram's force_reply)
|
|
238
|
+
const modalCallbackId = `ccv:${callbackId}`;
|
|
239
|
+
await this.app.client.views.open({
|
|
240
|
+
trigger_id: triggerId,
|
|
241
|
+
view: {
|
|
242
|
+
type: "modal",
|
|
243
|
+
callback_id: modalCallbackId,
|
|
244
|
+
title: { type: "plain_text", text: "Deny with note" },
|
|
245
|
+
submit: { type: "plain_text", text: "Submit" },
|
|
246
|
+
blocks: [
|
|
247
|
+
{
|
|
248
|
+
type: "input",
|
|
249
|
+
block_id: "feedback_block",
|
|
250
|
+
element: {
|
|
251
|
+
type: "plain_text_input",
|
|
252
|
+
action_id: "feedback_input",
|
|
253
|
+
multiline: true,
|
|
254
|
+
placeholder: { type: "plain_text", text: "Why are you denying this action?" },
|
|
255
|
+
},
|
|
256
|
+
label: { type: "plain_text", text: "Note" },
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const followUpText = await new Promise((resolve) => {
|
|
262
|
+
this.pendingModalCallbacks.set(modalCallbackId, resolve);
|
|
263
|
+
});
|
|
264
|
+
if (followUpText && result.ts) {
|
|
265
|
+
try {
|
|
266
|
+
await this.app.client.chat.postMessage({
|
|
267
|
+
channel,
|
|
268
|
+
text: `Denied with note:\n> ${followUpText}`,
|
|
269
|
+
thread_ts: result.ts,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch { /* best-effort */ }
|
|
273
|
+
}
|
|
274
|
+
return { value: pressed.value, text: followUpText };
|
|
275
|
+
}
|
|
276
|
+
return { value: pressed.value };
|
|
277
|
+
}
|
|
278
|
+
async editMessage(chatId, handle, text) {
|
|
279
|
+
const chunk = splitMessage(text)[0];
|
|
280
|
+
await this.app.client.chat.update({
|
|
281
|
+
channel: this.slackId(chatId),
|
|
282
|
+
ts: handle,
|
|
283
|
+
text: chunk,
|
|
284
|
+
blocks: mrkdwnBlocks(chunk),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async deleteMessage(chatId, handle) {
|
|
288
|
+
await this.app.client.chat.delete({
|
|
289
|
+
channel: this.slackId(chatId),
|
|
290
|
+
ts: handle,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
async pinMessage(chatId, handle) {
|
|
294
|
+
await this.app.client.pins.add({
|
|
295
|
+
channel: this.slackId(chatId),
|
|
296
|
+
timestamp: handle,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async unpinAllMessages(chatId) {
|
|
300
|
+
const channel = this.slackId(chatId);
|
|
301
|
+
try {
|
|
302
|
+
const result = await this.app.client.pins.list({ channel });
|
|
303
|
+
if (result.items) {
|
|
304
|
+
for (const item of result.items) {
|
|
305
|
+
// The Slack API returns message.ts on pinned messages but the type defs omit it
|
|
306
|
+
const ts = item.message?.ts;
|
|
307
|
+
if (ts) {
|
|
308
|
+
await this.app.client.pins.remove({ channel, timestamp: ts });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch { /* may not have permission */ }
|
|
314
|
+
}
|
|
315
|
+
async updateStatus(chatId, text) {
|
|
316
|
+
await this.app.client.conversations.setTopic({
|
|
317
|
+
channel: this.slackId(chatId),
|
|
318
|
+
topic: text,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async setTyping(chatId, isTyping) {
|
|
322
|
+
const channel = this.slackId(chatId);
|
|
323
|
+
if (isTyping) {
|
|
324
|
+
if (this.typingMessageTs.has(chatId))
|
|
325
|
+
return;
|
|
326
|
+
try {
|
|
327
|
+
const result = await this.app.client.chat.postMessage({
|
|
328
|
+
channel, text: "_typing…_",
|
|
329
|
+
});
|
|
330
|
+
if (result.ts)
|
|
331
|
+
this.typingMessageTs.set(chatId, result.ts);
|
|
332
|
+
}
|
|
333
|
+
catch { /* best-effort */ }
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Delete placeholder if it wasn't consumed by sendMessage
|
|
337
|
+
const ts = this.typingMessageTs.get(chatId);
|
|
338
|
+
if (ts) {
|
|
339
|
+
this.typingMessageTs.delete(chatId);
|
|
340
|
+
try {
|
|
341
|
+
await this.app.client.chat.delete({ channel, ts });
|
|
342
|
+
}
|
|
343
|
+
catch { /* already deleted */ }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
slackId(chatId) {
|
|
348
|
+
return chatId.replace(/^slack:/, "");
|
|
349
|
+
}
|
|
350
|
+
/** Download a file from Slack using bot token auth. */
|
|
351
|
+
async downloadFile(url) {
|
|
352
|
+
const resp = await fetch(url, {
|
|
353
|
+
headers: { Authorization: `Bearer ${this.botToken}` },
|
|
354
|
+
});
|
|
355
|
+
if (!resp.ok)
|
|
356
|
+
throw new Error(`Slack file download failed: ${resp.status}`);
|
|
357
|
+
return Buffer.from(await resp.arrayBuffer());
|
|
358
|
+
}
|
|
359
|
+
async resolveUserName(userId) {
|
|
360
|
+
const cached = this.userNameCache.get(userId);
|
|
361
|
+
if (cached)
|
|
362
|
+
return cached;
|
|
363
|
+
try {
|
|
364
|
+
const result = await this.app.client.users.info({ user: userId });
|
|
365
|
+
const name = result.user?.real_name || result.user?.name;
|
|
366
|
+
if (name)
|
|
367
|
+
this.userNameCache.set(userId, name);
|
|
368
|
+
return name;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function mrkdwnBlocks(text) {
|
|
376
|
+
return [{ type: "section", text: { type: "mrkdwn", text } }];
|
|
377
|
+
}
|
|
378
|
+
// Block Kit section.text has a 3000 char limit — the tightest Slack constraint.
|
|
379
|
+
// We split at this limit so each chunk fits both the text fallback and the block.
|
|
380
|
+
const MAX_TEXT = 3000;
|
|
381
|
+
function splitMessage(text) {
|
|
382
|
+
if (text.length <= MAX_TEXT)
|
|
383
|
+
return [text];
|
|
384
|
+
log.warn("[channel] splitting message (%d chars) into chunks", text.length);
|
|
385
|
+
const chunks = [];
|
|
386
|
+
let remaining = text;
|
|
387
|
+
while (remaining.length > MAX_TEXT) {
|
|
388
|
+
const newlineAt = remaining.lastIndexOf("\n", MAX_TEXT);
|
|
389
|
+
if (newlineAt > 0) {
|
|
390
|
+
chunks.push(remaining.slice(0, newlineAt));
|
|
391
|
+
remaining = remaining.slice(newlineAt + 1);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
chunks.push(remaining.slice(0, MAX_TEXT));
|
|
395
|
+
remaining = remaining.slice(MAX_TEXT);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (remaining)
|
|
399
|
+
chunks.push(remaining);
|
|
400
|
+
return chunks;
|
|
401
|
+
}
|
|
402
|
+
//# sourceMappingURL=slack.js.map
|
|
@@ -3,16 +3,29 @@ import type { Channel, Button, ButtonResponse, SendMessageOpts } from "../types.
|
|
|
3
3
|
export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
4
4
|
name: string;
|
|
5
5
|
private bot;
|
|
6
|
+
private botToken;
|
|
6
7
|
private allowedUserIds;
|
|
7
8
|
private typingIntervals;
|
|
9
|
+
private statusHandles;
|
|
8
10
|
private pendingCallbacks;
|
|
9
11
|
private pendingTextResolvers;
|
|
10
12
|
constructor(botToken: string, allowedUserIds: Set<string>);
|
|
11
13
|
connect(): Promise<void>;
|
|
12
14
|
disconnect(): Promise<void>;
|
|
13
15
|
ownsId(chatId: string): boolean;
|
|
14
|
-
sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<
|
|
15
|
-
sendInteractive(chatId: string, text: string, buttons: Button[]): Promise<ButtonResponse>;
|
|
16
|
+
sendMessage(chatId: string, text: string, opts?: SendMessageOpts): Promise<string[]>;
|
|
17
|
+
sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
|
|
18
|
+
editMessage(chatId: string, handle: string, text: string): Promise<void>;
|
|
19
|
+
deleteMessage(chatId: string, handle: string): Promise<void>;
|
|
20
|
+
pinMessage(chatId: string, handle: string): Promise<void>;
|
|
21
|
+
unpinAllMessages(chatId: string): Promise<void>;
|
|
22
|
+
updateStatus(chatId: string, text: string): Promise<void>;
|
|
16
23
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
24
|
+
/** Extract auth-checked sender info from a grammY context. Returns null if unauthorized. */
|
|
25
|
+
private extractSender;
|
|
26
|
+
/** Shared handler for photo/document messages: download, emit attachment or fallback. */
|
|
27
|
+
private handleMediaMessage;
|
|
28
|
+
/** Download a Telegram file by file_id, returning the raw buffer. */
|
|
29
|
+
private downloadFile;
|
|
17
30
|
private numericId;
|
|
18
31
|
}
|