@tormentalabs/opencode-telegram-plugin 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +222 -0
- package/package.json +34 -0
- package/src/bot.ts +143 -0
- package/src/config.ts +218 -0
- package/src/handlers/callbacks.ts +209 -0
- package/src/handlers/commands.ts +562 -0
- package/src/handlers/messages.ts +163 -0
- package/src/hooks/message.ts +448 -0
- package/src/hooks/permission.ts +81 -0
- package/src/hooks/session.ts +126 -0
- package/src/hooks/tool.ts +99 -0
- package/src/index.ts +395 -0
- package/src/state/mapping.ts +112 -0
- package/src/state/mode.ts +40 -0
- package/src/state/store.ts +167 -0
- package/src/utils/chunk.ts +186 -0
- package/src/utils/format.ts +120 -0
- package/src/utils/safeSend.ts +99 -0
- package/src/utils/throttle.ts +128 -0
- package/src/utils/typing.ts +30 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export interface ChatMapping {
|
|
6
|
+
chatId: number;
|
|
7
|
+
lastSessionId: string | null;
|
|
8
|
+
username: string | null;
|
|
9
|
+
firstSeen: number;
|
|
10
|
+
lastActive: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const mappings = new Map<number, ChatMapping>();
|
|
14
|
+
let filePath: string | null = null;
|
|
15
|
+
|
|
16
|
+
export function initMapping(dataDir: string): void {
|
|
17
|
+
mkdirSync(dataDir, { recursive: true });
|
|
18
|
+
filePath = join(dataDir, "chat-mappings.json");
|
|
19
|
+
|
|
20
|
+
if (!existsSync(filePath)) return;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
24
|
+
const parsed: unknown = JSON.parse(raw);
|
|
25
|
+
if (!Array.isArray(parsed)) return;
|
|
26
|
+
|
|
27
|
+
for (const item of parsed) {
|
|
28
|
+
if (item != null && typeof item === "object" && typeof (item as Record<string, unknown>).chatId === "number") {
|
|
29
|
+
const m = item as Record<string, unknown>;
|
|
30
|
+
const chatId = m.chatId as number;
|
|
31
|
+
mappings.set(chatId, {
|
|
32
|
+
chatId,
|
|
33
|
+
lastSessionId: typeof m.lastSessionId === "string" ? m.lastSessionId : null,
|
|
34
|
+
username: typeof m.username === "string" ? m.username : null,
|
|
35
|
+
firstSeen: typeof m.firstSeen === "number" ? m.firstSeen : Date.now(),
|
|
36
|
+
lastActive: typeof m.lastActive === "number" ? m.lastActive : Date.now(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Corrupt or unreadable file — start fresh
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getMapping(chatId: number): ChatMapping | null {
|
|
46
|
+
return mappings.get(chatId) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setMapping(chatId: number, data: Partial<ChatMapping>): void {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const existing = mappings.get(chatId);
|
|
52
|
+
|
|
53
|
+
if (existing) {
|
|
54
|
+
mappings.set(chatId, {
|
|
55
|
+
...existing,
|
|
56
|
+
...data,
|
|
57
|
+
chatId,
|
|
58
|
+
lastActive: now,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
mappings.set(chatId, {
|
|
62
|
+
lastSessionId: null,
|
|
63
|
+
username: null,
|
|
64
|
+
firstSeen: now,
|
|
65
|
+
...data,
|
|
66
|
+
chatId,
|
|
67
|
+
lastActive: now,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
scheduleSave();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getAllMappings(): ChatMapping[] {
|
|
75
|
+
return Array.from(mappings.values());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveMappingsSync(): void {
|
|
79
|
+
if (!filePath) return;
|
|
80
|
+
|
|
81
|
+
const dir = dirname(filePath);
|
|
82
|
+
mkdirSync(dir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const json = JSON.stringify(Array.from(mappings.values()), null, 2);
|
|
85
|
+
const tmpPath = filePath + "." + randomBytes(4).toString("hex") + ".tmp";
|
|
86
|
+
try {
|
|
87
|
+
writeFileSync(tmpPath, json, "utf-8");
|
|
88
|
+
renameSync(tmpPath, filePath);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// Clean up temp file on failure
|
|
91
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
97
|
+
|
|
98
|
+
function scheduleSave(): void {
|
|
99
|
+
if (saveTimer !== null) return;
|
|
100
|
+
saveTimer = setTimeout(() => {
|
|
101
|
+
saveTimer = null;
|
|
102
|
+
try {
|
|
103
|
+
saveMappingsSync();
|
|
104
|
+
} catch {
|
|
105
|
+
// Non-fatal — data will be retried on next write
|
|
106
|
+
}
|
|
107
|
+
}, 500);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function saveMappings(): void {
|
|
111
|
+
scheduleSave();
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getChatState } from "./store.js";
|
|
2
|
+
|
|
3
|
+
export type SessionMode = "attached" | "independent" | "detached";
|
|
4
|
+
|
|
5
|
+
export function getActiveSessionId(chatId: number): string | null {
|
|
6
|
+
const state = getChatState(chatId);
|
|
7
|
+
switch (state.mode) {
|
|
8
|
+
case "attached":
|
|
9
|
+
return state.attachedSessionId;
|
|
10
|
+
case "independent":
|
|
11
|
+
return state.independentSessionId;
|
|
12
|
+
case "detached":
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function attachSession(chatId: number, sessionId: string): void {
|
|
18
|
+
const state = getChatState(chatId);
|
|
19
|
+
state.mode = "attached";
|
|
20
|
+
state.attachedSessionId = sessionId;
|
|
21
|
+
state.independentSessionId = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function detachSession(chatId: number): void {
|
|
25
|
+
const state = getChatState(chatId);
|
|
26
|
+
state.mode = "detached";
|
|
27
|
+
state.attachedSessionId = null;
|
|
28
|
+
state.independentSessionId = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function startIndependentSession(chatId: number, sessionId: string): void {
|
|
32
|
+
const state = getChatState(chatId);
|
|
33
|
+
state.mode = "independent";
|
|
34
|
+
state.independentSessionId = sessionId;
|
|
35
|
+
state.attachedSessionId = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getMode(chatId: number): SessionMode {
|
|
39
|
+
return getChatState(chatId).mode;
|
|
40
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export type StreamState = "IDLE" | "PENDING_SEND" | "SENT" | "EDITING" | "FINAL";
|
|
2
|
+
|
|
3
|
+
export interface StreamTracker {
|
|
4
|
+
state: StreamState;
|
|
5
|
+
messageId: number | null;
|
|
6
|
+
lastSentText: string;
|
|
7
|
+
sessionId: string | null;
|
|
8
|
+
messageIdOC: string | null;
|
|
9
|
+
chunks: number[];
|
|
10
|
+
streamGeneration: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PendingPermission {
|
|
14
|
+
permissionId: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
tool: string;
|
|
17
|
+
description: string;
|
|
18
|
+
telegramMessageId: number | null;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SelectedModel {
|
|
23
|
+
providerID: string;
|
|
24
|
+
modelID: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type EffortLevel = "low" | "medium" | "high";
|
|
29
|
+
|
|
30
|
+
export interface ChatState {
|
|
31
|
+
chatId: number;
|
|
32
|
+
mode: "attached" | "independent" | "detached";
|
|
33
|
+
attachedSessionId: string | null;
|
|
34
|
+
independentSessionId: string | null;
|
|
35
|
+
selectedModel: SelectedModel | null;
|
|
36
|
+
effort: EffortLevel;
|
|
37
|
+
stream: StreamTracker;
|
|
38
|
+
pendingPermissions: Map<string, PendingPermission>;
|
|
39
|
+
typingStop: (() => void) | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CallbackEntry {
|
|
43
|
+
action: string;
|
|
44
|
+
data: Record<string, string>;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CALLBACK_TTL_MS = 600_000; // 10 minutes
|
|
49
|
+
const CALLBACK_KEY_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
50
|
+
const CALLBACK_KEY_LENGTH = 6;
|
|
51
|
+
|
|
52
|
+
const chatStates = new Map<number, ChatState>();
|
|
53
|
+
const callbackRegistry = new Map<string, CallbackEntry>();
|
|
54
|
+
|
|
55
|
+
function makeDefaultStream(): StreamTracker {
|
|
56
|
+
return {
|
|
57
|
+
state: "IDLE",
|
|
58
|
+
messageId: null,
|
|
59
|
+
lastSentText: "",
|
|
60
|
+
sessionId: null,
|
|
61
|
+
messageIdOC: null,
|
|
62
|
+
chunks: [],
|
|
63
|
+
streamGeneration: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeDefaultChatState(chatId: number): ChatState {
|
|
68
|
+
return {
|
|
69
|
+
chatId,
|
|
70
|
+
mode: "detached",
|
|
71
|
+
attachedSessionId: null,
|
|
72
|
+
independentSessionId: null,
|
|
73
|
+
selectedModel: null,
|
|
74
|
+
effort: "high",
|
|
75
|
+
stream: makeDefaultStream(),
|
|
76
|
+
pendingPermissions: new Map(),
|
|
77
|
+
typingStop: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getChatState(chatId: number): ChatState {
|
|
82
|
+
let state = chatStates.get(chatId);
|
|
83
|
+
if (!state) {
|
|
84
|
+
state = makeDefaultChatState(chatId);
|
|
85
|
+
chatStates.set(chatId, state);
|
|
86
|
+
}
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function deleteChatState(chatId: number): void {
|
|
91
|
+
chatStates.delete(chatId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getAllChatIds(): number[] {
|
|
95
|
+
return Array.from(chatStates.keys());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resetStream(chatId: number): void {
|
|
99
|
+
const state = getChatState(chatId);
|
|
100
|
+
const nextGen = state.stream.streamGeneration + 1;
|
|
101
|
+
state.stream = makeDefaultStream();
|
|
102
|
+
state.stream.streamGeneration = nextGen;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function cleanupChatStream(chatId: number): void {
|
|
106
|
+
const state = getChatState(chatId);
|
|
107
|
+
state.typingStop?.();
|
|
108
|
+
state.typingStop = null;
|
|
109
|
+
resetStream(chatId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function generateKey(): string {
|
|
113
|
+
let key = "";
|
|
114
|
+
for (let i = 0; i < CALLBACK_KEY_LENGTH; i++) {
|
|
115
|
+
key += CALLBACK_KEY_CHARS[Math.floor(Math.random() * CALLBACK_KEY_CHARS.length)];
|
|
116
|
+
}
|
|
117
|
+
return key;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function registerCallback(
|
|
121
|
+
action: string,
|
|
122
|
+
data: Record<string, string>,
|
|
123
|
+
ttlMs: number = DEFAULT_CALLBACK_TTL_MS,
|
|
124
|
+
): string {
|
|
125
|
+
let key: string;
|
|
126
|
+
do {
|
|
127
|
+
key = generateKey();
|
|
128
|
+
} while (callbackRegistry.has(key));
|
|
129
|
+
|
|
130
|
+
callbackRegistry.set(key, {
|
|
131
|
+
action,
|
|
132
|
+
data,
|
|
133
|
+
expiresAt: Date.now() + ttlMs,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return key;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveCallback(key: string): CallbackEntry | null {
|
|
140
|
+
const entry = callbackRegistry.get(key);
|
|
141
|
+
if (!entry) return null;
|
|
142
|
+
|
|
143
|
+
callbackRegistry.delete(key);
|
|
144
|
+
|
|
145
|
+
if (Date.now() > entry.expiresAt) return null;
|
|
146
|
+
|
|
147
|
+
return entry;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function cleanExpiredCallbacks(): void {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
for (const [key, entry] of callbackRegistry) {
|
|
153
|
+
if (now > entry.expiresAt) {
|
|
154
|
+
callbackRegistry.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Also sweep expired pending permissions across all chats.
|
|
159
|
+
// Permissions share the same TTL as callbacks (10 minutes).
|
|
160
|
+
for (const state of chatStates.values()) {
|
|
161
|
+
for (const [permId, perm] of state.pendingPermissions) {
|
|
162
|
+
if (now - perm.timestamp > DEFAULT_CALLBACK_TTL_MS) {
|
|
163
|
+
state.pendingPermissions.delete(permId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const MAX_LENGTH = 4096;
|
|
2
|
+
|
|
3
|
+
/** Tags whose open/close state is tracked across chunk boundaries. */
|
|
4
|
+
const TRACKED_TAGS = new Set([
|
|
5
|
+
"b",
|
|
6
|
+
"i",
|
|
7
|
+
"u",
|
|
8
|
+
"s",
|
|
9
|
+
"code",
|
|
10
|
+
"pre",
|
|
11
|
+
"blockquote",
|
|
12
|
+
"a",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
interface OpenTag {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Attribute string, e.g. `href="https://example.com"` */
|
|
18
|
+
attrs: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function openTagStr(tag: OpenTag): string {
|
|
22
|
+
return tag.attrs ? `<${tag.name} ${tag.attrs}>` : `<${tag.name}>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stackCloseStr(stack: readonly OpenTag[]): string {
|
|
26
|
+
return [...stack]
|
|
27
|
+
.reverse()
|
|
28
|
+
.map((t) => `</${t.name}>`)
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stackOpenStr(stack: readonly OpenTag[]): string {
|
|
33
|
+
return stack.map(openTagStr).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split an HTML string into chunks of at most `maxLength` characters.
|
|
38
|
+
*
|
|
39
|
+
* When a chunk boundary falls inside open HTML tags, the chunk is closed
|
|
40
|
+
* with the appropriate closing tags and the next chunk is reopened with the
|
|
41
|
+
* matching opening tags, preserving attributes (e.g. href on <a>).
|
|
42
|
+
*
|
|
43
|
+
* Split preference order: newline → space → forced.
|
|
44
|
+
*/
|
|
45
|
+
export function chunkMessage(
|
|
46
|
+
html: string,
|
|
47
|
+
maxLength: number = MAX_LENGTH,
|
|
48
|
+
): string[] {
|
|
49
|
+
const chunks: string[] = [];
|
|
50
|
+
const stack: OpenTag[] = [];
|
|
51
|
+
let current = "";
|
|
52
|
+
|
|
53
|
+
/** Close open tags, push chunk, reopen tags for next chunk. */
|
|
54
|
+
function flush(): void {
|
|
55
|
+
const close = stackCloseStr(stack);
|
|
56
|
+
const chunk = current + close;
|
|
57
|
+
if (chunk.trim().length > 0) {
|
|
58
|
+
chunks.push(chunk);
|
|
59
|
+
}
|
|
60
|
+
current = stackOpenStr(stack);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns true if adding `addition` to current, with `futureStack` as the
|
|
65
|
+
* resulting open-tag state, would still fit within maxLength.
|
|
66
|
+
*/
|
|
67
|
+
function fitsInCurrent(addition: string, futureStack: OpenTag[]): boolean {
|
|
68
|
+
const close = stackCloseStr(futureStack);
|
|
69
|
+
return current.length + addition.length + close.length <= maxLength;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Tokenise: either an HTML tag (<tag ...> or </tag>) or a run of text.
|
|
73
|
+
const TOKEN_RE = /<\/?[a-zA-Z][^>]*>|[^<]+/g;
|
|
74
|
+
const tokens = html.match(TOKEN_RE) ?? [];
|
|
75
|
+
|
|
76
|
+
for (const token of tokens) {
|
|
77
|
+
// ── Text token ──────────────────────────────────────────────────────────
|
|
78
|
+
if (!token.startsWith("<")) {
|
|
79
|
+
let remaining = token;
|
|
80
|
+
|
|
81
|
+
while (remaining.length > 0) {
|
|
82
|
+
const close = stackCloseStr(stack);
|
|
83
|
+
const available = maxLength - current.length - close.length;
|
|
84
|
+
|
|
85
|
+
if (remaining.length <= available) {
|
|
86
|
+
current += remaining;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (available <= 0) {
|
|
91
|
+
flush();
|
|
92
|
+
// Safety: if the tag prefix alone fills maxLength, bail out.
|
|
93
|
+
const newAvail =
|
|
94
|
+
maxLength - current.length - stackCloseStr(stack).length;
|
|
95
|
+
if (newAvail <= 0) break;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Find the best split point within the available window.
|
|
100
|
+
const sub = remaining.slice(0, available);
|
|
101
|
+
let splitAt = available;
|
|
102
|
+
|
|
103
|
+
const nl = sub.lastIndexOf("\n");
|
|
104
|
+
if (nl > 0) {
|
|
105
|
+
splitAt = nl + 1; // include the newline in this chunk
|
|
106
|
+
} else {
|
|
107
|
+
const sp = sub.lastIndexOf(" ");
|
|
108
|
+
if (sp > 0) {
|
|
109
|
+
splitAt = sp + 1; // include the space in this chunk
|
|
110
|
+
}
|
|
111
|
+
// else: forced split at `available`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
current += remaining.slice(0, splitAt);
|
|
115
|
+
remaining = remaining.slice(splitAt);
|
|
116
|
+
flush();
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── HTML tag token ───────────────────────────────────────────────────────
|
|
122
|
+
const isClose = token.startsWith("</");
|
|
123
|
+
const nameMatch = token.match(/<\/?(\w+)/);
|
|
124
|
+
if (!nameMatch) continue;
|
|
125
|
+
|
|
126
|
+
const name = nameMatch[1].toLowerCase();
|
|
127
|
+
const tracked = TRACKED_TAGS.has(name);
|
|
128
|
+
|
|
129
|
+
if (isClose) {
|
|
130
|
+
// Locate the last matching open tag in the stack.
|
|
131
|
+
let stackIdx = -1;
|
|
132
|
+
for (let j = stack.length - 1; j >= 0; j--) {
|
|
133
|
+
if (stack[j].name === name) {
|
|
134
|
+
stackIdx = j;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Compute what the stack will look like after closing this tag.
|
|
140
|
+
const futureStack: OpenTag[] =
|
|
141
|
+
tracked && stackIdx !== -1
|
|
142
|
+
? [
|
|
143
|
+
...stack.slice(0, stackIdx),
|
|
144
|
+
...stack.slice(stackIdx + 1),
|
|
145
|
+
]
|
|
146
|
+
: [...stack];
|
|
147
|
+
|
|
148
|
+
if (!fitsInCurrent(token, futureStack)) {
|
|
149
|
+
flush();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
current += token;
|
|
153
|
+
|
|
154
|
+
if (tracked && stackIdx !== -1) {
|
|
155
|
+
stack.splice(stackIdx, 1);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
// Opening tag — extract attribute string for tracked tags.
|
|
159
|
+
const attrsMatch = token.match(/^<\w+((?:\s+[^>]*)?)>/);
|
|
160
|
+
const attrs = attrsMatch ? attrsMatch[1].trim() : "";
|
|
161
|
+
|
|
162
|
+
const futureStack: OpenTag[] = tracked
|
|
163
|
+
? [...stack, { name, attrs }]
|
|
164
|
+
: [...stack];
|
|
165
|
+
|
|
166
|
+
if (!fitsInCurrent(token, futureStack)) {
|
|
167
|
+
flush();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
current += token;
|
|
171
|
+
|
|
172
|
+
if (tracked) {
|
|
173
|
+
stack.push({ name, attrs });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Flush any remaining content.
|
|
179
|
+
const close = stackCloseStr(stack);
|
|
180
|
+
const final = current + close;
|
|
181
|
+
if (final.trim().length > 0) {
|
|
182
|
+
chunks.push(final);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return chunks.filter((c) => c.trim().length > 0);
|
|
186
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape HTML special characters for Telegram HTML parse mode.
|
|
3
|
+
*/
|
|
4
|
+
export function escapeHtml(text: string): string {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">")
|
|
9
|
+
.replace(/"/g, """);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert Markdown text to Telegram-safe HTML.
|
|
14
|
+
*
|
|
15
|
+
* Supported output tags: <b>, <i>, <u>, <s>, <code>, <pre>,
|
|
16
|
+
* <a href="...">, <blockquote>
|
|
17
|
+
*
|
|
18
|
+
* Strategy:
|
|
19
|
+
* 1. Extract fenced/inline code blocks into placeholders
|
|
20
|
+
* 2. Escape HTML entities in remaining text
|
|
21
|
+
* 3. Convert Markdown inline formatting
|
|
22
|
+
* 4. Convert headers
|
|
23
|
+
* 5. Collect adjacent blockquote lines
|
|
24
|
+
* 6. Restore code placeholders as <pre>/<code>
|
|
25
|
+
*/
|
|
26
|
+
export function markdownToTelegramHtml(md: string): string {
|
|
27
|
+
const codeBlocks: string[] = [];
|
|
28
|
+
const inlineCodes: string[] = [];
|
|
29
|
+
|
|
30
|
+
// Step 1a: Extract fenced code blocks (``` lang \n code \n ```)
|
|
31
|
+
let text = md.replace(
|
|
32
|
+
/```([^\n]*)\n([\s\S]*?)```/g,
|
|
33
|
+
(_match, _lang: string, code: string) => {
|
|
34
|
+
const idx = codeBlocks.length;
|
|
35
|
+
codeBlocks.push(code);
|
|
36
|
+
return `\x00CODEBLOCK_${idx}\x00`;
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Step 1b: Extract inline code (` code `)
|
|
41
|
+
text = text.replace(/`([^`]+)`/g, (_match, code: string) => {
|
|
42
|
+
const idx = inlineCodes.length;
|
|
43
|
+
inlineCodes.push(code);
|
|
44
|
+
return `\x00INLINE_${idx}\x00`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Step 2: Escape HTML entities in the remaining text
|
|
48
|
+
text = escapeHtml(text);
|
|
49
|
+
|
|
50
|
+
// Step 3: Convert Markdown inline formatting
|
|
51
|
+
|
|
52
|
+
// Bold: **text**
|
|
53
|
+
text = text.replace(/\*\*([\s\S]+?)\*\*/g, "<b>$1</b>");
|
|
54
|
+
|
|
55
|
+
// Italic: *text* (single asterisk, content must not contain * or newline)
|
|
56
|
+
text = text.replace(/\*([^*\n]+)\*/g, "<i>$1</i>");
|
|
57
|
+
|
|
58
|
+
// Strikethrough: ~~text~~
|
|
59
|
+
text = text.replace(/~~([\s\S]+?)~~/g, "<s>$1</s>");
|
|
60
|
+
|
|
61
|
+
// Links: [text](url)
|
|
62
|
+
text = text.replace(/\[([^\]]+)\]\(((?:[^()]*|\([^()]*\))*)\)/g, '<a href="$2">$1</a>');
|
|
63
|
+
|
|
64
|
+
// Step 4: Headers (# through ######) → <b>header text</b>
|
|
65
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
|
|
66
|
+
|
|
67
|
+
// Step 5: Blockquotes
|
|
68
|
+
// After HTML escaping, ">" at the start of a line becomes ">".
|
|
69
|
+
// Collect adjacent blockquote lines into a single <blockquote> element.
|
|
70
|
+
const lines = text.split("\n");
|
|
71
|
+
const result: string[] = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < lines.length) {
|
|
74
|
+
if (lines[i].startsWith("> ") || lines[i] === ">") {
|
|
75
|
+
const blockLines: string[] = [];
|
|
76
|
+
while (
|
|
77
|
+
i < lines.length &&
|
|
78
|
+
(lines[i].startsWith("> ") || lines[i] === ">")
|
|
79
|
+
) {
|
|
80
|
+
blockLines.push(lines[i].replace(/^>[ ]?/, ""));
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
result.push(`<blockquote>${blockLines.join("\n")}</blockquote>`);
|
|
84
|
+
} else {
|
|
85
|
+
result.push(lines[i]);
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
text = result.join("\n");
|
|
90
|
+
|
|
91
|
+
// Step 6: Restore code placeholders with properly escaped content
|
|
92
|
+
|
|
93
|
+
// Fenced code blocks → <pre>
|
|
94
|
+
text = text.replace(/\x00CODEBLOCK_(\d+)\x00/g, (_match, idx: string) => {
|
|
95
|
+
const raw = codeBlocks[parseInt(idx, 10)] ?? "";
|
|
96
|
+
// Trim trailing newline that the regex capture group includes
|
|
97
|
+
const normalized = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
98
|
+
return `<pre>${escapeHtml(normalized)}</pre>`;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Inline code → <code>
|
|
102
|
+
text = text.replace(/\x00INLINE_(\d+)\x00/g, (_match, idx: string) => {
|
|
103
|
+
const raw = inlineCodes[parseInt(idx, 10)] ?? "";
|
|
104
|
+
return `<code>${escapeHtml(raw)}</code>`;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove all HTML tags from a string and decode basic HTML entities.
|
|
112
|
+
*/
|
|
113
|
+
export function stripHtml(text: string): string {
|
|
114
|
+
return text
|
|
115
|
+
.replace(/<[^>]+>/g, "")
|
|
116
|
+
.replace(/&/g, "&")
|
|
117
|
+
.replace(/</g, "<")
|
|
118
|
+
.replace(/>/g, ">")
|
|
119
|
+
.replace(/"/g, '"');
|
|
120
|
+
}
|