@hienlh/ppm 0.9.41 → 0.9.42
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 +67 -1
- package/dist/web/assets/{chat-tab-CrkhvVjF.js → chat-tab-BSQOFDle.js} +2 -2
- package/dist/web/assets/{code-editor-BfMyExLp.js → code-editor-eDYb_XML.js} +2 -2
- package/dist/web/assets/{csv-preview--ZSEumXf.js → csv-preview-sx6DC51G.js} +1 -1
- package/dist/web/assets/{database-viewer-CeRUrZKj.js → database-viewer-nP78XqEF.js} +1 -1
- package/dist/web/assets/{diff-viewer-D2p3WTMS.js → diff-viewer-DTMtBxHM.js} +1 -1
- package/dist/web/assets/{extension-webview-DQWAHMlR.js → extension-webview-DzWz--CI.js} +1 -1
- package/dist/web/assets/{git-graph-BWRMlCdK.js → git-graph-D_6NTVVT.js} +1 -1
- package/dist/web/assets/index-BEfMoc_W.css +2 -0
- package/dist/web/assets/index-D48IQVYU.js +30 -0
- package/dist/web/assets/keybindings-store-BaWyhjXJ.js +1 -0
- package/dist/web/assets/{markdown-renderer-C7lKs47M.js → markdown-renderer-CxJg37If.js} +1 -1
- package/dist/web/assets/port-forwarding-tab-DBBJ3z8x.js +1 -0
- package/dist/web/assets/{postgres-viewer-Cr9jpBNd.js → postgres-viewer-CQ3coJ1p.js} +1 -1
- package/dist/web/assets/{settings-tab-DKy-YDg2.js → settings-tab-CE8H5NiY.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-9AmeF-Zs.js → sqlite-viewer-Ccm-un47.js} +1 -1
- package/dist/web/assets/{terminal-tab-DFhB4Rxh.js → terminal-tab-DnlFNbY6.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-B7XLw-OX.js → use-monaco-theme-hwg4tMW2.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/system-architecture.md +65 -2
- package/package.json +1 -1
- package/src/server/index.ts +10 -3
- package/src/server/routes/{browser-preview.ts → port-forwarding.ts} +7 -7
- package/src/server/routes/settings.ts +83 -17
- package/src/services/config.service.ts +1 -1
- package/src/services/db.service.ts +285 -1
- package/src/services/ppmbot/ppmbot-formatter.ts +88 -0
- package/src/services/ppmbot/ppmbot-memory.ts +333 -0
- package/src/services/ppmbot/ppmbot-service.ts +545 -0
- package/src/services/ppmbot/ppmbot-session.ts +199 -0
- package/src/services/ppmbot/ppmbot-streamer.ts +288 -0
- package/src/services/ppmbot/ppmbot-telegram.ts +279 -0
- package/src/services/telegram-notification.service.ts +44 -21
- package/src/types/config.ts +25 -1
- package/src/types/ppmbot.ts +103 -0
- package/src/web/components/chat/chat-history-bar.tsx +8 -3
- package/src/web/components/layout/command-palette.tsx +1 -1
- package/src/web/components/layout/editor-panel.tsx +1 -1
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/tab-bar.tsx +1 -1
- package/src/web/components/layout/tab-content.tsx +3 -3
- package/src/web/components/{browser/browser-tab.tsx → ports/port-forwarding-tab.tsx} +5 -5
- package/src/web/components/settings/ppmbot-settings-section.tsx +355 -0
- package/src/web/components/settings/settings-tab.tsx +10 -5
- package/src/web/hooks/use-url-sync.ts +3 -3
- package/src/web/stores/panel-utils.ts +2 -2
- package/src/web/stores/tab-store.ts +1 -1
- package/dist/web/assets/browser-tab--V6I70pH.js +0 -1
- package/dist/web/assets/index-C7esr4gM.css +0 -2
- package/dist/web/assets/index-DU6UVgQY.js +0 -30
- package/dist/web/assets/keybindings-store-BE2T8jM9.js +0 -1
- /package/dist/web/assets/{dist-DKlZwvf8.js → dist-C40JmyoH.js} +0 -0
- /package/dist/web/assets/{lib-BeaDXEkP.js → lib-mag4ySk-.js} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TelegramUpdate,
|
|
3
|
+
TelegramMessage,
|
|
4
|
+
TelegramSentMessage,
|
|
5
|
+
PPMBotCommand,
|
|
6
|
+
} from "../../types/ppmbot.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 PPMBot 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 PPMBotTelegram {
|
|
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
|
+
/** 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
|
+
|
|
64
|
+
/** Start long-polling loop. Calls handler for each update. */
|
|
65
|
+
async startPolling(handler: UpdateHandler): Promise<void> {
|
|
66
|
+
if (this.running) return;
|
|
67
|
+
this.running = true;
|
|
68
|
+
this.retryCount = 0;
|
|
69
|
+
|
|
70
|
+
// Register commands on startup
|
|
71
|
+
await this.registerCommands();
|
|
72
|
+
|
|
73
|
+
console.log("[ppmbot] Polling started");
|
|
74
|
+
|
|
75
|
+
while (this.running) {
|
|
76
|
+
try {
|
|
77
|
+
const updates = await this.getUpdates();
|
|
78
|
+
this.retryCount = 0;
|
|
79
|
+
|
|
80
|
+
for (const update of updates) {
|
|
81
|
+
this.offset = update.update_id + 1;
|
|
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
|
+
});
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (!this.running) break;
|
|
90
|
+
this.retryCount++;
|
|
91
|
+
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000);
|
|
92
|
+
console.error(
|
|
93
|
+
`[ppmbot] Poll error (retry ${this.retryCount}): ${(err as Error).message}. Retrying in ${delay}ms`,
|
|
94
|
+
);
|
|
95
|
+
await Bun.sleep(delay);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log("[ppmbot] Polling stopped");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stop polling gracefully */
|
|
103
|
+
stop(): void {
|
|
104
|
+
this.running = false;
|
|
105
|
+
this.abortController?.abort();
|
|
106
|
+
this.lastEditTime.clear();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get isRunning(): boolean {
|
|
110
|
+
return this.running;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Telegram API Methods ────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Fetch updates via long-polling */
|
|
116
|
+
private async getUpdates(): Promise<TelegramUpdate[]> {
|
|
117
|
+
this.abortController = new AbortController();
|
|
118
|
+
const fetchTimeout = setTimeout(
|
|
119
|
+
() => this.abortController?.abort(),
|
|
120
|
+
(POLL_TIMEOUT + 10) * 1000,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(`${TELEGRAM_API}${this.token}/getUpdates`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
offset: this.offset,
|
|
129
|
+
timeout: POLL_TIMEOUT,
|
|
130
|
+
allowed_updates: ["message"],
|
|
131
|
+
}),
|
|
132
|
+
signal: this.abortController.signal,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const json = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] };
|
|
136
|
+
if (!json.ok || !json.result) return [];
|
|
137
|
+
return json.result;
|
|
138
|
+
} finally {
|
|
139
|
+
clearTimeout(fetchTimeout);
|
|
140
|
+
this.abortController = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Send a text message */
|
|
145
|
+
async sendMessage(
|
|
146
|
+
chatId: number | string,
|
|
147
|
+
text: string,
|
|
148
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
149
|
+
): Promise<TelegramSentMessage | null> {
|
|
150
|
+
try {
|
|
151
|
+
const res = await this.callApi("sendMessage", {
|
|
152
|
+
chat_id: chatId,
|
|
153
|
+
text,
|
|
154
|
+
parse_mode: parseMode,
|
|
155
|
+
disable_web_page_preview: true,
|
|
156
|
+
});
|
|
157
|
+
const json = (await res.json()) as { ok: boolean; result?: TelegramSentMessage; description?: string };
|
|
158
|
+
if (!json.ok) {
|
|
159
|
+
console.error(`[ppmbot] sendMessage failed: ${json.description}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return json.result ?? null;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`[ppmbot] sendMessage error: ${(err as Error).message}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Edit an existing message text (throttled at 1s intervals) */
|
|
170
|
+
async editMessage(
|
|
171
|
+
chatId: number | string,
|
|
172
|
+
messageId: number,
|
|
173
|
+
text: string,
|
|
174
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
175
|
+
): Promise<boolean> {
|
|
176
|
+
const key = `${chatId}:${messageId}`;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const lastEdit = this.lastEditTime.get(key) ?? 0;
|
|
179
|
+
if (now - lastEdit < MIN_EDIT_INTERVAL) return false;
|
|
180
|
+
|
|
181
|
+
this.lastEditTime.set(key, now);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const res = await this.callApi("editMessageText", {
|
|
185
|
+
chat_id: chatId,
|
|
186
|
+
message_id: messageId,
|
|
187
|
+
text,
|
|
188
|
+
parse_mode: parseMode,
|
|
189
|
+
disable_web_page_preview: true,
|
|
190
|
+
});
|
|
191
|
+
const json = (await res.json()) as { ok: boolean; description?: string };
|
|
192
|
+
if (!json.ok) {
|
|
193
|
+
if (json.description?.includes("not modified")) return true;
|
|
194
|
+
console.error(`[ppmbot] editMessage failed: ${json.description}`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error(`[ppmbot] editMessage error: ${(err as Error).message}`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Force-edit (bypass throttle) — used for final message */
|
|
205
|
+
async editMessageFinal(
|
|
206
|
+
chatId: number | string,
|
|
207
|
+
messageId: number,
|
|
208
|
+
text: string,
|
|
209
|
+
parseMode: "HTML" | "Markdown" = "HTML",
|
|
210
|
+
): Promise<boolean> {
|
|
211
|
+
const key = `${chatId}:${messageId}`;
|
|
212
|
+
this.lastEditTime.delete(key);
|
|
213
|
+
return this.editMessage(chatId, messageId, text, parseMode);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Send "typing" chat action */
|
|
217
|
+
async sendTyping(chatId: number | string): Promise<void> {
|
|
218
|
+
try {
|
|
219
|
+
await this.callApi("sendChatAction", {
|
|
220
|
+
chat_id: chatId,
|
|
221
|
+
action: "typing",
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
// Best-effort, ignore errors
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Delete a message */
|
|
229
|
+
async deleteMessage(chatId: number | string, messageId: number): Promise<void> {
|
|
230
|
+
try {
|
|
231
|
+
await this.callApi("deleteMessage", {
|
|
232
|
+
chat_id: chatId,
|
|
233
|
+
message_id: messageId,
|
|
234
|
+
});
|
|
235
|
+
} catch {
|
|
236
|
+
// Best-effort
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Command Parsing ─────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/** Parse a Telegram message into a PPMBotCommand if it starts with / */
|
|
243
|
+
static parseCommand(message: TelegramMessage): PPMBotCommand | null {
|
|
244
|
+
const text = message.text ?? message.caption ?? "";
|
|
245
|
+
if (!text.startsWith("/")) return null;
|
|
246
|
+
|
|
247
|
+
const match = text.match(/^\/(\w+)(?:@\S+)?\s*(.*)/s);
|
|
248
|
+
if (!match) return null;
|
|
249
|
+
|
|
250
|
+
const command = match[1]!.toLowerCase();
|
|
251
|
+
if (!COMMANDS.has(command)) return null;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
command,
|
|
255
|
+
args: match[2]?.trim() ?? "",
|
|
256
|
+
chatId: message.chat.id,
|
|
257
|
+
messageId: message.message_id,
|
|
258
|
+
userId: message.from?.id ?? 0,
|
|
259
|
+
username: message.from?.username,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Private Helpers ─────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
private async callApi(method: string, body: Record<string, unknown>): Promise<Response> {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
268
|
+
try {
|
|
269
|
+
return await fetch(`${TELEGRAM_API}${this.token}/${method}`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify(body),
|
|
273
|
+
signal: controller.signal,
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(timeout);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { configService } from "./config.service.ts";
|
|
2
2
|
import { tunnelService } from "./tunnel.service.ts";
|
|
3
3
|
import { getLocalIp } from "../lib/network-utils.ts";
|
|
4
|
+
import { getApprovedPairedChats } from "./db.service.ts";
|
|
4
5
|
import type { TelegramConfig } from "../types/config.ts";
|
|
5
6
|
import type { NotificationPayload } from "./notification.service.ts";
|
|
6
7
|
|
|
@@ -12,12 +13,15 @@ function escapeHtml(str: string): string {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
class TelegramNotificationService {
|
|
15
|
-
/** Send notification to
|
|
16
|
+
/** Send notification to all approved paired chats. No-op if not configured. */
|
|
16
17
|
async send(payload: NotificationPayload): Promise<void> {
|
|
17
18
|
const config = configService.get("telegram") as TelegramConfig | undefined;
|
|
18
|
-
if (!config?.bot_token
|
|
19
|
+
if (!config?.bot_token) return;
|
|
19
20
|
if (!BOT_TOKEN_RE.test(config.bot_token)) return;
|
|
20
21
|
|
|
22
|
+
const approvedChats = getApprovedPairedChats();
|
|
23
|
+
if (approvedChats.length === 0) return;
|
|
24
|
+
|
|
21
25
|
const deviceName = (configService.get("device_name") as string) || "PPM";
|
|
22
26
|
const deepLink = this.buildDeepLink(payload);
|
|
23
27
|
|
|
@@ -27,31 +31,50 @@ class TelegramNotificationService {
|
|
|
27
31
|
text += `\n\n<a href="${deepLink}">Open in PPM</a>`;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
// Send to all approved paired chats in parallel
|
|
35
|
+
await Promise.allSettled(
|
|
36
|
+
approvedChats.map((chat) =>
|
|
37
|
+
this.callApi(config.bot_token, chat.telegram_chat_id, text),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
/** Send a test message. Returns { ok, error? } */
|
|
34
|
-
async sendTest(botToken: string
|
|
42
|
+
/** Send a test message to all approved paired chats. Returns { ok, error? } */
|
|
43
|
+
async sendTest(botToken: string): Promise<{ ok: boolean; error?: string }> {
|
|
35
44
|
if (!BOT_TOKEN_RE.test(botToken)) return { ok: false, error: "Invalid bot token format" };
|
|
45
|
+
|
|
46
|
+
const approvedChats = getApprovedPairedChats();
|
|
47
|
+
if (approvedChats.length === 0) {
|
|
48
|
+
return { ok: false, error: "No approved paired chats. Pair a device in PPMBot settings first." };
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const deviceName = (configService.get("device_name") as string) || "PPM";
|
|
37
52
|
const text = `<b>${escapeHtml(deviceName)} — Test</b>\nTelegram notifications are working!`;
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
|
|
54
|
+
const results = await Promise.allSettled(
|
|
55
|
+
approvedChats.map(async (chat) => {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ chat_id: chat.telegram_chat_id, text, parse_mode: "HTML" }),
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
const json = (await res.json()) as { ok: boolean; description?: string };
|
|
66
|
+
if (!json.ok) throw new Error(json.description || "Unknown error");
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const failed = results.filter((r) => r.status === "rejected");
|
|
74
|
+
if (failed.length === results.length) {
|
|
75
|
+
return { ok: false, error: (failed[0] as PromiseRejectedResult).reason?.message || "All sends failed" };
|
|
54
76
|
}
|
|
77
|
+
return { ok: true };
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
private buildDeepLink(payload: NotificationPayload): string | null {
|
package/src/types/config.ts
CHANGED
|
@@ -6,7 +6,17 @@ export interface PushConfig {
|
|
|
6
6
|
|
|
7
7
|
export interface TelegramConfig {
|
|
8
8
|
bot_token: string;
|
|
9
|
-
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PPMBotConfig {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
default_provider: string;
|
|
14
|
+
default_project: string;
|
|
15
|
+
system_prompt: string;
|
|
16
|
+
show_tool_calls: boolean;
|
|
17
|
+
show_thinking: boolean;
|
|
18
|
+
permission_mode: string;
|
|
19
|
+
debounce_ms: number;
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export type ThemeConfig = "light" | "dark" | "system";
|
|
@@ -21,6 +31,7 @@ export interface PpmConfig {
|
|
|
21
31
|
ai: AIConfig;
|
|
22
32
|
push?: PushConfig;
|
|
23
33
|
telegram?: TelegramConfig;
|
|
34
|
+
clawbot?: PPMBotConfig;
|
|
24
35
|
cloud_url?: string;
|
|
25
36
|
}
|
|
26
37
|
|
|
@@ -85,6 +96,19 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
85
96
|
},
|
|
86
97
|
},
|
|
87
98
|
},
|
|
99
|
+
telegram: {
|
|
100
|
+
bot_token: "",
|
|
101
|
+
},
|
|
102
|
+
clawbot: {
|
|
103
|
+
enabled: false,
|
|
104
|
+
default_provider: "claude",
|
|
105
|
+
default_project: "",
|
|
106
|
+
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.",
|
|
107
|
+
show_tool_calls: true,
|
|
108
|
+
show_thinking: false,
|
|
109
|
+
permission_mode: "bypassPermissions",
|
|
110
|
+
debounce_ms: 2000,
|
|
111
|
+
},
|
|
88
112
|
};
|
|
89
113
|
|
|
90
114
|
const VALID_TYPES = ["agent-sdk", "cli", "mock"] as const;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Telegram update object (subset we care about) */
|
|
2
|
+
export interface TelegramUpdate {
|
|
3
|
+
update_id: number;
|
|
4
|
+
message?: TelegramMessage;
|
|
5
|
+
edited_message?: TelegramMessage;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TelegramMessage {
|
|
9
|
+
message_id: number;
|
|
10
|
+
from?: { id: number; first_name: string; username?: string };
|
|
11
|
+
chat: { id: number; type: "private" | "group" | "supergroup" };
|
|
12
|
+
date: number;
|
|
13
|
+
text?: string;
|
|
14
|
+
caption?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Sent message result from Telegram API */
|
|
18
|
+
export interface TelegramSentMessage {
|
|
19
|
+
message_id: number;
|
|
20
|
+
chat: { id: number };
|
|
21
|
+
date: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** PPMBot session row from SQLite */
|
|
25
|
+
export interface PPMBotSessionRow {
|
|
26
|
+
id: number;
|
|
27
|
+
telegram_chat_id: string;
|
|
28
|
+
session_id: string;
|
|
29
|
+
provider_id: string;
|
|
30
|
+
project_name: string;
|
|
31
|
+
project_path: string;
|
|
32
|
+
is_active: number;
|
|
33
|
+
created_at: number;
|
|
34
|
+
last_message_at: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PPMBot memory row from SQLite */
|
|
38
|
+
export interface PPMBotMemoryRow {
|
|
39
|
+
id: number;
|
|
40
|
+
project: string;
|
|
41
|
+
content: string;
|
|
42
|
+
category: PPMBotMemoryCategory;
|
|
43
|
+
importance: number;
|
|
44
|
+
created_at: number;
|
|
45
|
+
updated_at: number;
|
|
46
|
+
session_id: string | null;
|
|
47
|
+
superseded_by: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PPMBotMemoryCategory =
|
|
51
|
+
| "fact"
|
|
52
|
+
| "decision"
|
|
53
|
+
| "preference"
|
|
54
|
+
| "architecture"
|
|
55
|
+
| "issue";
|
|
56
|
+
|
|
57
|
+
/** Active session state tracked in memory (not DB) */
|
|
58
|
+
export interface PPMBotActiveSession {
|
|
59
|
+
telegramChatId: string;
|
|
60
|
+
sessionId: string;
|
|
61
|
+
providerId: string;
|
|
62
|
+
projectName: string;
|
|
63
|
+
projectPath: string;
|
|
64
|
+
/** Telegram message ID being edited for streaming */
|
|
65
|
+
currentMessageId?: number;
|
|
66
|
+
/** Debounce timer for rapid messages */
|
|
67
|
+
debounceTimer?: ReturnType<typeof setTimeout>;
|
|
68
|
+
/** Accumulated debounced text */
|
|
69
|
+
debouncedText?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parsed command from Telegram message */
|
|
73
|
+
export interface PPMBotCommand {
|
|
74
|
+
command: string;
|
|
75
|
+
args: string;
|
|
76
|
+
chatId: number;
|
|
77
|
+
messageId: number;
|
|
78
|
+
userId: number;
|
|
79
|
+
username?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Memory recall result with relevance score */
|
|
83
|
+
export interface MemoryRecallResult {
|
|
84
|
+
id: number;
|
|
85
|
+
content: string;
|
|
86
|
+
category: PPMBotMemoryCategory;
|
|
87
|
+
importance: number;
|
|
88
|
+
project: string;
|
|
89
|
+
/** FTS5 rank score (lower = more relevant) */
|
|
90
|
+
rank?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Paired chat row from SQLite */
|
|
94
|
+
export interface PPMBotPairedChat {
|
|
95
|
+
id: number;
|
|
96
|
+
telegram_chat_id: string;
|
|
97
|
+
telegram_user_id: string | null;
|
|
98
|
+
display_name: string | null;
|
|
99
|
+
pairing_code: string | null;
|
|
100
|
+
status: "pending" | "approved" | "revoked";
|
|
101
|
+
created_at: number;
|
|
102
|
+
approved_at: number | null;
|
|
103
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users } from "lucide-react";
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot } from "lucide-react";
|
|
3
3
|
import { Activity } from "lucide-react";
|
|
4
4
|
import { api, projectUrl } from "@/lib/api-client";
|
|
5
5
|
import { useTabStore } from "@/stores/tab-store";
|
|
@@ -398,9 +398,14 @@ export function ChatHistoryBar({
|
|
|
398
398
|
<>
|
|
399
399
|
<button
|
|
400
400
|
onClick={() => openSession(session)}
|
|
401
|
-
className="text-[11px] truncate flex-1 text-left"
|
|
401
|
+
className="text-[11px] truncate flex-1 text-left flex items-center gap-1"
|
|
402
402
|
>
|
|
403
|
-
{session.title
|
|
403
|
+
{session.title?.startsWith("[PPM]") && (
|
|
404
|
+
<Bot className="size-3 text-muted-foreground shrink-0" />
|
|
405
|
+
)}
|
|
406
|
+
{session.title?.startsWith("[PPM]")
|
|
407
|
+
? session.title.slice(7)
|
|
408
|
+
: session.title || "Untitled"}
|
|
404
409
|
</button>
|
|
405
410
|
<button
|
|
406
411
|
onClick={(e) => togglePin(e, session)}
|
|
@@ -161,7 +161,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
161
161
|
{ id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action", shortcut: formatShortcut(getBinding("open-chat")) },
|
|
162
162
|
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
|
|
163
163
|
{ id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action", shortcut: formatShortcut(getBinding("open-git-graph")) },
|
|
164
|
-
{ id: "
|
|
164
|
+
{ id: "ports", label: "Port Forwarding", icon: Globe, action: openNewTab("ports", "Ports"), keywords: "web preview localhost port forward tunnel url", group: "action" },
|
|
165
165
|
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
166
166
|
{ id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
|
|
167
167
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
|
|
@@ -25,7 +25,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
25
25
|
"git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
|
|
26
26
|
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
27
27
|
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
28
|
-
|
|
28
|
+
ports: lazy(() => import("@/components/ports/port-forwarding-tab").then((m) => ({ default: m.PortForwardingTab }))),
|
|
29
29
|
"extension-webview": lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -25,7 +25,7 @@ const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_
|
|
|
25
25
|
|
|
26
26
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
27
27
|
terminal: Terminal, chat: MessageSquare, editor: FileCode, database: Database, sqlite: Database, postgres: Database,
|
|
28
|
-
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
|
|
28
|
+
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings, ports: Globe,
|
|
29
29
|
"extension-webview": Puzzle,
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -48,9 +48,9 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
48
48
|
default: m.SettingsTab,
|
|
49
49
|
})),
|
|
50
50
|
),
|
|
51
|
-
|
|
52
|
-
import("@/components/
|
|
53
|
-
default: m.
|
|
51
|
+
ports: lazy(() =>
|
|
52
|
+
import("@/components/ports/port-forwarding-tab").then((m) => ({
|
|
53
|
+
default: m.PortForwardingTab,
|
|
54
54
|
})),
|
|
55
55
|
),
|
|
56
56
|
"extension-webview": lazy(() =>
|
|
@@ -9,7 +9,7 @@ interface TunnelInfo {
|
|
|
9
9
|
startedAt: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function PortForwardingTab() {
|
|
13
13
|
const [portInput, setPortInput] = useState("");
|
|
14
14
|
const [tunnels, setTunnels] = useState<TunnelInfo[]>([]);
|
|
15
15
|
const [loading, setLoading] = useState(false);
|
|
@@ -86,11 +86,11 @@ export function BrowserTab() {
|
|
|
86
86
|
{/* Header + form */}
|
|
87
87
|
<div className="p-4 md:p-6 border-b border-border bg-surface">
|
|
88
88
|
<div className="flex items-center gap-2 mb-3">
|
|
89
|
-
<Wifi className="size-5 text-
|
|
89
|
+
<Wifi className="size-5 text-primary" />
|
|
90
90
|
<h2 className="text-base font-medium text-text-primary">Port Forwarding</h2>
|
|
91
91
|
</div>
|
|
92
92
|
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
|
93
|
-
<div className="flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg bg-background border border-border focus-within:border-
|
|
93
|
+
<div className="flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg bg-background border border-border focus-within:border-primary/50 transition-colors">
|
|
94
94
|
<span className="text-sm text-text-subtle shrink-0">localhost:</span>
|
|
95
95
|
<input
|
|
96
96
|
type="number"
|
|
@@ -105,7 +105,7 @@ export function BrowserTab() {
|
|
|
105
105
|
<button
|
|
106
106
|
type="submit"
|
|
107
107
|
disabled={loading || !portInput}
|
|
108
|
-
className="px-4 py-2.5 rounded-lg bg-
|
|
108
|
+
className="px-4 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors shrink-0 min-w-[72px] flex items-center justify-center"
|
|
109
109
|
>
|
|
110
110
|
{loading ? <Loader2 className="size-4 animate-spin" /> : "Forward"}
|
|
111
111
|
</button>
|
|
@@ -136,7 +136,7 @@ export function BrowserTab() {
|
|
|
136
136
|
className="flex items-center gap-3 p-3 rounded-lg bg-surface border border-border"
|
|
137
137
|
>
|
|
138
138
|
{/* Port badge */}
|
|
139
|
-
<div className="shrink-0 px-2 py-1 rounded bg-
|
|
139
|
+
<div className="shrink-0 px-2 py-1 rounded bg-primary/10 text-primary text-xs font-mono font-medium">
|
|
140
140
|
:{t.port}
|
|
141
141
|
</div>
|
|
142
142
|
|