@hienlh/ppm 0.9.33 → 0.9.35
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 +23 -0
- package/dist/web/assets/{browser-tab-B9nNKjZX.js → browser-tab-DnIsHiCc.js} +1 -1
- package/dist/web/assets/{chat-tab-6XGhEKaC.js → chat-tab-il6D4jql.js} +2 -2
- package/dist/web/assets/{code-editor-DMZMpzt2.js → code-editor-BUc1jBqm.js} +1 -1
- package/dist/web/assets/{database-viewer-CnP1FFS2.js → database-viewer-TjRo2b8_.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cvwd0XBO.js → diff-viewer-BMhCz0xk.js} +1 -1
- package/dist/web/assets/{extension-webview-DkhsRepr.js → extension-webview-DiVdlE2r.js} +1 -1
- package/dist/web/assets/{git-graph-C3670Nxm.js → git-graph-4eGJ8B1A.js} +1 -1
- package/dist/web/assets/{index-DjIQL8ar.js → index-BmcV1di6.js} +4 -4
- package/dist/web/assets/keybindings-store--5T5hsAj.js +1 -0
- package/dist/web/assets/{markdown-renderer-Co04dDdI.js → markdown-renderer-IyEzLrC6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-D8K1qnnA.js → postgres-viewer-CSynGGkJ.js} +1 -1
- package/dist/web/assets/{settings-tab-64ODAeQZ.js → settings-tab-BdI4HhRa.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-ClX7FICB.js → sqlite-viewer-C5mviyU5.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dw4IKWGM.js → terminal-tab-CDyC1grg.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-DA7EyR70.js → use-monaco-theme-DcVicB_i.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +4 -4
- package/src/server/routes/settings.ts +14 -14
- package/src/services/db.service.ts +29 -29
- package/src/services/{clawbot/clawbot-memory.ts → ppmbot/ppmbot-memory.ts} +29 -29
- package/src/services/{clawbot/clawbot-service.ts → ppmbot/ppmbot-service.ts} +55 -38
- package/src/services/{clawbot/clawbot-session.ts → ppmbot/ppmbot-session.ts} +48 -37
- package/src/services/{clawbot/clawbot-streamer.ts → ppmbot/ppmbot-streamer.ts} +114 -80
- package/src/services/{clawbot/clawbot-telegram.ts → ppmbot/ppmbot-telegram.ts} +46 -18
- package/src/types/config.ts +3 -3
- package/src/types/{clawbot.ts → ppmbot.ts} +10 -10
- package/src/web/components/chat/chat-history-bar.tsx +2 -2
- package/src/web/components/settings/{clawbot-settings-section.tsx → ppmbot-settings-section.tsx} +7 -7
- package/src/web/components/settings/settings-tab.tsx +3 -3
- package/dist/web/assets/keybindings-store-DHh6rwm-.js +0 -1
- /package/src/services/{clawbot/clawbot-formatter.ts → ppmbot/ppmbot-formatter.ts} +0 -0
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
1
4
|
import { chatService } from "../chat.service.ts";
|
|
2
5
|
import { configService } from "../config.service.ts";
|
|
3
6
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
getActivePPMBotSession,
|
|
8
|
+
createPPMBotSession,
|
|
9
|
+
deactivatePPMBotSession,
|
|
10
|
+
touchPPMBotSession,
|
|
11
|
+
getRecentPPMBotSessions,
|
|
9
12
|
setSessionTitle,
|
|
10
13
|
} from "../db.service.ts";
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
14
|
+
import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
|
|
15
|
+
import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
|
|
13
16
|
|
|
14
|
-
export class
|
|
17
|
+
export class PPMBotSessionManager {
|
|
15
18
|
/** In-memory cache: telegramChatId → active session */
|
|
16
|
-
private activeSessions = new Map<string,
|
|
19
|
+
private activeSessions = new Map<string, PPMBotActiveSession>();
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Get active session for chatId. If none exists, create one for the
|
|
@@ -22,21 +25,22 @@ export class ClawBotSessionManager {
|
|
|
22
25
|
async getOrCreateSession(
|
|
23
26
|
chatId: string,
|
|
24
27
|
projectName?: string,
|
|
25
|
-
): Promise<
|
|
28
|
+
): Promise<PPMBotActiveSession> {
|
|
26
29
|
const cached = this.activeSessions.get(chatId);
|
|
27
30
|
if (cached && (!projectName || cached.projectName === projectName)) {
|
|
28
|
-
|
|
31
|
+
touchPPMBotSession(cached.sessionId);
|
|
29
32
|
return cached;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const input = projectName || this.getDefaultProject();
|
|
36
|
+
const resolvedProject = input
|
|
37
|
+
? this.resolveProject(input)
|
|
38
|
+
: this.getFallbackProject();
|
|
35
39
|
if (!resolvedProject) {
|
|
36
40
|
throw new Error(`Project not found: "${projectName || "(default)"}"`);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
const dbSession =
|
|
43
|
+
const dbSession = getActivePPMBotSession(chatId, resolvedProject.name);
|
|
40
44
|
if (dbSession) {
|
|
41
45
|
return this.resumeFromDb(chatId, dbSession, resolvedProject);
|
|
42
46
|
}
|
|
@@ -48,7 +52,7 @@ export class ClawBotSessionManager {
|
|
|
48
52
|
async switchProject(
|
|
49
53
|
chatId: string,
|
|
50
54
|
projectName: string,
|
|
51
|
-
): Promise<
|
|
55
|
+
): Promise<PPMBotActiveSession> {
|
|
52
56
|
await this.closeSession(chatId);
|
|
53
57
|
return this.getOrCreateSession(chatId, projectName);
|
|
54
58
|
}
|
|
@@ -57,27 +61,27 @@ export class ClawBotSessionManager {
|
|
|
57
61
|
async closeSession(chatId: string): Promise<void> {
|
|
58
62
|
const active = this.activeSessions.get(chatId);
|
|
59
63
|
if (active) {
|
|
60
|
-
|
|
64
|
+
deactivatePPMBotSession(active.sessionId);
|
|
61
65
|
this.activeSessions.delete(chatId);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/** Get active session from cache (no DB hit) */
|
|
66
|
-
getActiveSession(chatId: string):
|
|
70
|
+
getActiveSession(chatId: string): PPMBotActiveSession | null {
|
|
67
71
|
return this.activeSessions.get(chatId) ?? null;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/** List recent sessions for a chat (from DB) */
|
|
71
|
-
listRecentSessions(chatId: string, limit = 10):
|
|
72
|
-
return
|
|
75
|
+
listRecentSessions(chatId: string, limit = 10): PPMBotSessionRow[] {
|
|
76
|
+
return getRecentPPMBotSessions(chatId, limit);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/** Resume a specific session by 1-indexed position in history */
|
|
76
80
|
async resumeSessionById(
|
|
77
81
|
chatId: string,
|
|
78
82
|
sessionIndex: number,
|
|
79
|
-
): Promise<
|
|
80
|
-
const sessions =
|
|
83
|
+
): Promise<PPMBotActiveSession | null> {
|
|
84
|
+
const sessions = getRecentPPMBotSessions(chatId, 20);
|
|
81
85
|
const target = sessions[sessionIndex - 1];
|
|
82
86
|
if (!target) return null;
|
|
83
87
|
|
|
@@ -111,7 +115,7 @@ export class ClawBotSessionManager {
|
|
|
111
115
|
/** Update session title (e.g. after first message) */
|
|
112
116
|
updateSessionTitle(sessionId: string, firstMessage: string): void {
|
|
113
117
|
const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
|
|
114
|
-
const title = `[
|
|
118
|
+
const title = `[PPM] ${preview}`;
|
|
115
119
|
setSessionTitle(sessionId, title);
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -124,30 +128,37 @@ export class ClawBotSessionManager {
|
|
|
124
128
|
// ── Private ─────────────────────────────────────────────────────
|
|
125
129
|
|
|
126
130
|
private getDefaultProject(): string {
|
|
127
|
-
const
|
|
128
|
-
return
|
|
131
|
+
const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
|
|
132
|
+
return cfg?.default_project || "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Fallback project when nothing is configured: ~/.ppm/bot/ */
|
|
136
|
+
private getFallbackProject(): { name: string; path: string } {
|
|
137
|
+
const botDir = join(homedir(), ".ppm", "bot");
|
|
138
|
+
if (!existsSync(botDir)) mkdirSync(botDir, { recursive: true });
|
|
139
|
+
return { name: "bot", path: botDir };
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
private getDefaultProvider(): string {
|
|
132
|
-
const
|
|
133
|
-
return
|
|
143
|
+
const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
|
|
144
|
+
return cfg?.default_provider || configService.get("ai").default_provider;
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
private async createNewSession(
|
|
137
148
|
chatId: string,
|
|
138
149
|
project: { name: string; path: string },
|
|
139
|
-
): Promise<
|
|
150
|
+
): Promise<PPMBotActiveSession> {
|
|
140
151
|
const providerId = this.getDefaultProvider();
|
|
141
152
|
|
|
142
153
|
const session = await chatService.createSession(providerId, {
|
|
143
154
|
projectName: project.name,
|
|
144
155
|
projectPath: project.path,
|
|
145
|
-
title: `[
|
|
156
|
+
title: `[PPM] New session`,
|
|
146
157
|
});
|
|
147
158
|
|
|
148
|
-
|
|
159
|
+
createPPMBotSession(chatId, session.id, providerId, project.name, project.path);
|
|
149
160
|
|
|
150
|
-
const active:
|
|
161
|
+
const active: PPMBotActiveSession = {
|
|
151
162
|
telegramChatId: chatId,
|
|
152
163
|
sessionId: session.id,
|
|
153
164
|
providerId,
|
|
@@ -161,20 +172,20 @@ export class ClawBotSessionManager {
|
|
|
161
172
|
|
|
162
173
|
private async resumeFromDb(
|
|
163
174
|
chatId: string,
|
|
164
|
-
dbSession:
|
|
175
|
+
dbSession: PPMBotSessionRow,
|
|
165
176
|
project: { name: string; path: string },
|
|
166
|
-
): Promise<
|
|
177
|
+
): Promise<PPMBotActiveSession> {
|
|
167
178
|
try {
|
|
168
179
|
await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
|
|
169
180
|
} catch {
|
|
170
|
-
console.warn(`[
|
|
171
|
-
|
|
181
|
+
console.warn(`[ppmbot] Failed to resume session ${dbSession.session_id}, creating new`);
|
|
182
|
+
deactivatePPMBotSession(dbSession.session_id);
|
|
172
183
|
return this.createNewSession(chatId, project);
|
|
173
184
|
}
|
|
174
185
|
|
|
175
|
-
|
|
186
|
+
touchPPMBotSession(dbSession.session_id);
|
|
176
187
|
|
|
177
|
-
const active:
|
|
188
|
+
const active: PPMBotActiveSession = {
|
|
178
189
|
telegramChatId: chatId,
|
|
179
190
|
sessionId: dbSession.session_id,
|
|
180
191
|
providerId: dbSession.provider_id,
|
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
import type { ChatEvent, ResultSubtype } from "../../types/chat.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PPMBotTelegram } from "./ppmbot-telegram.ts";
|
|
3
3
|
import {
|
|
4
4
|
markdownToTelegramHtml,
|
|
5
5
|
chunkMessage,
|
|
6
6
|
escapeHtml,
|
|
7
|
-
} from "./
|
|
7
|
+
} from "./ppmbot-formatter.ts";
|
|
8
8
|
|
|
9
9
|
const MAX_MSG_LEN = 4096;
|
|
10
10
|
const TYPING_REFRESH_MS = 4000;
|
|
11
|
+
const EVENT_TIMEOUT_MS = 60_000; // 60s max wait per event
|
|
11
12
|
const PLACEHOLDER = "\u2026"; // ellipsis
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Wrap an async iterable with per-event timeout.
|
|
16
|
+
* If .next() doesn't resolve within timeoutMs, yields a timeout error.
|
|
17
|
+
*/
|
|
18
|
+
async function* withEventTimeout<T>(
|
|
19
|
+
iterable: AsyncIterable<T>,
|
|
20
|
+
timeoutMs: number,
|
|
21
|
+
): AsyncGenerator<T> {
|
|
22
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const result = await Promise.race([
|
|
26
|
+
iterator.next(),
|
|
27
|
+
new Promise<{ done: true; value: undefined; timedOut: true }>((resolve) =>
|
|
28
|
+
setTimeout(() => resolve({ done: true, value: undefined, timedOut: true }), timeoutMs),
|
|
29
|
+
),
|
|
30
|
+
]);
|
|
31
|
+
if ("timedOut" in result) {
|
|
32
|
+
throw new Error("No response within 60 seconds");
|
|
33
|
+
}
|
|
34
|
+
if (result.done) break;
|
|
35
|
+
yield result.value;
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
iterator.return?.();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
export interface StreamConfig {
|
|
14
43
|
showToolCalls: boolean;
|
|
15
44
|
showThinking: boolean;
|
|
@@ -18,30 +47,59 @@ export interface StreamConfig {
|
|
|
18
47
|
export interface StreamResult {
|
|
19
48
|
contextWindowPct?: number;
|
|
20
49
|
resultSubtype?: ResultSubtype;
|
|
21
|
-
/** All Telegram message IDs sent during this stream */
|
|
22
50
|
messageIds: number[];
|
|
23
|
-
/** New session ID if session was migrated */
|
|
24
51
|
newSessionId?: string;
|
|
25
52
|
}
|
|
26
53
|
|
|
27
54
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
55
|
+
* Segments of accumulated response.
|
|
56
|
+
* - "md" = raw markdown from AI (needs conversion)
|
|
57
|
+
* - "html" = pre-formatted HTML (tool calls, thinking, errors — already escaped)
|
|
36
58
|
*/
|
|
59
|
+
type Segment = { type: "md"; text: string } | { type: "html"; text: string };
|
|
60
|
+
|
|
61
|
+
/** Render segments into Telegram HTML */
|
|
62
|
+
function renderSegments(segments: Segment[]): string {
|
|
63
|
+
return segments
|
|
64
|
+
.map((s) => (s.type === "md" ? markdownToTelegramHtml(s.text) : s.text))
|
|
65
|
+
.join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if segments have meaningful content */
|
|
69
|
+
function hasContent(segments: Segment[]): boolean {
|
|
70
|
+
return segments.some((s) => s.text.trim().length > 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Get raw text length (approximation for split decisions) */
|
|
74
|
+
function segmentsLength(segments: Segment[]): number {
|
|
75
|
+
return segments.reduce((sum, s) => sum + s.text.length, 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Append markdown text — merges into last segment if also md */
|
|
79
|
+
function appendMd(segments: Segment[], text: string): void {
|
|
80
|
+
const last = segments[segments.length - 1];
|
|
81
|
+
if (last?.type === "md") {
|
|
82
|
+
last.text += text;
|
|
83
|
+
} else {
|
|
84
|
+
segments.push({ type: "md", text });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append pre-formatted HTML */
|
|
89
|
+
function appendHtml(segments: Segment[], html: string): void {
|
|
90
|
+
segments.push({ type: "html", html: html } as any);
|
|
91
|
+
// fix: use correct field
|
|
92
|
+
segments[segments.length - 1] = { type: "html", text: html };
|
|
93
|
+
}
|
|
94
|
+
|
|
37
95
|
export async function streamToTelegram(
|
|
38
96
|
chatId: number | string,
|
|
39
97
|
events: AsyncIterable<ChatEvent>,
|
|
40
|
-
telegram:
|
|
98
|
+
telegram: PPMBotTelegram,
|
|
41
99
|
config: StreamConfig,
|
|
42
100
|
): Promise<StreamResult> {
|
|
43
101
|
const result: StreamResult = { messageIds: [] };
|
|
44
|
-
|
|
102
|
+
const segments: Segment[] = [];
|
|
45
103
|
let currentMsgId: number | null = null;
|
|
46
104
|
let lastTypingTime = 0;
|
|
47
105
|
|
|
@@ -62,62 +120,49 @@ export async function streamToTelegram(
|
|
|
62
120
|
result.messageIds.push(currentMsgId);
|
|
63
121
|
}
|
|
64
122
|
|
|
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
123
|
const editCurrent = async (): Promise<void> => {
|
|
87
|
-
if (!currentMsgId || !
|
|
124
|
+
if (!currentMsgId || !hasContent(segments)) return;
|
|
88
125
|
|
|
89
|
-
const html =
|
|
126
|
+
const html = renderSegments(segments);
|
|
90
127
|
if (html.length > MAX_MSG_LEN) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
// Finalize current message with what fits, start new one
|
|
129
|
+
await telegram.editMessageFinal(chatId, currentMsgId, html.slice(0, MAX_MSG_LEN));
|
|
130
|
+
currentMsgId = null;
|
|
131
|
+
|
|
132
|
+
const overflow = html.slice(MAX_MSG_LEN);
|
|
133
|
+
if (overflow.trim()) {
|
|
134
|
+
const chunks = chunkMessage(overflow, MAX_MSG_LEN);
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
const sent = await telegram.sendMessage(chatId, chunk);
|
|
137
|
+
if (sent) {
|
|
138
|
+
currentMsgId = sent.message_id;
|
|
139
|
+
result.messageIds.push(currentMsgId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
99
142
|
}
|
|
143
|
+
// Reset segments — only keep any un-rendered text
|
|
144
|
+
segments.length = 0;
|
|
100
145
|
return;
|
|
101
146
|
}
|
|
102
147
|
|
|
103
148
|
await telegram.editMessage(chatId, currentMsgId, html);
|
|
104
149
|
};
|
|
105
150
|
|
|
106
|
-
// Process event stream
|
|
151
|
+
// Process event stream with per-event timeout
|
|
107
152
|
try {
|
|
108
|
-
for await (const event of events) {
|
|
153
|
+
for await (const event of withEventTimeout(events, EVENT_TIMEOUT_MS)) {
|
|
109
154
|
await refreshTyping();
|
|
110
155
|
|
|
111
156
|
switch (event.type) {
|
|
112
157
|
case "text": {
|
|
113
|
-
|
|
158
|
+
appendMd(segments, event.content);
|
|
114
159
|
await editCurrent();
|
|
115
160
|
break;
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
case "thinking": {
|
|
119
164
|
if (config.showThinking && event.content) {
|
|
120
|
-
|
|
165
|
+
appendHtml(segments, `\n<i>💭 ${escapeHtml(event.content)}</i>\n`);
|
|
121
166
|
await editCurrent();
|
|
122
167
|
}
|
|
123
168
|
break;
|
|
@@ -127,7 +172,10 @@ export async function streamToTelegram(
|
|
|
127
172
|
if (config.showToolCalls) {
|
|
128
173
|
const toolName = event.tool;
|
|
129
174
|
const inputPreview = formatToolInput(event.input);
|
|
130
|
-
|
|
175
|
+
appendHtml(
|
|
176
|
+
segments,
|
|
177
|
+
`\n🔧 <code>${escapeHtml(toolName)}</code>(${escapeHtml(inputPreview)})\n`,
|
|
178
|
+
);
|
|
131
179
|
await editCurrent();
|
|
132
180
|
}
|
|
133
181
|
break;
|
|
@@ -135,14 +183,17 @@ export async function streamToTelegram(
|
|
|
135
183
|
|
|
136
184
|
case "tool_result": {
|
|
137
185
|
if (config.showToolCalls && event.isError) {
|
|
138
|
-
|
|
186
|
+
appendHtml(
|
|
187
|
+
segments,
|
|
188
|
+
`\n⚠️ <code>${escapeHtml(event.output.slice(0, 200))}</code>\n`,
|
|
189
|
+
);
|
|
139
190
|
await editCurrent();
|
|
140
191
|
}
|
|
141
192
|
break;
|
|
142
193
|
}
|
|
143
194
|
|
|
144
195
|
case "error": {
|
|
145
|
-
|
|
196
|
+
appendHtml(segments, `\n\n❌ <b>Error:</b> ${escapeHtml(event.message)}`);
|
|
146
197
|
await editCurrent();
|
|
147
198
|
break;
|
|
148
199
|
}
|
|
@@ -159,23 +210,28 @@ export async function streamToTelegram(
|
|
|
159
210
|
}
|
|
160
211
|
|
|
161
212
|
case "account_retry": {
|
|
162
|
-
|
|
213
|
+
appendHtml(
|
|
214
|
+
segments,
|
|
215
|
+
`\n⏳ <i>Switching account: ${escapeHtml(event.reason)}</i>\n`,
|
|
216
|
+
);
|
|
163
217
|
await editCurrent();
|
|
164
218
|
break;
|
|
165
219
|
}
|
|
166
220
|
|
|
167
221
|
default:
|
|
168
|
-
// Ignore unknown events (system, team_detected, account_info, etc.)
|
|
169
222
|
break;
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
225
|
} catch (err) {
|
|
173
|
-
|
|
226
|
+
appendHtml(
|
|
227
|
+
segments,
|
|
228
|
+
`\n\n❌ <b>Stream error:</b> ${escapeHtml((err as Error).message)}`,
|
|
229
|
+
);
|
|
174
230
|
}
|
|
175
231
|
|
|
176
232
|
// Final edit with complete content
|
|
177
|
-
if (currentMsgId &&
|
|
178
|
-
const html =
|
|
233
|
+
if (currentMsgId && hasContent(segments)) {
|
|
234
|
+
const html = renderSegments(segments);
|
|
179
235
|
const chunks = chunkMessage(html, MAX_MSG_LEN);
|
|
180
236
|
|
|
181
237
|
if (chunks.length === 1) {
|
|
@@ -187,7 +243,7 @@ export async function streamToTelegram(
|
|
|
187
243
|
if (sent) result.messageIds.push(sent.message_id);
|
|
188
244
|
}
|
|
189
245
|
}
|
|
190
|
-
} else if (currentMsgId && !
|
|
246
|
+
} else if (currentMsgId && !hasContent(segments)) {
|
|
191
247
|
await telegram.editMessageFinal(
|
|
192
248
|
chatId,
|
|
193
249
|
currentMsgId,
|
|
@@ -200,7 +256,6 @@ export async function streamToTelegram(
|
|
|
200
256
|
|
|
201
257
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
202
258
|
|
|
203
|
-
/** Format tool input for compact display */
|
|
204
259
|
function formatToolInput(input: unknown): string {
|
|
205
260
|
if (!input) return "";
|
|
206
261
|
if (typeof input === "string") return input.slice(0, 80);
|
|
@@ -222,24 +277,3 @@ function formatToolInput(input: unknown): string {
|
|
|
222
277
|
return "";
|
|
223
278
|
}
|
|
224
279
|
}
|
|
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
|
-
}
|
|
@@ -2,15 +2,15 @@ import type {
|
|
|
2
2
|
TelegramUpdate,
|
|
3
3
|
TelegramMessage,
|
|
4
4
|
TelegramSentMessage,
|
|
5
|
-
|
|
6
|
-
} from "../../types/
|
|
5
|
+
PPMBotCommand,
|
|
6
|
+
} from "../../types/ppmbot.ts";
|
|
7
7
|
|
|
8
8
|
const TELEGRAM_API = "https://api.telegram.org/bot";
|
|
9
9
|
const POLL_TIMEOUT = 25;
|
|
10
10
|
const MIN_EDIT_INTERVAL = 1000;
|
|
11
11
|
const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
|
|
12
12
|
|
|
13
|
-
/** Known
|
|
13
|
+
/** Known PPMBot slash commands */
|
|
14
14
|
const COMMANDS = new Set([
|
|
15
15
|
"start", "project", "new", "sessions", "resume",
|
|
16
16
|
"status", "stop", "memory", "forget", "remember", "help",
|
|
@@ -18,7 +18,7 @@ const COMMANDS = new Set([
|
|
|
18
18
|
|
|
19
19
|
export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
|
|
20
20
|
|
|
21
|
-
export class
|
|
21
|
+
export class PPMBotTelegram {
|
|
22
22
|
private token: string;
|
|
23
23
|
private offset = 0;
|
|
24
24
|
private running = false;
|
|
@@ -37,12 +37,40 @@ export class ClawBotTelegram {
|
|
|
37
37
|
|
|
38
38
|
// ── Polling ─────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
+
/** Register bot commands with Telegram so they show in the menu */
|
|
41
|
+
async registerCommands(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await this.callApi("setMyCommands", {
|
|
44
|
+
commands: [
|
|
45
|
+
{ command: "start", description: "Greeting + list projects" },
|
|
46
|
+
{ command: "project", description: "Switch project" },
|
|
47
|
+
{ command: "new", description: "Fresh session (current project)" },
|
|
48
|
+
{ command: "sessions", description: "List recent sessions" },
|
|
49
|
+
{ command: "resume", description: "Resume a previous session" },
|
|
50
|
+
{ command: "status", description: "Current project/session info" },
|
|
51
|
+
{ command: "stop", description: "End current session" },
|
|
52
|
+
{ command: "memory", description: "Show project memories" },
|
|
53
|
+
{ command: "forget", description: "Remove matching memories" },
|
|
54
|
+
{ command: "remember", description: "Save a fact" },
|
|
55
|
+
{ command: "help", description: "Show all commands" },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
console.log("[ppmbot] Commands registered");
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn("[ppmbot] Failed to register commands:", (err as Error).message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/** Start long-polling loop. Calls handler for each update. */
|
|
41
65
|
async startPolling(handler: UpdateHandler): Promise<void> {
|
|
42
66
|
if (this.running) return;
|
|
43
67
|
this.running = true;
|
|
44
68
|
this.retryCount = 0;
|
|
45
|
-
|
|
69
|
+
|
|
70
|
+
// Register commands on startup
|
|
71
|
+
await this.registerCommands();
|
|
72
|
+
|
|
73
|
+
console.log("[ppmbot] Polling started");
|
|
46
74
|
|
|
47
75
|
while (this.running) {
|
|
48
76
|
try {
|
|
@@ -51,24 +79,24 @@ export class ClawBotTelegram {
|
|
|
51
79
|
|
|
52
80
|
for (const update of updates) {
|
|
53
81
|
this.offset = update.update_id + 1;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.error("[
|
|
58
|
-
}
|
|
82
|
+
// Fire-and-forget: don't block polling on handler execution
|
|
83
|
+
// Per-chatId serialization is handled by processing lock in service
|
|
84
|
+
handler(update).catch((err) => {
|
|
85
|
+
console.error("[ppmbot] Handler error:", (err as Error).message);
|
|
86
|
+
});
|
|
59
87
|
}
|
|
60
88
|
} catch (err) {
|
|
61
89
|
if (!this.running) break;
|
|
62
90
|
this.retryCount++;
|
|
63
91
|
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000);
|
|
64
92
|
console.error(
|
|
65
|
-
`[
|
|
93
|
+
`[ppmbot] Poll error (retry ${this.retryCount}): ${(err as Error).message}. Retrying in ${delay}ms`,
|
|
66
94
|
);
|
|
67
95
|
await Bun.sleep(delay);
|
|
68
96
|
}
|
|
69
97
|
}
|
|
70
98
|
|
|
71
|
-
console.log("[
|
|
99
|
+
console.log("[ppmbot] Polling stopped");
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
/** Stop polling gracefully */
|
|
@@ -128,12 +156,12 @@ export class ClawBotTelegram {
|
|
|
128
156
|
});
|
|
129
157
|
const json = (await res.json()) as { ok: boolean; result?: TelegramSentMessage; description?: string };
|
|
130
158
|
if (!json.ok) {
|
|
131
|
-
console.error(`[
|
|
159
|
+
console.error(`[ppmbot] sendMessage failed: ${json.description}`);
|
|
132
160
|
return null;
|
|
133
161
|
}
|
|
134
162
|
return json.result ?? null;
|
|
135
163
|
} catch (err) {
|
|
136
|
-
console.error(`[
|
|
164
|
+
console.error(`[ppmbot] sendMessage error: ${(err as Error).message}`);
|
|
137
165
|
return null;
|
|
138
166
|
}
|
|
139
167
|
}
|
|
@@ -163,12 +191,12 @@ export class ClawBotTelegram {
|
|
|
163
191
|
const json = (await res.json()) as { ok: boolean; description?: string };
|
|
164
192
|
if (!json.ok) {
|
|
165
193
|
if (json.description?.includes("not modified")) return true;
|
|
166
|
-
console.error(`[
|
|
194
|
+
console.error(`[ppmbot] editMessage failed: ${json.description}`);
|
|
167
195
|
return false;
|
|
168
196
|
}
|
|
169
197
|
return true;
|
|
170
198
|
} catch (err) {
|
|
171
|
-
console.error(`[
|
|
199
|
+
console.error(`[ppmbot] editMessage error: ${(err as Error).message}`);
|
|
172
200
|
return false;
|
|
173
201
|
}
|
|
174
202
|
}
|
|
@@ -211,8 +239,8 @@ export class ClawBotTelegram {
|
|
|
211
239
|
|
|
212
240
|
// ── Command Parsing ─────────────────────────────────────────────
|
|
213
241
|
|
|
214
|
-
/** Parse a Telegram message into a
|
|
215
|
-
static parseCommand(message: TelegramMessage):
|
|
242
|
+
/** Parse a Telegram message into a PPMBotCommand if it starts with / */
|
|
243
|
+
static parseCommand(message: TelegramMessage): PPMBotCommand | null {
|
|
216
244
|
const text = message.text ?? message.caption ?? "";
|
|
217
245
|
if (!text.startsWith("/")) return null;
|
|
218
246
|
|
package/src/types/config.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface TelegramConfig {
|
|
|
9
9
|
chat_id: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface PPMBotConfig {
|
|
13
13
|
enabled: boolean;
|
|
14
14
|
default_provider: string;
|
|
15
15
|
default_project: string;
|
|
@@ -32,7 +32,7 @@ export interface PpmConfig {
|
|
|
32
32
|
ai: AIConfig;
|
|
33
33
|
push?: PushConfig;
|
|
34
34
|
telegram?: TelegramConfig;
|
|
35
|
-
clawbot?:
|
|
35
|
+
clawbot?: PPMBotConfig;
|
|
36
36
|
cloud_url?: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -105,7 +105,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
105
105
|
enabled: false,
|
|
106
106
|
default_provider: "claude",
|
|
107
107
|
default_project: "",
|
|
108
|
-
system_prompt: "",
|
|
108
|
+
system_prompt: "You are PPMBot, a helpful AI coding assistant on Telegram. Keep responses concise and mobile-friendly. Use short paragraphs. When showing code, use compact examples. Be direct and helpful.",
|
|
109
109
|
show_tool_calls: true,
|
|
110
110
|
show_thinking: false,
|
|
111
111
|
permission_mode: "bypassPermissions",
|