@amanm/openpaw 0.1.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/AGENTS.md +1 -0
- package/README.md +144 -0
- package/agent/agent.ts +217 -0
- package/agent/context-scan.ts +81 -0
- package/agent/file-editor-store.ts +27 -0
- package/agent/index.ts +31 -0
- package/agent/memory-store.ts +404 -0
- package/agent/model.ts +14 -0
- package/agent/prompt-builder.ts +139 -0
- package/agent/prompt-context-files.ts +151 -0
- package/agent/sandbox-paths.ts +52 -0
- package/agent/session-store.ts +80 -0
- package/agent/skill-catalog.ts +25 -0
- package/agent/skills/discover.ts +100 -0
- package/agent/tool-stream-format.ts +126 -0
- package/agent/tool-yaml-like.ts +96 -0
- package/agent/tools/bash.ts +100 -0
- package/agent/tools/file-editor.ts +293 -0
- package/agent/tools/list-dir.ts +58 -0
- package/agent/tools/load-skill.ts +40 -0
- package/agent/tools/memory.ts +84 -0
- package/agent/turn-context.ts +46 -0
- package/agent/types.ts +37 -0
- package/agent/workspace-bootstrap.ts +98 -0
- package/bin/openpaw.cjs +177 -0
- package/bundled-skills/find-skills/SKILL.md +163 -0
- package/cli/components/chat-app.tsx +759 -0
- package/cli/components/onboard-ui.tsx +325 -0
- package/cli/components/theme.ts +16 -0
- package/cli/configure.tsx +0 -0
- package/cli/lib/chat-transcript-types.ts +11 -0
- package/cli/lib/markdown-render-node.ts +523 -0
- package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
- package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
- package/cli/lib/use-auto-copy-selection.ts +38 -0
- package/cli/onboard.tsx +248 -0
- package/cli/openpaw.tsx +144 -0
- package/cli/reset.ts +12 -0
- package/cli/tui.tsx +31 -0
- package/config/index.ts +3 -0
- package/config/paths.ts +71 -0
- package/config/personality-copy.ts +68 -0
- package/config/storage.ts +80 -0
- package/config/types.ts +37 -0
- package/gateway/bootstrap.ts +25 -0
- package/gateway/channel-adapter.ts +8 -0
- package/gateway/daemon-manager.ts +191 -0
- package/gateway/index.ts +18 -0
- package/gateway/session-key.ts +13 -0
- package/gateway/slash-command-tokens.ts +39 -0
- package/gateway/start-messaging.ts +40 -0
- package/gateway/telegram/active-thread-store.ts +89 -0
- package/gateway/telegram/adapter.ts +290 -0
- package/gateway/telegram/assistant-markdown.ts +48 -0
- package/gateway/telegram/bot-commands.ts +40 -0
- package/gateway/telegram/chat-preferences.ts +100 -0
- package/gateway/telegram/constants.ts +5 -0
- package/gateway/telegram/index.ts +4 -0
- package/gateway/telegram/message-html.ts +138 -0
- package/gateway/telegram/message-queue.ts +19 -0
- package/gateway/telegram/reserved-command-filter.ts +33 -0
- package/gateway/telegram/session-file-discovery.ts +62 -0
- package/gateway/telegram/session-key.ts +13 -0
- package/gateway/telegram/session-label.ts +14 -0
- package/gateway/telegram/sessions-list-reply.ts +39 -0
- package/gateway/telegram/stream-delivery.ts +618 -0
- package/gateway/tui/constants.ts +2 -0
- package/gateway/tui/tui-active-thread-store.ts +103 -0
- package/gateway/tui/tui-session-discovery.ts +94 -0
- package/gateway/tui/tui-session-label.ts +22 -0
- package/gateway/tui/tui-sessions-list-message.ts +37 -0
- package/package.json +52 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel for the OpenPaw gateway: wires a grammy `Bot` to gateway
|
|
3
|
+
* commands, per-chat message serialization, chat preferences, and streaming
|
|
4
|
+
* assistant replies via `deliverStreamingReply` and `runtime.runTurn`.
|
|
5
|
+
*
|
|
6
|
+
* Exports `createTelegramChannelAdapter` for the multi-channel gateway and
|
|
7
|
+
* `runTelegramGateway` as a standalone long-polling entrypoint.
|
|
8
|
+
*/
|
|
9
|
+
import { Bot } from "grammy";
|
|
10
|
+
import { createGatewayContext, type OpenPawGatewayContext } from "../bootstrap";
|
|
11
|
+
import type { ChannelAdapter } from "../channel-adapter";
|
|
12
|
+
import {
|
|
13
|
+
getTelegramPersistenceSessionId,
|
|
14
|
+
setActiveTelegramSession,
|
|
15
|
+
startNewTelegramThread,
|
|
16
|
+
} from "./active-thread-store";
|
|
17
|
+
import { createTelegramMessageQueue } from "./message-queue";
|
|
18
|
+
import { registerOpenPawBotCommands } from "./bot-commands";
|
|
19
|
+
import {
|
|
20
|
+
firstCommandToken,
|
|
21
|
+
shouldForwardTextToAgent,
|
|
22
|
+
shouldReportUnknownOpenPawSlashCommand,
|
|
23
|
+
} from "./reserved-command-filter";
|
|
24
|
+
import { formatAvailableOpenPawSlashCommandsForUser } from "../slash-command-tokens";
|
|
25
|
+
import { replyWithSessionsList } from "./sessions-list-reply";
|
|
26
|
+
import { telegramSessionKey } from "./session-key";
|
|
27
|
+
import { listTelegramSessionsForChat } from "./session-file-discovery";
|
|
28
|
+
import { formatTelegramSessionLabel } from "./session-label";
|
|
29
|
+
import {
|
|
30
|
+
getTelegramChatPreferences,
|
|
31
|
+
setTelegramChatPreferences,
|
|
32
|
+
} from "./chat-preferences";
|
|
33
|
+
import { deliverStreamingReply } from "./stream-delivery";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registers Telegram handlers: session management (`/new`, `/sessions`, `/resume`),
|
|
37
|
+
* display prefs (`/reasoning`, `/tool_calls`), sandbox (`/sandbox on|off`),
|
|
38
|
+
* unknown OpenPaw slash-command help,
|
|
39
|
+
* and plain text forwarded to the agent. Work for each chat/topic is serialized
|
|
40
|
+
* through the per-key message queue.
|
|
41
|
+
*/
|
|
42
|
+
function wireTelegramBot(bot: Bot, ctx: OpenPawGatewayContext): void {
|
|
43
|
+
const { runtime } = ctx;
|
|
44
|
+
const runNext = createTelegramMessageQueue();
|
|
45
|
+
|
|
46
|
+
bot.command("new", async (grammyCtx) => {
|
|
47
|
+
const chatId = grammyCtx.chat?.id;
|
|
48
|
+
if (chatId === undefined) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
52
|
+
await runNext(queueKey, async () => {
|
|
53
|
+
try {
|
|
54
|
+
await startNewTelegramThread(chatId);
|
|
55
|
+
await grammyCtx.reply("Started a new conversation.");
|
|
56
|
+
} catch (e) {
|
|
57
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
58
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
bot.command("sessions", async (grammyCtx) => {
|
|
64
|
+
const chatId = grammyCtx.chat?.id;
|
|
65
|
+
if (chatId === undefined) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
69
|
+
await runNext(queueKey, async () => {
|
|
70
|
+
try {
|
|
71
|
+
await replyWithSessionsList(grammyCtx, chatId);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
74
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
bot.command("reasoning", async (grammyCtx) => {
|
|
80
|
+
const chatId = grammyCtx.chat?.id;
|
|
81
|
+
if (chatId === undefined) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
85
|
+
await runNext(queueKey, async () => {
|
|
86
|
+
try {
|
|
87
|
+
const arg = String(grammyCtx.match ?? "").trim().toLowerCase();
|
|
88
|
+
if (arg !== "show" && arg !== "hide") {
|
|
89
|
+
await grammyCtx.reply("Usage: /reasoning show — or — /reasoning hide");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const showReasoning = arg === "show";
|
|
93
|
+
await setTelegramChatPreferences(chatId, { showReasoning });
|
|
94
|
+
await grammyCtx.reply(
|
|
95
|
+
showReasoning
|
|
96
|
+
? "Reasoning will appear as separate messages."
|
|
97
|
+
: "Reasoning messages are hidden (session still saves them).",
|
|
98
|
+
);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
101
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
bot.command("tool_calls", async (grammyCtx) => {
|
|
107
|
+
const chatId = grammyCtx.chat?.id;
|
|
108
|
+
if (chatId === undefined) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
112
|
+
await runNext(queueKey, async () => {
|
|
113
|
+
try {
|
|
114
|
+
const arg = String(grammyCtx.match ?? "").trim().toLowerCase();
|
|
115
|
+
if (arg !== "show" && arg !== "hide") {
|
|
116
|
+
await grammyCtx.reply("Usage: /tool_calls show — or — /tool_calls hide");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const showToolCalls = arg === "show";
|
|
120
|
+
await setTelegramChatPreferences(chatId, { showToolCalls });
|
|
121
|
+
await grammyCtx.reply(
|
|
122
|
+
showToolCalls
|
|
123
|
+
? "Tool call status lines will be shown."
|
|
124
|
+
: "Tool call status lines are hidden (session still saves them).",
|
|
125
|
+
);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
128
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
bot.command("sandbox", async (grammyCtx) => {
|
|
134
|
+
const chatId = grammyCtx.chat?.id;
|
|
135
|
+
if (chatId === undefined) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
139
|
+
await runNext(queueKey, async () => {
|
|
140
|
+
try {
|
|
141
|
+
const arg = String(grammyCtx.match ?? "").trim().toLowerCase();
|
|
142
|
+
if (arg !== "on" && arg !== "off") {
|
|
143
|
+
await grammyCtx.reply("Usage: /sandbox on — or — /sandbox off");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const sandboxRestricted = arg === "on";
|
|
147
|
+
await setTelegramChatPreferences(chatId, { sandboxRestricted });
|
|
148
|
+
if (sandboxRestricted) {
|
|
149
|
+
await grammyCtx.reply(
|
|
150
|
+
"Filesystem sandbox is on: file_editor and bash are limited to the workspace.",
|
|
151
|
+
);
|
|
152
|
+
} else {
|
|
153
|
+
await grammyCtx.reply(
|
|
154
|
+
"Filesystem sandbox is off. The agent can read/write outside the workspace and run shell commands with cwd in your home directory. Use only if you trust this chat.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
159
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
bot.command("resume", async (grammyCtx) => {
|
|
165
|
+
const chatId = grammyCtx.chat?.id;
|
|
166
|
+
if (chatId === undefined) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
170
|
+
await runNext(queueKey, async () => {
|
|
171
|
+
try {
|
|
172
|
+
const arg = String(grammyCtx.match ?? "").trim();
|
|
173
|
+
if (!/^\d+$/.test(arg)) {
|
|
174
|
+
await grammyCtx.reply("Usage: /resume 1 — use /sessions to see numbers.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const n = Number.parseInt(arg, 10);
|
|
178
|
+
if (n < 1) {
|
|
179
|
+
await grammyCtx.reply("Usage: /resume 1 — use /sessions to see numbers.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const entries = await listTelegramSessionsForChat(chatId);
|
|
183
|
+
if (entries.length === 0) {
|
|
184
|
+
await grammyCtx.reply("No saved sessions yet.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (n > entries.length) {
|
|
188
|
+
await grammyCtx.reply(`No session ${n}. Run /sessions (1–${entries.length}).`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const chosen = entries[n - 1]!;
|
|
192
|
+
await setActiveTelegramSession(chatId, chosen.sessionId);
|
|
193
|
+
const label = formatTelegramSessionLabel(chosen.sessionId, chatId);
|
|
194
|
+
await grammyCtx.reply(`Resumed session ${n} (${label}).`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
197
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
bot
|
|
203
|
+
.on("message:text")
|
|
204
|
+
.filter(shouldReportUnknownOpenPawSlashCommand, async (grammyCtx) => {
|
|
205
|
+
const chatId = grammyCtx.chat?.id;
|
|
206
|
+
if (chatId === undefined) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const text = grammyCtx.message.text ?? "";
|
|
210
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
211
|
+
const token = firstCommandToken(text) ?? "/?";
|
|
212
|
+
const available = formatAvailableOpenPawSlashCommandsForUser();
|
|
213
|
+
await runNext(queueKey, async () => {
|
|
214
|
+
await grammyCtx.reply(
|
|
215
|
+
`Unknown command ${token}. Available: ${available}.`,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
bot.on("message:text").filter(shouldForwardTextToAgent, async (grammyCtx) => {
|
|
221
|
+
const text = grammyCtx.message.text;
|
|
222
|
+
if (!text?.trim()) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const queueKey = telegramSessionKey(grammyCtx);
|
|
227
|
+
const chatId = grammyCtx.chat?.id;
|
|
228
|
+
if (chatId === undefined) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const persistenceId = await getTelegramPersistenceSessionId(chatId);
|
|
233
|
+
const prefs = await getTelegramChatPreferences(chatId);
|
|
234
|
+
|
|
235
|
+
await runNext(queueKey, async () => {
|
|
236
|
+
try {
|
|
237
|
+
await deliverStreamingReply(grammyCtx, prefs, async (handlers) => {
|
|
238
|
+
await runtime.runTurn({
|
|
239
|
+
sessionId: persistenceId,
|
|
240
|
+
userText: text,
|
|
241
|
+
surface: "telegram",
|
|
242
|
+
sandboxRestricted: prefs.sandboxRestricted,
|
|
243
|
+
onTextDelta: handlers.onTextDelta,
|
|
244
|
+
onReasoningDelta: handlers.onReasoningDelta,
|
|
245
|
+
onToolStatus: handlers.onToolStatus,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
} catch (e) {
|
|
249
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
250
|
+
try {
|
|
251
|
+
await grammyCtx.reply(`OpenPaw error: ${msg}`);
|
|
252
|
+
} catch {
|
|
253
|
+
// ignore double failure
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Builds a {@link ChannelAdapter} that starts the Telegram bot with registered
|
|
262
|
+
* commands and long polling. Requires `channels.telegram.botToken` in config.
|
|
263
|
+
*/
|
|
264
|
+
export function createTelegramChannelAdapter(ctx: OpenPawGatewayContext): ChannelAdapter {
|
|
265
|
+
const token = ctx.config.channels?.telegram?.botToken;
|
|
266
|
+
if (!token) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Telegram bot token missing. Add channels.telegram.botToken to ~/.openpaw/config.yaml",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: "telegram",
|
|
274
|
+
run: async () => {
|
|
275
|
+
const bot = new Bot(token);
|
|
276
|
+
await registerOpenPawBotCommands(bot);
|
|
277
|
+
wireTelegramBot(bot, ctx);
|
|
278
|
+
console.log("OpenPaw Telegram channel starting (long polling)…");
|
|
279
|
+
await bot.start();
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Run only the Telegram channel (single-adapter entrypoint).
|
|
286
|
+
*/
|
|
287
|
+
export async function runTelegramGateway(): Promise<void> {
|
|
288
|
+
const ctx = await createGatewayContext();
|
|
289
|
+
await createTelegramChannelAdapter(ctx).run();
|
|
290
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps {@link https://www.npmjs.com/package/tg-markdown-converter tg-markdown-converter}
|
|
3
|
+
* for assistant replies: CommonMark/GFM → Telegram MarkdownV2 (no post-processing).
|
|
4
|
+
*/
|
|
5
|
+
import { converter } from "tg-markdown-converter";
|
|
6
|
+
|
|
7
|
+
/** Options passed to {@link converter} (no `splitAt`: streaming layer chunks source markdown first). */
|
|
8
|
+
const TELEGRAM_MARKDOWN_CONVERTER_OPTIONS = {
|
|
9
|
+
olSeparator: ")" as const,
|
|
10
|
+
ulMarker: "-",
|
|
11
|
+
imgMarker: "🎨",
|
|
12
|
+
thematicBreak: "* * *",
|
|
13
|
+
headingEmojis: {
|
|
14
|
+
h1: '',
|
|
15
|
+
h2: '',
|
|
16
|
+
h3: '',
|
|
17
|
+
h4: '',
|
|
18
|
+
h5: '',
|
|
19
|
+
h6: '',
|
|
20
|
+
} as const,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Body to send and whether Telegram should parse it as MarkdownV2 (omit for plain text). */
|
|
24
|
+
export type AssistantTelegramPayload = {
|
|
25
|
+
body: string;
|
|
26
|
+
parseMode: "MarkdownV2" | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Converts model markdown for the main assistant bubble (streaming edits and finalize).
|
|
31
|
+
* On success returns MarkdownV2; on converter failure returns plain text with no parse_mode
|
|
32
|
+
* so Telegram does not treat `_*[]()` as formatting.
|
|
33
|
+
*/
|
|
34
|
+
export function formatAssistantMarkdownForTelegram(markdown: string): AssistantTelegramPayload {
|
|
35
|
+
if (!markdown) {
|
|
36
|
+
return { body: markdown, parseMode: undefined };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const body = converter(markdown, TELEGRAM_MARKDOWN_CONVERTER_OPTIONS);
|
|
40
|
+
return { body, parseMode: "MarkdownV2" };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.warn(
|
|
43
|
+
"OpenPaw Telegram: MarkdownV2 conversion failed (tg-markdown-converter), sending plain text",
|
|
44
|
+
e,
|
|
45
|
+
);
|
|
46
|
+
return { body: markdown.slice(0, 4096), parseMode: undefined };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Bot } from "grammy";
|
|
2
|
+
import { OPENPAW_SLASH_COMMAND_NAMES } from "../slash-command-tokens";
|
|
3
|
+
|
|
4
|
+
const OPENPAW_COMMAND_DESCRIPTIONS: Record<(typeof OPENPAW_SLASH_COMMAND_NAMES)[number], string> =
|
|
5
|
+
{
|
|
6
|
+
new: "Start a fresh conversation",
|
|
7
|
+
sessions: "List saved sessions for this chat",
|
|
8
|
+
resume: "Resume a session by number from /sessions",
|
|
9
|
+
reasoning: "show or hide — reasoning bubbles in Telegram",
|
|
10
|
+
tool_calls: "show or hide — tool status lines in Telegram",
|
|
11
|
+
sandbox: "on or off — restrict file editor and shell to the workspace",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const OPENPAW_COMMANDS = OPENPAW_SLASH_COMMAND_NAMES.map((command) => ({
|
|
15
|
+
command,
|
|
16
|
+
description: OPENPAW_COMMAND_DESCRIPTIONS[command],
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Telegram keeps separate command lists per {@link https://core.telegram.org/bots/api#botcommandscope}.
|
|
21
|
+
* Without `all_group_chats`, supergroups still show BotFather/old menu entries while handlers work.
|
|
22
|
+
*/
|
|
23
|
+
const COMMAND_SCOPES = [
|
|
24
|
+
{ type: "default" as const },
|
|
25
|
+
{ type: "all_private_chats" as const },
|
|
26
|
+
{ type: "all_group_chats" as const },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export async function registerOpenPawBotCommands(bot: Bot): Promise<void> {
|
|
30
|
+
for (const scope of COMMAND_SCOPES) {
|
|
31
|
+
try {
|
|
32
|
+
await bot.api.setMyCommands(OPENPAW_COMMANDS, { scope });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.warn(
|
|
35
|
+
`OpenPaw: setMyCommands failed (scope=${scope.type}). Menu may be wrong in some chats:`,
|
|
36
|
+
e instanceof Error ? e.message : e,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getSessionsDir } from "../../config/paths";
|
|
4
|
+
import { TELEGRAM_CHAT_PREFERENCES_FILENAME } from "./constants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-chat Telegram display flags and tool sandbox. Session persistence is unchanged;
|
|
8
|
+
* display flags only affect live delivery; sandbox affects file_editor and bash per turn.
|
|
9
|
+
*/
|
|
10
|
+
export type TelegramChatPreferences = {
|
|
11
|
+
/** When false, reasoning phases are not sent as separate Telegram messages. */
|
|
12
|
+
showReasoning: boolean;
|
|
13
|
+
/** When false, tool status lines are not sent as Telegram messages. */
|
|
14
|
+
showToolCalls: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* When true (default), file_editor and bash are scoped to the workspace; when false,
|
|
17
|
+
* file_editor may access the broader filesystem and bash uses $HOME as cwd.
|
|
18
|
+
*/
|
|
19
|
+
sandboxRestricted: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_PREFS: TelegramChatPreferences = {
|
|
23
|
+
showReasoning: true,
|
|
24
|
+
showToolCalls: true,
|
|
25
|
+
sandboxRestricted: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PrefsFile = Record<
|
|
29
|
+
string,
|
|
30
|
+
{ showReasoning?: boolean; showToolCalls?: boolean; sandboxRestricted?: boolean }
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
function prefsPath(): string {
|
|
34
|
+
return join(getSessionsDir(), TELEGRAM_CHAT_PREFERENCES_FILENAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readAllPrefs(): Promise<PrefsFile> {
|
|
38
|
+
const path = prefsPath();
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const raw = await Bun.file(path).text();
|
|
44
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
45
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
return parsed as PrefsFile;
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writeAllPrefs(data: PrefsFile): Promise<void> {
|
|
55
|
+
const dir = getSessionsDir();
|
|
56
|
+
if (!existsSync(dir)) {
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
await Bun.write(prefsPath(), JSON.stringify(data, null, 2));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns persisted preferences for a Telegram chat, merged with defaults.
|
|
64
|
+
*/
|
|
65
|
+
export async function getTelegramChatPreferences(chatId: number): Promise<TelegramChatPreferences> {
|
|
66
|
+
const all = await readAllPrefs();
|
|
67
|
+
const row = all[String(chatId)];
|
|
68
|
+
return {
|
|
69
|
+
showReasoning: row?.showReasoning ?? DEFAULT_PREFS.showReasoning,
|
|
70
|
+
showToolCalls: row?.showToolCalls ?? DEFAULT_PREFS.showToolCalls,
|
|
71
|
+
sandboxRestricted: row?.sandboxRestricted ?? DEFAULT_PREFS.sandboxRestricted,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Updates one or both flags for a chat and persists to disk.
|
|
77
|
+
*/
|
|
78
|
+
export async function setTelegramChatPreferences(
|
|
79
|
+
chatId: number,
|
|
80
|
+
patch: Partial<
|
|
81
|
+
Pick<TelegramChatPreferences, "showReasoning" | "showToolCalls" | "sandboxRestricted">
|
|
82
|
+
>,
|
|
83
|
+
): Promise<TelegramChatPreferences> {
|
|
84
|
+
const all = await readAllPrefs();
|
|
85
|
+
const key = String(chatId);
|
|
86
|
+
const prev = all[key] ?? {};
|
|
87
|
+
const next: TelegramChatPreferences = {
|
|
88
|
+
showReasoning: patch.showReasoning ?? prev.showReasoning ?? DEFAULT_PREFS.showReasoning,
|
|
89
|
+
showToolCalls: patch.showToolCalls ?? prev.showToolCalls ?? DEFAULT_PREFS.showToolCalls,
|
|
90
|
+
sandboxRestricted:
|
|
91
|
+
patch.sandboxRestricted ?? prev.sandboxRestricted ?? DEFAULT_PREFS.sandboxRestricted,
|
|
92
|
+
};
|
|
93
|
+
all[key] = {
|
|
94
|
+
showReasoning: next.showReasoning,
|
|
95
|
+
showToolCalls: next.showToolCalls,
|
|
96
|
+
sandboxRestricted: next.sandboxRestricted,
|
|
97
|
+
};
|
|
98
|
+
await writeAllPrefs(all);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Persisted map of Telegram chat → active thread UUID (`~/.openpaw/workspace/sessions/`). */
|
|
2
|
+
export const TELEGRAM_ACTIVE_THREADS_FILENAME = "telegram-active-threads.json";
|
|
3
|
+
|
|
4
|
+
/** Per-chat UI preferences (reasoning/tool visibility in Telegram). */
|
|
5
|
+
export const TELEGRAM_CHAT_PREFERENCES_FILENAME = "telegram-chat-preferences.json";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds Telegram Bot API HTML bodies (parse_mode: HTML) for reasoning and tool lines.
|
|
3
|
+
* @see https://core.telegram.org/bots/api#html-style
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { truncateJson } from "../../agent/tool-stream-format";
|
|
7
|
+
import { toolInputToYamlLike, toolOutputToYamlLike } from "../../agent/tool-yaml-like";
|
|
8
|
+
import type { ToolStreamEvent } from "../../agent/types";
|
|
9
|
+
|
|
10
|
+
const TELEGRAM_MAX = 4096;
|
|
11
|
+
|
|
12
|
+
/** Shown after the reasoning block while the model is still streaming. */
|
|
13
|
+
const STREAMING_CURSOR_PLAIN = " ▉";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Escapes text for Telegram HTML mode (outside of tags you control).
|
|
17
|
+
*/
|
|
18
|
+
export function escapeTelegramHtml(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.replace(/&/g, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function truncateInner(s: string, reserve: number): string {
|
|
26
|
+
const max = Math.max(0, TELEGRAM_MAX - reserve);
|
|
27
|
+
if (s.length <= max) {
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
return `${s.slice(0, max - 1)}…`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Full HTML message for a reasoning phase (expandable blockquote for visual separation).
|
|
35
|
+
*/
|
|
36
|
+
export function formatReasoningPhaseHtml(
|
|
37
|
+
plain: string,
|
|
38
|
+
showCursor: boolean,
|
|
39
|
+
): string {
|
|
40
|
+
const trimmed = plain.trimEnd();
|
|
41
|
+
if (!trimmed) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
const inner = truncateInner(escapeTelegramHtml(trimmed), 200);
|
|
45
|
+
const block = `<blockquote expandable>${inner}</blockquote>`;
|
|
46
|
+
return showCursor ? `${block}${STREAMING_CURSOR_PLAIN}` : block;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatToolInputBlock(toolName: string, input: unknown): string {
|
|
50
|
+
const yaml = truncateInner(toolInputToYamlLike(toolName, input), 220);
|
|
51
|
+
const body = escapeTelegramHtml(yaml);
|
|
52
|
+
return `<b>Tool · ${escapeTelegramHtml(toolName)}</b>\n<pre><code class="language-yaml">${body}</code></pre>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* HTML for the tool-invocation half only (before the result is appended).
|
|
57
|
+
*/
|
|
58
|
+
export function formatToolInputOnlyHtml(toolName: string, input: unknown): string {
|
|
59
|
+
return formatToolInputBlock(toolName, input);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatToolOutputBlock(toolName: string, output: unknown): string {
|
|
63
|
+
const raw =
|
|
64
|
+
typeof output === "string"
|
|
65
|
+
? output
|
|
66
|
+
: truncateJson(output, TELEGRAM_MAX - 400);
|
|
67
|
+
const inner = truncateInner(escapeTelegramHtml(raw), 220);
|
|
68
|
+
return `<b>→ ${escapeTelegramHtml(toolName)}</b>\n<pre><code class="language-json">${inner}</code></pre>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Full tool bubble: input block plus a Result section (after execution).
|
|
73
|
+
*/
|
|
74
|
+
export function formatToolCallCompleteHtml(
|
|
75
|
+
toolName: string,
|
|
76
|
+
input: unknown,
|
|
77
|
+
result: ToolStreamEvent,
|
|
78
|
+
): string {
|
|
79
|
+
const head = formatToolInputBlock(toolName, input);
|
|
80
|
+
let tail = "";
|
|
81
|
+
switch (result.type) {
|
|
82
|
+
case "tool_output": {
|
|
83
|
+
const yaml = truncateInner(toolOutputToYamlLike(result.output), 600);
|
|
84
|
+
tail = `<b>Result</b>\n<pre><code class="language-yaml">${escapeTelegramHtml(yaml)}</code></pre>`;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "tool_error":
|
|
88
|
+
tail = `<b>Result</b>\n<blockquote>${escapeTelegramHtml(truncateInner(result.errorText, 400))}</blockquote>`;
|
|
89
|
+
break;
|
|
90
|
+
case "tool_denied":
|
|
91
|
+
tail = "<b>Result</b>\n<i>denied</i>";
|
|
92
|
+
break;
|
|
93
|
+
default:
|
|
94
|
+
return head;
|
|
95
|
+
}
|
|
96
|
+
const combined = `${head}\n${tail}`;
|
|
97
|
+
if (combined.length <= TELEGRAM_MAX) {
|
|
98
|
+
return combined;
|
|
99
|
+
}
|
|
100
|
+
const reserve = tail.length + 80;
|
|
101
|
+
const shrunkYaml = truncateInner(toolInputToYamlLike(toolName, input), reserve);
|
|
102
|
+
const shrunkHead = `<b>Tool · ${escapeTelegramHtml(toolName)}</b>\n<pre><code class="language-yaml">${escapeTelegramHtml(shrunkYaml)}</code></pre>`;
|
|
103
|
+
return `${shrunkHead}\n${tail}`.slice(0, TELEGRAM_MAX);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* When no matching tool_input message exists, send the result alone (HTML).
|
|
108
|
+
*/
|
|
109
|
+
export function formatStandaloneToolResultHtml(ev: ToolStreamEvent): string {
|
|
110
|
+
switch (ev.type) {
|
|
111
|
+
case "tool_output":
|
|
112
|
+
return formatToolOutputBlock(ev.toolName, ev.output);
|
|
113
|
+
case "tool_error":
|
|
114
|
+
return `<b>⚠ ${escapeTelegramHtml(ev.toolName)}</b>\n<blockquote>${escapeTelegramHtml(truncateInner(ev.errorText, 120))}</blockquote>`;
|
|
115
|
+
case "tool_denied":
|
|
116
|
+
return `<b>⛔ ${escapeTelegramHtml(ev.toolName)}</b>\n<i>denied</i>`;
|
|
117
|
+
default:
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Single Telegram HTML message for a streamed tool event (input-only or legacy standalone).
|
|
124
|
+
*/
|
|
125
|
+
export function formatToolStreamEventHtml(ev: ToolStreamEvent): string {
|
|
126
|
+
switch (ev.type) {
|
|
127
|
+
case "tool_input":
|
|
128
|
+
return formatToolInputBlock(ev.toolName, ev.input);
|
|
129
|
+
case "tool_output":
|
|
130
|
+
return formatToolOutputBlock(ev.toolName, ev.output);
|
|
131
|
+
case "tool_error":
|
|
132
|
+
return `<b>⚠ ${escapeTelegramHtml(ev.toolName)}</b>\n<blockquote>${escapeTelegramHtml(truncateInner(ev.errorText, 120))}</blockquote>`;
|
|
133
|
+
case "tool_denied":
|
|
134
|
+
return `<b>⛔ ${escapeTelegramHtml(ev.toolName)}</b>\n<i>denied</i>`;
|
|
135
|
+
default:
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes async work per queue key (one chain per Telegram chat queue id).
|
|
3
|
+
*/
|
|
4
|
+
export function createTelegramMessageQueue() {
|
|
5
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
6
|
+
|
|
7
|
+
return function runSerialized<T>(queueKey: string, task: () => Promise<T>): Promise<T> {
|
|
8
|
+
const prev = chains.get(queueKey) ?? Promise.resolve();
|
|
9
|
+
const result = prev.then(() => task());
|
|
10
|
+
chains.set(
|
|
11
|
+
queueKey,
|
|
12
|
+
result.then(
|
|
13
|
+
() => {},
|
|
14
|
+
() => {},
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Context } from "grammy";
|
|
2
|
+
import { firstCommandToken, RESERVED_SLASH_COMMANDS } from "../slash-command-tokens";
|
|
3
|
+
|
|
4
|
+
export { firstCommandToken };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* True when this text message should be handled by the OpenPaw agent.
|
|
8
|
+
* Messages whose first token looks like a Telegram slash command (`/…`) are never forwarded;
|
|
9
|
+
* they are handled by {@link shouldReportUnknownOpenPawSlashCommand} or grammy `bot.command` handlers.
|
|
10
|
+
*/
|
|
11
|
+
export function shouldForwardTextToAgent(ctx: Context): boolean {
|
|
12
|
+
const text = ctx.message?.text;
|
|
13
|
+
if (!text?.trim()) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const token = firstCommandToken(text);
|
|
17
|
+
return token !== undefined && !token.startsWith("/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True when the user sent a slash-prefixed first token that is not a known OpenPaw command
|
|
22
|
+
* (e.g. `/start`, `/help`, or a typo). Reply instead of invoking the model.
|
|
23
|
+
*/
|
|
24
|
+
export function shouldReportUnknownOpenPawSlashCommand(ctx: Context): boolean {
|
|
25
|
+
const text = ctx.message?.text;
|
|
26
|
+
if (!text?.trim()) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const token = firstCommandToken(text);
|
|
30
|
+
return (
|
|
31
|
+
token !== undefined && token.startsWith("/") && !RESERVED_SLASH_COMMANDS.has(token)
|
|
32
|
+
);
|
|
33
|
+
}
|