@hienlh/ppm 0.9.30 → 0.9.32
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/CHANGELOG.md +18 -5
- package/dist/web/assets/{browser-tab-D0o6oSlt.js → browser-tab-B9nNKjZX.js} +1 -1
- package/dist/web/assets/{chat-tab-Boo_H1k9.js → chat-tab-6XGhEKaC.js} +2 -2
- package/dist/web/assets/{code-editor-DayGetAZ.js → code-editor-DMZMpzt2.js} +1 -1
- package/dist/web/assets/{database-viewer-CaxAp1qK.js → database-viewer-CnP1FFS2.js} +1 -1
- package/dist/web/assets/{diff-viewer-BvEXe_B4.js → diff-viewer-Cvwd0XBO.js} +1 -1
- package/dist/web/assets/{extension-webview-6XProGzB.js → extension-webview-DkhsRepr.js} +1 -1
- package/dist/web/assets/{git-graph-CvgIIt2x.js → git-graph-C3670Nxm.js} +1 -1
- package/dist/web/assets/index-CcFDEPCo.css +2 -0
- package/dist/web/assets/index-DjIQL8ar.js +30 -0
- package/dist/web/assets/keybindings-store-DHh6rwm-.js +1 -0
- package/dist/web/assets/{markdown-renderer-UCGYJpI-.js → markdown-renderer-Co04dDdI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-TV6kyo6B.js → postgres-viewer-D8K1qnnA.js} +1 -1
- package/dist/web/assets/{settings-tab-EziN5Pco.js → settings-tab-64ODAeQZ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D7LPvSkU.js → sqlite-viewer-ClX7FICB.js} +1 -1
- package/dist/web/assets/{terminal-tab-C7Hdv1nq.js → terminal-tab-Dw4IKWGM.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CI4vTUsh.js → use-monaco-theme-DA7EyR70.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +33 -3
- package/docs/project-changelog.md +47 -0
- package/docs/project-roadmap.md +14 -7
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +65 -2
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/server/index.ts +8 -1
- package/src/server/routes/settings.ts +72 -1
- package/src/services/clawbot/clawbot-formatter.ts +88 -0
- package/src/services/clawbot/clawbot-memory.ts +333 -0
- package/src/services/clawbot/clawbot-service.ts +500 -0
- package/src/services/clawbot/clawbot-session.ts +188 -0
- package/src/services/clawbot/clawbot-streamer.ts +245 -0
- package/src/services/clawbot/clawbot-telegram.ts +251 -0
- package/src/services/config.service.ts +1 -1
- package/src/services/db.service.ts +279 -1
- package/src/services/supervisor.ts +10 -0
- package/src/types/clawbot.ts +103 -0
- package/src/types/config.ts +22 -0
- package/src/web/components/chat/chat-history-bar.tsx +8 -3
- package/src/web/components/settings/clawbot-settings-section.tsx +270 -0
- package/src/web/components/settings/settings-tab.tsx +4 -1
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/dist/web/assets/index-CJvp0DJT.css +0 -2
- package/dist/web/assets/index-DocPzjV6.js +0 -30
- package/dist/web/assets/keybindings-store-2KURy8S3.js +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { ChatEvent, ResultSubtype } from "../../types/chat.ts";
|
|
2
|
+
import type { ClawBotTelegram } from "./clawbot-telegram.ts";
|
|
3
|
+
import {
|
|
4
|
+
markdownToTelegramHtml,
|
|
5
|
+
chunkMessage,
|
|
6
|
+
escapeHtml,
|
|
7
|
+
} from "./clawbot-formatter.ts";
|
|
8
|
+
|
|
9
|
+
const MAX_MSG_LEN = 4096;
|
|
10
|
+
const TYPING_REFRESH_MS = 4000;
|
|
11
|
+
const PLACEHOLDER = "\u2026"; // ellipsis
|
|
12
|
+
|
|
13
|
+
export interface StreamConfig {
|
|
14
|
+
showToolCalls: boolean;
|
|
15
|
+
showThinking: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StreamResult {
|
|
19
|
+
contextWindowPct?: number;
|
|
20
|
+
resultSubtype?: ResultSubtype;
|
|
21
|
+
/** All Telegram message IDs sent during this stream */
|
|
22
|
+
messageIds: number[];
|
|
23
|
+
/** New session ID if session was migrated */
|
|
24
|
+
newSessionId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Consume a ChatEvent stream and progressively send/edit Telegram messages.
|
|
29
|
+
*
|
|
30
|
+
* Flow:
|
|
31
|
+
* 1. Send placeholder message
|
|
32
|
+
* 2. Accumulate text/tool/thinking events
|
|
33
|
+
* 3. Edit message every time ClawBotTelegram allows (1s throttle)
|
|
34
|
+
* 4. When text exceeds 4096, finalize current msg, start new one
|
|
35
|
+
* 5. On done/error, finalize and return result
|
|
36
|
+
*/
|
|
37
|
+
export async function streamToTelegram(
|
|
38
|
+
chatId: number | string,
|
|
39
|
+
events: AsyncIterable<ChatEvent>,
|
|
40
|
+
telegram: ClawBotTelegram,
|
|
41
|
+
config: StreamConfig,
|
|
42
|
+
): Promise<StreamResult> {
|
|
43
|
+
const result: StreamResult = { messageIds: [] };
|
|
44
|
+
let accumulated = "";
|
|
45
|
+
let currentMsgId: number | null = null;
|
|
46
|
+
let lastTypingTime = 0;
|
|
47
|
+
|
|
48
|
+
const refreshTyping = async (): Promise<void> => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
if (now - lastTypingTime >= TYPING_REFRESH_MS) {
|
|
51
|
+
lastTypingTime = now;
|
|
52
|
+
await telegram.sendTyping(chatId);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Send placeholder
|
|
57
|
+
await telegram.sendTyping(chatId);
|
|
58
|
+
lastTypingTime = Date.now();
|
|
59
|
+
const placeholder = await telegram.sendMessage(chatId, PLACEHOLDER);
|
|
60
|
+
if (placeholder) {
|
|
61
|
+
currentMsgId = placeholder.message_id;
|
|
62
|
+
result.messageIds.push(currentMsgId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const finalizeAndStartNew = async (text: string): Promise<void> => {
|
|
66
|
+
if (currentMsgId && text.trim()) {
|
|
67
|
+
const html = markdownToTelegramHtml(text);
|
|
68
|
+
await telegram.editMessageFinal(chatId, currentMsgId, html);
|
|
69
|
+
}
|
|
70
|
+
accumulated = "";
|
|
71
|
+
currentMsgId = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const sendNewMessage = async (text: string): Promise<void> => {
|
|
75
|
+
const html = markdownToTelegramHtml(text);
|
|
76
|
+
const chunks = chunkMessage(html, MAX_MSG_LEN);
|
|
77
|
+
for (const chunk of chunks) {
|
|
78
|
+
const sent = await telegram.sendMessage(chatId, chunk);
|
|
79
|
+
if (sent) {
|
|
80
|
+
currentMsgId = sent.message_id;
|
|
81
|
+
result.messageIds.push(currentMsgId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const editCurrent = async (): Promise<void> => {
|
|
87
|
+
if (!currentMsgId || !accumulated.trim()) return;
|
|
88
|
+
|
|
89
|
+
const html = markdownToTelegramHtml(accumulated);
|
|
90
|
+
if (html.length > MAX_MSG_LEN) {
|
|
91
|
+
const splitPoint = findSplitPoint(accumulated, MAX_MSG_LEN * 0.8);
|
|
92
|
+
const first = accumulated.slice(0, splitPoint);
|
|
93
|
+
const rest = accumulated.slice(splitPoint).trimStart();
|
|
94
|
+
|
|
95
|
+
await finalizeAndStartNew(first);
|
|
96
|
+
accumulated = rest;
|
|
97
|
+
if (rest) {
|
|
98
|
+
await sendNewMessage(rest);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await telegram.editMessage(chatId, currentMsgId, html);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Process event stream
|
|
107
|
+
try {
|
|
108
|
+
for await (const event of events) {
|
|
109
|
+
await refreshTyping();
|
|
110
|
+
|
|
111
|
+
switch (event.type) {
|
|
112
|
+
case "text": {
|
|
113
|
+
accumulated += event.content;
|
|
114
|
+
await editCurrent();
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "thinking": {
|
|
119
|
+
if (config.showThinking && event.content) {
|
|
120
|
+
accumulated += `\n<i>${escapeHtml(event.content)}</i>\n`;
|
|
121
|
+
await editCurrent();
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "tool_use": {
|
|
127
|
+
if (config.showToolCalls) {
|
|
128
|
+
const toolName = event.tool;
|
|
129
|
+
const inputPreview = formatToolInput(event.input);
|
|
130
|
+
accumulated += `\n🔧 <code>${escapeHtml(toolName)}</code>(${escapeHtml(inputPreview)})\n`;
|
|
131
|
+
await editCurrent();
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "tool_result": {
|
|
137
|
+
if (config.showToolCalls && event.isError) {
|
|
138
|
+
accumulated += `\n⚠️ <code>${escapeHtml(event.output.slice(0, 200))}</code>\n`;
|
|
139
|
+
await editCurrent();
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "error": {
|
|
145
|
+
accumulated += `\n\n❌ <b>Error:</b> ${escapeHtml(event.message)}`;
|
|
146
|
+
await editCurrent();
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "done": {
|
|
151
|
+
result.contextWindowPct = event.contextWindowPct;
|
|
152
|
+
result.resultSubtype = event.resultSubtype;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "session_migrated": {
|
|
157
|
+
result.newSessionId = event.newSessionId;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "account_retry": {
|
|
162
|
+
accumulated += `\n⏳ <i>Switching account: ${escapeHtml(event.reason)}</i>\n`;
|
|
163
|
+
await editCurrent();
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
default:
|
|
168
|
+
// Ignore unknown events (system, team_detected, account_info, etc.)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
accumulated += `\n\n❌ <b>Stream error:</b> ${escapeHtml((err as Error).message)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Final edit with complete content
|
|
177
|
+
if (currentMsgId && accumulated.trim()) {
|
|
178
|
+
const html = markdownToTelegramHtml(accumulated);
|
|
179
|
+
const chunks = chunkMessage(html, MAX_MSG_LEN);
|
|
180
|
+
|
|
181
|
+
if (chunks.length === 1) {
|
|
182
|
+
await telegram.editMessageFinal(chatId, currentMsgId, chunks[0]!);
|
|
183
|
+
} else {
|
|
184
|
+
await telegram.editMessageFinal(chatId, currentMsgId, chunks[0]!);
|
|
185
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
186
|
+
const sent = await telegram.sendMessage(chatId, chunks[i]!);
|
|
187
|
+
if (sent) result.messageIds.push(sent.message_id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else if (currentMsgId && !accumulated.trim()) {
|
|
191
|
+
await telegram.editMessageFinal(
|
|
192
|
+
chatId,
|
|
193
|
+
currentMsgId,
|
|
194
|
+
"<i>No response generated.</i>",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** Format tool input for compact display */
|
|
204
|
+
function formatToolInput(input: unknown): string {
|
|
205
|
+
if (!input) return "";
|
|
206
|
+
if (typeof input === "string") return input.slice(0, 80);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const obj = input as Record<string, unknown>;
|
|
210
|
+
const keys = Object.keys(obj);
|
|
211
|
+
if (keys.length === 0) return "";
|
|
212
|
+
|
|
213
|
+
if ("command" in obj) return String(obj.command).slice(0, 80);
|
|
214
|
+
if ("file_path" in obj) return String(obj.file_path).slice(0, 80);
|
|
215
|
+
if ("pattern" in obj) return String(obj.pattern).slice(0, 80);
|
|
216
|
+
if ("query" in obj) return String(obj.query).slice(0, 80);
|
|
217
|
+
if ("url" in obj) return String(obj.url).slice(0, 80);
|
|
218
|
+
|
|
219
|
+
const firstKey = keys[0]!;
|
|
220
|
+
return `${firstKey}=${String(obj[firstKey]).slice(0, 60)}`;
|
|
221
|
+
} catch {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Find a good split point in text, aiming for targetLen.
|
|
228
|
+
* Prefers double newline > single newline > space.
|
|
229
|
+
*/
|
|
230
|
+
function findSplitPoint(text: string, targetLen: number): number {
|
|
231
|
+
if (text.length <= targetLen) return text.length;
|
|
232
|
+
|
|
233
|
+
const window = text.slice(0, Math.floor(targetLen));
|
|
234
|
+
|
|
235
|
+
let point = window.lastIndexOf("\n\n");
|
|
236
|
+
if (point > targetLen * 0.3) return point;
|
|
237
|
+
|
|
238
|
+
point = window.lastIndexOf("\n");
|
|
239
|
+
if (point > targetLen * 0.3) return point;
|
|
240
|
+
|
|
241
|
+
point = window.lastIndexOf(" ");
|
|
242
|
+
if (point > targetLen * 0.3) return point;
|
|
243
|
+
|
|
244
|
+
return Math.floor(targetLen);
|
|
245
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TelegramUpdate,
|
|
3
|
+
TelegramMessage,
|
|
4
|
+
TelegramSentMessage,
|
|
5
|
+
ClawBotCommand,
|
|
6
|
+
} from "../../types/clawbot.ts";
|
|
7
|
+
|
|
8
|
+
const TELEGRAM_API = "https://api.telegram.org/bot";
|
|
9
|
+
const POLL_TIMEOUT = 25;
|
|
10
|
+
const MIN_EDIT_INTERVAL = 1000;
|
|
11
|
+
const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
|
|
12
|
+
|
|
13
|
+
/** Known ClawBot slash commands */
|
|
14
|
+
const COMMANDS = new Set([
|
|
15
|
+
"start", "project", "new", "sessions", "resume",
|
|
16
|
+
"status", "stop", "memory", "forget", "remember", "help",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
|
|
20
|
+
|
|
21
|
+
export class ClawBotTelegram {
|
|
22
|
+
private token: string;
|
|
23
|
+
private offset = 0;
|
|
24
|
+
private running = false;
|
|
25
|
+
private abortController: AbortController | null = null;
|
|
26
|
+
private retryCount = 0;
|
|
27
|
+
|
|
28
|
+
/** Track last edit time per chatId:messageId to throttle */
|
|
29
|
+
private lastEditTime = new Map<string, number>();
|
|
30
|
+
|
|
31
|
+
constructor(token: string) {
|
|
32
|
+
if (!BOT_TOKEN_RE.test(token)) {
|
|
33
|
+
throw new Error("Invalid Telegram bot token format");
|
|
34
|
+
}
|
|
35
|
+
this.token = token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Polling ─────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Start long-polling loop. Calls handler for each update. */
|
|
41
|
+
async startPolling(handler: UpdateHandler): Promise<void> {
|
|
42
|
+
if (this.running) return;
|
|
43
|
+
this.running = true;
|
|
44
|
+
this.retryCount = 0;
|
|
45
|
+
console.log("[clawbot] Polling started");
|
|
46
|
+
|
|
47
|
+
while (this.running) {
|
|
48
|
+
try {
|
|
49
|
+
const updates = await this.getUpdates();
|
|
50
|
+
this.retryCount = 0;
|
|
51
|
+
|
|
52
|
+
for (const update of updates) {
|
|
53
|
+
this.offset = update.update_id + 1;
|
|
54
|
+
try {
|
|
55
|
+
await handler(update);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("[clawbot] Handler error:", (err as Error).message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!this.running) break;
|
|
62
|
+
this.retryCount++;
|
|
63
|
+
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000);
|
|
64
|
+
console.error(
|
|
65
|
+
`[clawbot] Poll error (retry ${this.retryCount}): ${(err as Error).message}. Retrying in ${delay}ms`,
|
|
66
|
+
);
|
|
67
|
+
await Bun.sleep(delay);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("[clawbot] Polling stopped");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stop polling gracefully */
|
|
75
|
+
stop(): void {
|
|
76
|
+
this.running = false;
|
|
77
|
+
this.abortController?.abort();
|
|
78
|
+
this.lastEditTime.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get isRunning(): boolean {
|
|
82
|
+
return this.running;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Telegram API Methods ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** Fetch updates via long-polling */
|
|
88
|
+
private async getUpdates(): Promise<TelegramUpdate[]> {
|
|
89
|
+
this.abortController = new AbortController();
|
|
90
|
+
const fetchTimeout = setTimeout(
|
|
91
|
+
() => this.abortController?.abort(),
|
|
92
|
+
(POLL_TIMEOUT + 10) * 1000,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`${TELEGRAM_API}${this.token}/getUpdates`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
offset: this.offset,
|
|
101
|
+
timeout: POLL_TIMEOUT,
|
|
102
|
+
allowed_updates: ["message"],
|
|
103
|
+
}),
|
|
104
|
+
signal: this.abortController.signal,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const json = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] };
|
|
108
|
+
if (!json.ok || !json.result) return [];
|
|
109
|
+
return json.result;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(fetchTimeout);
|
|
112
|
+
this.abortController = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Send a text message */
|
|
117
|
+
async sendMessage(
|
|
118
|
+
chatId: number | string,
|
|
119
|
+
text: string,
|
|
120
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
121
|
+
): Promise<TelegramSentMessage | null> {
|
|
122
|
+
try {
|
|
123
|
+
const res = await this.callApi("sendMessage", {
|
|
124
|
+
chat_id: chatId,
|
|
125
|
+
text,
|
|
126
|
+
parse_mode: parseMode,
|
|
127
|
+
disable_web_page_preview: true,
|
|
128
|
+
});
|
|
129
|
+
const json = (await res.json()) as { ok: boolean; result?: TelegramSentMessage; description?: string };
|
|
130
|
+
if (!json.ok) {
|
|
131
|
+
console.error(`[clawbot] sendMessage failed: ${json.description}`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return json.result ?? null;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`[clawbot] sendMessage error: ${(err as Error).message}`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Edit an existing message text (throttled at 1s intervals) */
|
|
142
|
+
async editMessage(
|
|
143
|
+
chatId: number | string,
|
|
144
|
+
messageId: number,
|
|
145
|
+
text: string,
|
|
146
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
const key = `${chatId}:${messageId}`;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const lastEdit = this.lastEditTime.get(key) ?? 0;
|
|
151
|
+
if (now - lastEdit < MIN_EDIT_INTERVAL) return false;
|
|
152
|
+
|
|
153
|
+
this.lastEditTime.set(key, now);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const res = await this.callApi("editMessageText", {
|
|
157
|
+
chat_id: chatId,
|
|
158
|
+
message_id: messageId,
|
|
159
|
+
text,
|
|
160
|
+
parse_mode: parseMode,
|
|
161
|
+
disable_web_page_preview: true,
|
|
162
|
+
});
|
|
163
|
+
const json = (await res.json()) as { ok: boolean; description?: string };
|
|
164
|
+
if (!json.ok) {
|
|
165
|
+
if (json.description?.includes("not modified")) return true;
|
|
166
|
+
console.error(`[clawbot] editMessage failed: ${json.description}`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(`[clawbot] editMessage error: ${(err as Error).message}`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Force-edit (bypass throttle) — used for final message */
|
|
177
|
+
async editMessageFinal(
|
|
178
|
+
chatId: number | string,
|
|
179
|
+
messageId: number,
|
|
180
|
+
text: string,
|
|
181
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
const key = `${chatId}:${messageId}`;
|
|
184
|
+
this.lastEditTime.delete(key);
|
|
185
|
+
return this.editMessage(chatId, messageId, text, parseMode);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Send "typing" chat action */
|
|
189
|
+
async sendTyping(chatId: number | string): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
await this.callApi("sendChatAction", {
|
|
192
|
+
chat_id: chatId,
|
|
193
|
+
action: "typing",
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
// Best-effort, ignore errors
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Delete a message */
|
|
201
|
+
async deleteMessage(chatId: number | string, messageId: number): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await this.callApi("deleteMessage", {
|
|
204
|
+
chat_id: chatId,
|
|
205
|
+
message_id: messageId,
|
|
206
|
+
});
|
|
207
|
+
} catch {
|
|
208
|
+
// Best-effort
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Command Parsing ─────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** Parse a Telegram message into a ClawBotCommand if it starts with / */
|
|
215
|
+
static parseCommand(message: TelegramMessage): ClawBotCommand | null {
|
|
216
|
+
const text = message.text ?? message.caption ?? "";
|
|
217
|
+
if (!text.startsWith("/")) return null;
|
|
218
|
+
|
|
219
|
+
const match = text.match(/^\/(\w+)(?:@\S+)?\s*(.*)/s);
|
|
220
|
+
if (!match) return null;
|
|
221
|
+
|
|
222
|
+
const command = match[1]!.toLowerCase();
|
|
223
|
+
if (!COMMANDS.has(command)) return null;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
command,
|
|
227
|
+
args: match[2]?.trim() ?? "",
|
|
228
|
+
chatId: message.chat.id,
|
|
229
|
+
messageId: message.message_id,
|
|
230
|
+
userId: message.from?.id ?? 0,
|
|
231
|
+
username: message.from?.username,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Private Helpers ─────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
private async callApi(method: string, body: Record<string, unknown>): Promise<Response> {
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
240
|
+
try {
|
|
241
|
+
return await fetch(`${TELEGRAM_API}${this.token}/${method}`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "Content-Type": "application/json" },
|
|
244
|
+
body: JSON.stringify(body),
|
|
245
|
+
signal: controller.signal,
|
|
246
|
+
});
|
|
247
|
+
} finally {
|
|
248
|
+
clearTimeout(timeout);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -19,7 +19,7 @@ const PPM_DIR = resolve(homedir(), ".ppm");
|
|
|
19
19
|
|
|
20
20
|
/** Top-level config keys stored in the config table (not projects) */
|
|
21
21
|
const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
|
|
22
|
-
"device_name", "port", "host", "theme", "auth", "ai", "push", "telegram",
|
|
22
|
+
"device_name", "port", "host", "theme", "auth", "ai", "push", "telegram", "clawbot",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
class ConfigService {
|