@hienlh/ppm 0.9.31 → 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/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +16 -0
- package/dist/web/assets/{browser-tab-DmDrxklj.js → browser-tab-B9nNKjZX.js} +1 -1
- package/dist/web/assets/{chat-tab-CMwOy57v.js → chat-tab-6XGhEKaC.js} +2 -2
- package/dist/web/assets/{code-editor-jsL0PK8A.js → code-editor-DMZMpzt2.js} +1 -1
- package/dist/web/assets/{database-viewer-CBo5yPV-.js → database-viewer-CnP1FFS2.js} +1 -1
- package/dist/web/assets/{diff-viewer-Dk-plEOm.js → diff-viewer-Cvwd0XBO.js} +1 -1
- package/dist/web/assets/{extension-webview-B0tE14-C.js → extension-webview-DkhsRepr.js} +1 -1
- package/dist/web/assets/{git-graph-BsYuai5I.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-lUfZhpU0.js → markdown-renderer-Co04dDdI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-sZclUhuS.js → postgres-viewer-D8K1qnnA.js} +1 -1
- package/dist/web/assets/{settings-tab-CvbLGbR6.js → settings-tab-64ODAeQZ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BAjul3Ct.js → sqlite-viewer-ClX7FICB.js} +1 -1
- package/dist/web/assets/{terminal-tab-Ds9ymO7D.js → terminal-tab-Dw4IKWGM.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-D9bFLaXR.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/system-architecture.md +65 -2
- package/package.json +1 -1
- package/src/server/index.ts +7 -0
- package/src/server/routes/proxy.ts +15 -0
- package/src/server/routes/settings.ts +74 -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/proxy-openai-bridge.ts +241 -0
- package/src/services/proxy-sdk-bridge.ts +63 -21
- package/src/services/proxy.service.ts +33 -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/proxy-settings-section.tsx +50 -37
- package/src/web/components/settings/proxy-test-section.tsx +48 -25
- package/src/web/components/settings/settings-tab.tsx +4 -1
- package/src/web/lib/api-settings.ts +2 -0
- package/dist/web/assets/index-CJvp0DJT.css +0 -2
- package/dist/web/assets/index-DMiaze7L.js +0 -37
- package/dist/web/assets/keybindings-store-B01E0k20.js +0 -1
package/docs/project-roadmap.md
CHANGED
|
@@ -109,14 +109,15 @@ PPM is the **lightest path from phone to code** — a self-hosted, BYOK, multi-d
|
|
|
109
109
|
|
|
110
110
|
### v0.11.0 — "Intelligence" (Q3–Q4 2026)
|
|
111
111
|
|
|
112
|
-
**Theme:** Event system + PPM's own AI layer. Hooks → Skills API → Clawbot dependency chain.
|
|
112
|
+
**Theme:** Event system + PPM's own AI layer + Telegram bot. Hooks → Skills API → Clawbot dependency chain.
|
|
113
113
|
|
|
114
|
-
| Feature | Priority | Description |
|
|
115
|
-
|
|
116
|
-
| **Hooks system** | High | Event hooks for PPM lifecycle (file save, git commit, chat message, etc.). Foundation for Skills API and deeper extension integration. |
|
|
117
|
-
| **PPM Skills API** | High | Stable internal API for AI to control PPM: file.read/write/search, terminal.run, git.status/commit/diff, db.query, editor.open/goto, project.switch. Skills are the bridge between AI and PPM features. |
|
|
118
|
-
| **
|
|
119
|
-
| **
|
|
114
|
+
| Feature | Priority | Status | Description |
|
|
115
|
+
|---------|----------|--------|-------------|
|
|
116
|
+
| **Hooks system** | High | — | Event hooks for PPM lifecycle (file save, git commit, chat message, etc.). Foundation for Skills API and deeper extension integration. |
|
|
117
|
+
| **PPM Skills API** | High | — | Stable internal API for AI to control PPM: file.read/write/search, terminal.run, git.status/commit/diff, db.query, editor.open/goto, project.switch. Skills are the bridge between AI and PPM features. |
|
|
118
|
+
| **Telegram Bot (ClawBot)** | High | ✅ Done (v0.9.10) | Long-polling Telegram bot with session routing, FTS5 memory system, progressive message editing, pairing-based security. Routes through existing providers (Claude SDK, Cursor). No external bot infrastructure. |
|
|
119
|
+
| **Built-in Clawbot** | High | — | Lightweight AI agent built into PPM using Anthropic Messages API (not Agent SDK). Uses Skills API + MCP tools. Instant response, no external CLI deps. For quick tasks: file search, code explanation, simple refactors. |
|
|
120
|
+
| **More providers** | Medium | — | Gemini CLI (Tier 2), OpenAI Codex (Tier 2), Tier 3 chat-only (any OpenAI-compatible API). Provider interface already clean from v0.9. |
|
|
120
121
|
|
|
121
122
|
**Built-in Clawbot — why it matters:**
|
|
122
123
|
- Claude Agent SDK spawns subprocess — heavy, slow startup, requires CLI installed
|
|
@@ -124,6 +125,12 @@ PPM is the **lightest path from phone to code** — a self-hosted, BYOK, multi-d
|
|
|
124
125
|
- Users can use Clawbot to create extensions → zero-friction extension authoring
|
|
125
126
|
- Foundation for "AI creates extensions on demand" vision
|
|
126
127
|
|
|
128
|
+
**Telegram Bot — why it matters:**
|
|
129
|
+
- Extends PPM beyond the web UI → reach users on mobile, messaging apps
|
|
130
|
+
- Long-polling avoids webhooks — simpler for self-hosted, no public URL needed
|
|
131
|
+
- Paired devices enable secure multi-user access — owner controls who can use the bot
|
|
132
|
+
- Persistent memory in FTS5 — bot learns conversation context over time
|
|
133
|
+
|
|
127
134
|
---
|
|
128
135
|
|
|
129
136
|
### v1.0.0 — "Production Ready" (Q4 2026)
|
|
@@ -193,8 +193,14 @@ Tab IDs are deterministic: `{type}:{identifier}` (e.g., `editor:src/index.ts`, `
|
|
|
193
193
|
| **AccountService** | Account CRUD, token encryption/decryption | getAccounts, createAccount, updateAccount, deleteAccount |
|
|
194
194
|
| **AccountSelectorService** | Select active account based on config | getActiveAccount, setActiveAccount, selectByProject |
|
|
195
195
|
| **UpgradeService** | Version checking, installation, self-replace signaling | checkForUpdate, applyUpgrade, getInstallMethod, compareSemver |
|
|
196
|
+
| **ClawBotService** | Telegram bot orchestrator | start, stop, handleMessage, routeToChat |
|
|
197
|
+
| **ClawBotTelegramService** | Telegram long-polling, message ops | getUpdates, sendMessage, editMessage, setTyping, handleCommands |
|
|
198
|
+
| **ClawBotSessionService** | chatID → PPM sessionID mapping | mapSession, getSession, deleteSession |
|
|
199
|
+
| **ClawBotMemoryService** | FTS5 memory persistence | saveMemory, recallMemories, decayMemories, searchByProject |
|
|
200
|
+
| **ClawBotFormatterService** | Markdown → Telegram HTML + chunking | formatMarkdown, chunkMessage |
|
|
201
|
+
| **ClawBotStreamerService** | ChatEvent → progressive Telegram edits | streamMessageEdits |
|
|
196
202
|
|
|
197
|
-
**Key Files:** `src/services/*.service.ts`
|
|
203
|
+
**Key Files:** `src/services/*.service.ts`, `src/services/clawbot/*.ts`
|
|
198
204
|
|
|
199
205
|
---
|
|
200
206
|
|
|
@@ -263,6 +269,63 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
|
|
|
263
269
|
|
|
264
270
|
---
|
|
265
271
|
|
|
272
|
+
### ClawBot Service Layer (Telegram Bot Integration)
|
|
273
|
+
**Component:** Telegram bot service + subsidiary services
|
|
274
|
+
|
|
275
|
+
**Responsibilities:**
|
|
276
|
+
- Receive Telegram messages via long-polling (no webhooks needed)
|
|
277
|
+
- Route Telegram user (chatID) to PPM session with pairing-based security
|
|
278
|
+
- Persist session state + conversation memory in SQLite (FTS5)
|
|
279
|
+
- Stream AI responses back to Telegram with progressive message editing
|
|
280
|
+
- Format responses as Telegram HTML with proper chunking (4096 char limit)
|
|
281
|
+
|
|
282
|
+
**Architecture:**
|
|
283
|
+
```
|
|
284
|
+
Telegram → ClawBotTelegramService (polling) → ClawBotService (orchestrator)
|
|
285
|
+
↓
|
|
286
|
+
ClawBotSessionService (chatID→sessionID)
|
|
287
|
+
ClawBotMemoryService (FTS5 recall)
|
|
288
|
+
ChatService + ProviderRegistry
|
|
289
|
+
ClawBotStreamerService (ChatEvent→edits)
|
|
290
|
+
ClawBotFormatterService (Markdown→HTML)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Services (src/services/clawbot/):**
|
|
294
|
+
- **ClawBotService** — Lifecycle management (start/stop), message queue, routing logic
|
|
295
|
+
- **ClawBotTelegramService** — Telegram Bot API wrapper (getUpdates long-polling, sendMessage, editMessage, setTyping, command handlers)
|
|
296
|
+
- **ClawBotSessionService** — chatID ↔ PPM sessionID bidirectional mapping, session state tracking
|
|
297
|
+
- **ClawBotMemoryService** — FTS5 persistent memory (save, recall with relevance, decay factor, supersede logic, cross-project search by name mention)
|
|
298
|
+
- **ClawBotFormatterService** — Markdown → Telegram HTML conversion, message chunking (respects 4096 char limit), code block formatting
|
|
299
|
+
- **ClawBotStreamerService** — ChatEvent async generator → progressive Telegram message edits (1s throttle for rate limiting)
|
|
300
|
+
|
|
301
|
+
**Security Model:**
|
|
302
|
+
- **Pairing System** — Replace allowlists with code-based pairing: User requests pairing → receives code → owner approves in web UI → chatID registered in `clawbot_paired_chats`
|
|
303
|
+
- **Per-User Sessions** — Each Telegram chatID maps to isolated PPM session (no cross-user interference)
|
|
304
|
+
- **bypassPermissions** — ClawBot bot runs headless, auto-approves tools (no manual approval flow)
|
|
305
|
+
|
|
306
|
+
**Database Schema (v13):**
|
|
307
|
+
- `clawbot_sessions` — chatID (PK), sessionID (FK chat_sessions), pairedAt, lastUsed
|
|
308
|
+
- `clawbot_memories` — id (PK), sessionID (FK), content (text), role (user|assistant), created, decay_factor (FTS5 full-text index)
|
|
309
|
+
- `clawbot_paired_chats` — chatID (PK), pairingCode (unique, 6 chars), approvedAt, approvedBy (user ID)
|
|
310
|
+
|
|
311
|
+
**Key Design Decisions:**
|
|
312
|
+
1. **Long-polling** — No webhooks = no public URL required, simpler for self-hosted
|
|
313
|
+
2. **Message queue** — Concurrent Telegram messages queued FIFO, prevents race conditions
|
|
314
|
+
3. **Progressive edits** — Edit same message for long responses, reduce Telegram API calls, better UX
|
|
315
|
+
4. **Memory system** — Hybrid extraction (AI primary + regex fallback), supports cross-project search by project name mention
|
|
316
|
+
5. **Config reuse** — Shares existing Telegram bot_token with notifications, separate ClawBotConfig section
|
|
317
|
+
6. **Session tagging** — [Claw] prefix visible in web UI without schema changes, robot icon for identification
|
|
318
|
+
|
|
319
|
+
**Settings Endpoints:**
|
|
320
|
+
- `GET /api/settings/clawbot` — Fetch config (enabled, bot token, default project, system prompt, debounce, display toggles)
|
|
321
|
+
- `PUT /api/settings/clawbot` — Update config
|
|
322
|
+
- `GET /api/clawbot/paired-chats` — List paired Telegram chatIDs
|
|
323
|
+
- `POST /api/clawbot/pairing` — Request pairing code (returns code)
|
|
324
|
+
- `POST /api/clawbot/pairing/:code/approve` — Approve pairing code (owner only)
|
|
325
|
+
- `DELETE /api/clawbot/paired-chats/:chatId` — Revoke pairing
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
266
329
|
### Data Access Layer (SQLite + Filesystem + Git)
|
|
267
330
|
**Components:** SQLite via bun:sqlite, direct filesystem access, simple-git wrapper
|
|
268
331
|
|
|
@@ -274,7 +337,7 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
|
|
|
274
337
|
- Enforce security (no parent directory access)
|
|
275
338
|
|
|
276
339
|
**Key Patterns:**
|
|
277
|
-
- SQLite: WAL mode, foreign keys, lazy init, schema
|
|
340
|
+
- SQLite: WAL mode, foreign keys, lazy init, schema v13 (13 tables: config, connections, accounts, usage_history, session_logs, push_subscriptions, session_map, table_metadata, workspace_state, extension_storage, mcp_servers, clawbot_sessions, clawbot_memories, clawbot_paired_chats)
|
|
278
341
|
- Path validation: `projectPath/relativePath` only, reject `..`
|
|
279
342
|
- Caching: Directory trees cached with TTL
|
|
280
343
|
- Error handling: Descriptive messages (file not found, permission denied)
|
package/package.json
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -477,5 +477,12 @@ if (process.argv.includes("__serve__")) {
|
|
|
477
477
|
console.error("[ExtService] Startup error:", e);
|
|
478
478
|
});
|
|
479
479
|
|
|
480
|
+
// Start ClawBot Telegram poller (if enabled)
|
|
481
|
+
import("../services/clawbot/clawbot-service.ts")
|
|
482
|
+
.then(({ clawbotService }) => clawbotService.start())
|
|
483
|
+
.catch((e) => {
|
|
484
|
+
console.error("[clawbot] Startup error:", e);
|
|
485
|
+
});
|
|
486
|
+
|
|
480
487
|
console.log(`Server child ready on port ${port}`);
|
|
481
488
|
}
|
|
@@ -57,6 +57,21 @@ proxyRoutes.post("/v1/messages", async (c) => {
|
|
|
57
57
|
return proxyService.forward("/v1/messages", "POST", headers, body);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
/** POST /proxy/v1/chat/completions — OpenAI-compatible chat completions proxy */
|
|
61
|
+
proxyRoutes.post("/v1/chat/completions", async (c) => {
|
|
62
|
+
if (!proxyService.isEnabled()) {
|
|
63
|
+
return c.json({ error: { message: "Proxy is disabled", type: "server_error" } }, 503);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const authHeader = c.req.header("authorization") || c.req.header("x-api-key");
|
|
67
|
+
if (!validateProxyAuth(authHeader)) {
|
|
68
|
+
return c.json({ error: { message: "Invalid proxy auth key", type: "authentication_error" } }, 401);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = await c.req.text();
|
|
72
|
+
return proxyService.forwardOpenAi(body);
|
|
73
|
+
});
|
|
74
|
+
|
|
60
75
|
/** POST /proxy/v1/messages/count_tokens — token counting proxy */
|
|
61
76
|
proxyRoutes.post("/v1/messages/count_tokens", async (c) => {
|
|
62
77
|
if (!proxyService.isEnabled()) {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { configService } from "../../services/config.service.ts";
|
|
3
|
-
import { getConfigValue, setConfigValue } from "../../services/db.service.ts";
|
|
3
|
+
import { getConfigValue, setConfigValue, listPairedChats, getPairingByCode, approvePairing, revokePairing } from "../../services/db.service.ts";
|
|
4
4
|
import {
|
|
5
5
|
validateAIProviderConfig,
|
|
6
6
|
validateDefaultProvider,
|
|
7
7
|
VALID_PROVIDERS,
|
|
8
|
+
DEFAULT_CONFIG,
|
|
8
9
|
type AIProviderConfig,
|
|
9
10
|
type TelegramConfig,
|
|
11
|
+
type ClawBotConfig,
|
|
10
12
|
type ThemeConfig,
|
|
11
13
|
} from "../../types/config.ts";
|
|
12
14
|
import { ok, err } from "../../types/api.ts";
|
|
@@ -292,8 +294,10 @@ async function buildProxyResponse() {
|
|
|
292
294
|
authKey: proxyService.getAuthKey() ?? null,
|
|
293
295
|
requestCount: proxyService.getRequestCount(),
|
|
294
296
|
localEndpoint: `${localOrigin}/proxy/v1/messages`,
|
|
297
|
+
localOpenAiEndpoint: `${localOrigin}/proxy/v1/chat/completions`,
|
|
295
298
|
tunnelUrl: tunnelUrl ?? null,
|
|
296
299
|
proxyEndpoint: tunnelUrl ? `${tunnelUrl}/proxy/v1/messages` : null,
|
|
300
|
+
openAiEndpoint: tunnelUrl ? `${tunnelUrl}/proxy/v1/chat/completions` : null,
|
|
297
301
|
};
|
|
298
302
|
}
|
|
299
303
|
|
|
@@ -314,3 +318,72 @@ settingsRoutes.put("/proxy", async (c) => {
|
|
|
314
318
|
return c.json(err((e as Error).message), 400);
|
|
315
319
|
}
|
|
316
320
|
});
|
|
321
|
+
|
|
322
|
+
// ── ClawBot ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/** GET /settings/clawbot — return current clawbot config */
|
|
325
|
+
settingsRoutes.get("/clawbot", (c) => {
|
|
326
|
+
const config = configService.get("clawbot") as ClawBotConfig | undefined;
|
|
327
|
+
if (!config) return c.json(ok(DEFAULT_CONFIG.clawbot));
|
|
328
|
+
return c.json(ok(config));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/** PUT /settings/clawbot — update clawbot config */
|
|
332
|
+
settingsRoutes.put("/clawbot", async (c) => {
|
|
333
|
+
try {
|
|
334
|
+
const body = await c.req.json<Partial<ClawBotConfig>>();
|
|
335
|
+
const current = (configService.get("clawbot") as ClawBotConfig | undefined)
|
|
336
|
+
?? structuredClone(DEFAULT_CONFIG.clawbot!);
|
|
337
|
+
const updated: ClawBotConfig = { ...current, ...body };
|
|
338
|
+
|
|
339
|
+
if (updated.debounce_ms < 0 || updated.debounce_ms > 30000) {
|
|
340
|
+
return c.json(err("debounce_ms must be 0-30000"), 400);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
configService.set("clawbot", updated);
|
|
344
|
+
configService.save();
|
|
345
|
+
|
|
346
|
+
// Restart clawbot if running state changed
|
|
347
|
+
try {
|
|
348
|
+
const { clawbotService } = await import("../../services/clawbot/clawbot-service.ts");
|
|
349
|
+
if (updated.enabled && !clawbotService.isRunning) {
|
|
350
|
+
await clawbotService.start();
|
|
351
|
+
} else if (!updated.enabled && clawbotService.isRunning) {
|
|
352
|
+
clawbotService.stop();
|
|
353
|
+
}
|
|
354
|
+
} catch { /* ClawBot module not loaded yet — OK */ }
|
|
355
|
+
|
|
356
|
+
return c.json(ok(updated));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return c.json(err((e as Error).message), 400);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/** GET /settings/clawbot/paired — list paired devices */
|
|
363
|
+
settingsRoutes.get("/clawbot/paired", (c) => {
|
|
364
|
+
return c.json(ok(listPairedChats()));
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/** POST /settings/clawbot/paired/approve — approve pairing by code */
|
|
368
|
+
settingsRoutes.post("/clawbot/paired/approve", async (c) => {
|
|
369
|
+
try {
|
|
370
|
+
const { code } = await c.req.json<{ code: string }>();
|
|
371
|
+
const pairing = getPairingByCode(code);
|
|
372
|
+
if (!pairing) return c.json(err("Invalid pairing code"), 404);
|
|
373
|
+
approvePairing(pairing.telegram_chat_id);
|
|
374
|
+
// Notify user on Telegram
|
|
375
|
+
try {
|
|
376
|
+
const { clawbotService } = await import("../../services/clawbot/clawbot-service.ts");
|
|
377
|
+
await clawbotService.notifyPairingApproved(pairing.telegram_chat_id);
|
|
378
|
+
} catch { /* OK */ }
|
|
379
|
+
return c.json(ok({ approved: pairing.telegram_chat_id }));
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return c.json(err((e as Error).message), 400);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
/** DELETE /settings/clawbot/paired/:chatId — revoke pairing */
|
|
386
|
+
settingsRoutes.delete("/clawbot/paired/:chatId", (c) => {
|
|
387
|
+
revokePairing(c.req.param("chatId"));
|
|
388
|
+
return c.json(ok({ revoked: true }));
|
|
389
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
2
|
+
|
|
3
|
+
/** Escape HTML special chars for Telegram HTML parse mode */
|
|
4
|
+
export function escapeHtml(str: string): string {
|
|
5
|
+
return str
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">")
|
|
9
|
+
.replace(/"/g, """);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert Markdown to Telegram-compatible HTML.
|
|
14
|
+
* Handles: **bold**, *italic*, `code`, ```pre```, [links](url), ~~strikethrough~~
|
|
15
|
+
* Does NOT handle nested formatting (Telegram limitation).
|
|
16
|
+
*/
|
|
17
|
+
export function markdownToTelegramHtml(md: string): string {
|
|
18
|
+
let html = md;
|
|
19
|
+
|
|
20
|
+
// Code blocks first (prevent inner processing)
|
|
21
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
|
22
|
+
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
23
|
+
return `<pre><code${langAttr}>${escapeHtml(code.trimEnd())}</code></pre>`;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Inline code: `code` → <code>code</code>
|
|
27
|
+
html = html.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
28
|
+
return `<code>${escapeHtml(code)}</code>`;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Bold: **text** → <b>text</b>
|
|
32
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
33
|
+
|
|
34
|
+
// Italic: *text* → <i>text</i>
|
|
35
|
+
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
|
|
36
|
+
|
|
37
|
+
// Strikethrough: ~~text~~ → <s>text</s>
|
|
38
|
+
html = html.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
|
39
|
+
|
|
40
|
+
// Links: [text](url) → <a href="url">text</a>
|
|
41
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
42
|
+
|
|
43
|
+
return html;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split text into chunks that fit Telegram's 4096 char limit.
|
|
48
|
+
* Tries to break at newlines, falling back to word boundaries.
|
|
49
|
+
*/
|
|
50
|
+
export function chunkMessage(text: string, maxLen = MAX_MESSAGE_LENGTH): string[] {
|
|
51
|
+
if (text.length <= maxLen) return [text];
|
|
52
|
+
|
|
53
|
+
const chunks: string[] = [];
|
|
54
|
+
let remaining = text;
|
|
55
|
+
|
|
56
|
+
while (remaining.length > 0) {
|
|
57
|
+
if (remaining.length <= maxLen) {
|
|
58
|
+
chunks.push(remaining);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let breakAt = -1;
|
|
63
|
+
const searchWindow = remaining.slice(0, maxLen);
|
|
64
|
+
|
|
65
|
+
// Try double newline
|
|
66
|
+
breakAt = searchWindow.lastIndexOf("\n\n");
|
|
67
|
+
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
68
|
+
breakAt = searchWindow.lastIndexOf("\n");
|
|
69
|
+
}
|
|
70
|
+
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
71
|
+
breakAt = searchWindow.lastIndexOf(" ");
|
|
72
|
+
}
|
|
73
|
+
if (breakAt === -1 || breakAt < maxLen * 0.3) {
|
|
74
|
+
breakAt = maxLen;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
chunks.push(remaining.slice(0, breakAt));
|
|
78
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return chunks;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Truncate text for preview (e.g. session titles), adding ellipsis */
|
|
85
|
+
export function truncateForPreview(text: string, maxLen = 200): string {
|
|
86
|
+
if (text.length <= maxLen) return text;
|
|
87
|
+
return text.slice(0, maxLen - 1) + "\u2026";
|
|
88
|
+
}
|