@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.
@@ -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, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;");
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 "&gt;".
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("&gt; ") || lines[i] === "&gt;") {
75
+ const blockLines: string[] = [];
76
+ while (
77
+ i < lines.length &&
78
+ (lines[i].startsWith("&gt; ") || lines[i] === "&gt;")
79
+ ) {
80
+ blockLines.push(lines[i].replace(/^&gt;[ ]?/, ""));
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(/&amp;/g, "&")
117
+ .replace(/&lt;/g, "<")
118
+ .replace(/&gt;/g, ">")
119
+ .replace(/&quot;/g, '"');
120
+ }