@pedrohnas/opencode-telegram 0.1.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,93 @@
1
+ /**
2
+ * Convert Markdown text to Telegram-compatible HTML.
3
+ *
4
+ * Telegram supports a limited subset of HTML:
5
+ * <b>, <i>, <s>, <u>, <code>, <pre>, <a href="">, <blockquote>
6
+ *
7
+ * Strategy:
8
+ * 1. Extract code blocks/inline code (protect from formatting)
9
+ * 2. Escape HTML entities in remaining text
10
+ * 3. Apply markdown → HTML conversions
11
+ * 4. Re-insert protected code blocks
12
+ */
13
+
14
+ const PLACEHOLDER_PREFIX = "\x00CB"
15
+ const PLACEHOLDER_INLINE = "\x00CI"
16
+
17
+ export function markdownToTelegramHtml(markdown: string): string {
18
+ if (!markdown) return ""
19
+
20
+ // --- Step 1: Extract and protect code blocks & inline code ---
21
+ const codeBlocks: string[] = []
22
+ const inlineCodes: string[] = []
23
+
24
+ // Fenced code blocks: ```lang\ncode\n```
25
+ let text = markdown.replace(
26
+ /```(\w*)\n([\s\S]*?)```/g,
27
+ (_, lang, code) => {
28
+ const escaped = escapeHtml(code.replace(/\n$/, ""))
29
+ const langAttr = lang ? ` class="language-${lang}"` : ""
30
+ const html = `<pre><code${langAttr}>${escaped}</code></pre>`
31
+ codeBlocks.push(html)
32
+ return `${PLACEHOLDER_PREFIX}${codeBlocks.length - 1}\x00`
33
+ },
34
+ )
35
+
36
+ // Inline code: `code`
37
+ text = text.replace(/`([^`]+)`/g, (_, code) => {
38
+ const html = `<code>${escapeHtml(code)}</code>`
39
+ inlineCodes.push(html)
40
+ return `${PLACEHOLDER_INLINE}${inlineCodes.length - 1}\x00`
41
+ })
42
+
43
+ // --- Step 2: Escape HTML in remaining text ---
44
+ text = escapeHtml(text)
45
+
46
+ // --- Step 3: Markdown → HTML conversions ---
47
+
48
+ // Headings: # text → <b>text</b> (Telegram has no heading tags)
49
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>")
50
+
51
+ // Bold: **text** or __text__
52
+ text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
53
+ text = text.replace(/__(.+?)__/g, "<b>$1</b>")
54
+
55
+ // Italic: *text* or _text_ (but not inside words like file_name)
56
+ text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, "<i>$1</i>")
57
+ text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, "<i>$1</i>")
58
+
59
+ // Strikethrough: ~~text~~
60
+ text = text.replace(/~~(.+?)~~/g, "<s>$1</s>")
61
+
62
+ // Links: [text](url)
63
+ text = text.replace(
64
+ /\[([^\]]+)\]\(([^)]+)\)/g,
65
+ '<a href="$2">$1</a>',
66
+ )
67
+
68
+ // Blockquotes: > text
69
+ text = text.replace(/^&gt;\s?(.+)$/gm, "<blockquote>$1</blockquote>")
70
+
71
+ // --- Step 4: Re-insert protected code ---
72
+ text = text.replace(
73
+ new RegExp(`${escapeRegex(PLACEHOLDER_PREFIX)}(\\d+)\x00`, "g"),
74
+ (_, idx) => codeBlocks[Number(idx)],
75
+ )
76
+ text = text.replace(
77
+ new RegExp(`${escapeRegex(PLACEHOLDER_INLINE)}(\\d+)\x00`, "g"),
78
+ (_, idx) => inlineCodes[Number(idx)],
79
+ )
80
+
81
+ return text
82
+ }
83
+
84
+ function escapeHtml(text: string): string {
85
+ return text
86
+ .replace(/&/g, "&amp;")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;")
89
+ }
90
+
91
+ function escapeRegex(str: string): string {
92
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
93
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Format tool call status for display in the streaming draft.
3
+ *
4
+ * Returns a suffix string to append to the draft message while a tool
5
+ * is running/pending, or null if no suffix is needed (completed/error).
6
+ */
7
+ export function formatToolStatus(part: any): string | null {
8
+ if (part.type !== "tool") return null
9
+ if (!part.state) return null
10
+
11
+ const tool = part.tool ?? "tool"
12
+ const { status } = part.state
13
+
14
+ if (status === "running" && part.state.title) {
15
+ return `\n\n---\n⚙ Running ${tool}: ${part.state.title}`
16
+ }
17
+ if (status === "pending") {
18
+ return `\n\n---\n⚙ Preparing ${tool}...`
19
+ }
20
+
21
+ return null
22
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LRU-bounded session map: Telegram chatKey ↔ OpenCode sessionId.
3
+ *
4
+ * Anti-leak design:
5
+ * - Max entries cap (evicts oldest on overflow)
6
+ * - TTL-based expiration (cleanup removes stale entries)
7
+ * - Reverse map (sessionId → chatKey) for SSE event routing
8
+ * - Eviction only removes from memory — sessions persist on OpenCode server
9
+ */
10
+
11
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
12
+
13
+ export type SessionEntry = {
14
+ sessionId: string
15
+ directory: string
16
+ createdAt: number
17
+ lastAccessAt: number
18
+ }
19
+
20
+ export type SessionManagerOptions = {
21
+ maxEntries: number
22
+ ttlMs: number
23
+ }
24
+
25
+ export class SessionManager {
26
+ private map = new Map<string, SessionEntry>()
27
+ private reverseMap = new Map<string, string>() // sessionId → chatKey
28
+ private readonly maxEntries: number
29
+ private readonly ttlMs: number
30
+
31
+ constructor(opts: SessionManagerOptions) {
32
+ this.maxEntries = opts.maxEntries
33
+ this.ttlMs = opts.ttlMs
34
+ }
35
+
36
+ async getOrCreate(
37
+ chatKey: string,
38
+ sdk: OpencodeClient,
39
+ ): Promise<SessionEntry> {
40
+ const existing = this.map.get(chatKey)
41
+ if (existing) {
42
+ // LRU: refresh access order by re-inserting
43
+ this.map.delete(chatKey)
44
+ existing.lastAccessAt = Date.now()
45
+ this.map.set(chatKey, existing)
46
+ return existing
47
+ }
48
+
49
+ // Create new session via SDK
50
+ const result = await sdk.session.create({
51
+ title: `Telegram ${chatKey}`,
52
+ })
53
+
54
+ const session = result.data!
55
+ const entry: SessionEntry = {
56
+ sessionId: session.id,
57
+ directory: session.directory ?? "",
58
+ createdAt: Date.now(),
59
+ lastAccessAt: Date.now(),
60
+ }
61
+
62
+ this.insert(chatKey, entry)
63
+ return entry
64
+ }
65
+
66
+ get(chatKey: string): SessionEntry | undefined {
67
+ return this.map.get(chatKey)
68
+ }
69
+
70
+ getBySessionId(
71
+ sessionId: string,
72
+ ): { chatKey: string; entry: SessionEntry } | undefined {
73
+ const chatKey = this.reverseMap.get(sessionId)
74
+ if (!chatKey) return undefined
75
+ const entry = this.map.get(chatKey)
76
+ if (!entry) return undefined
77
+ return { chatKey, entry }
78
+ }
79
+
80
+ set(
81
+ chatKey: string,
82
+ init: { sessionId: string; directory: string },
83
+ ): void {
84
+ // Clean up old binding if exists
85
+ const old = this.map.get(chatKey)
86
+ if (old) {
87
+ this.reverseMap.delete(old.sessionId)
88
+ }
89
+
90
+ const entry: SessionEntry = {
91
+ sessionId: init.sessionId,
92
+ directory: init.directory,
93
+ createdAt: Date.now(),
94
+ lastAccessAt: Date.now(),
95
+ }
96
+
97
+ this.insert(chatKey, entry)
98
+ }
99
+
100
+ remove(chatKey: string): void {
101
+ const entry = this.map.get(chatKey)
102
+ if (entry) {
103
+ this.reverseMap.delete(entry.sessionId)
104
+ this.map.delete(chatKey)
105
+ }
106
+ }
107
+
108
+ cleanup(): void {
109
+ const now = Date.now()
110
+ for (const [chatKey, entry] of this.map) {
111
+ if (now - entry.lastAccessAt > this.ttlMs) {
112
+ this.reverseMap.delete(entry.sessionId)
113
+ this.map.delete(chatKey)
114
+ }
115
+ }
116
+ }
117
+
118
+ get size(): number {
119
+ return this.map.size
120
+ }
121
+
122
+ private insert(chatKey: string, entry: SessionEntry): void {
123
+ // Remove old binding for this chatKey
124
+ const old = this.map.get(chatKey)
125
+ if (old) {
126
+ this.reverseMap.delete(old.sessionId)
127
+ this.map.delete(chatKey)
128
+ }
129
+
130
+ // Evict oldest if at capacity
131
+ while (this.map.size >= this.maxEntries) {
132
+ const oldest = this.map.keys().next().value
133
+ if (oldest !== undefined) {
134
+ const evicted = this.map.get(oldest)
135
+ if (evicted) this.reverseMap.delete(evicted.sessionId)
136
+ this.map.delete(oldest)
137
+ }
138
+ }
139
+
140
+ this.map.set(chatKey, entry)
141
+ this.reverseMap.set(entry.sessionId, chatKey)
142
+ }
143
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Per-turn lifecycle manager.
3
+ *
4
+ * Each turn (user prompt → AI response) gets:
5
+ * - One AbortController (abort() cleans up all listeners + timers)
6
+ * - A set of tracked timers (auto-cleared on end)
7
+ * - Accumulated text from SSE events (for final send on session.idle)
8
+ *
9
+ * Anti-leak: end() or abortAll() guarantees full cleanup.
10
+ */
11
+
12
+ export type ActiveTurn = {
13
+ sessionId: string
14
+ chatId: number
15
+ abortController: AbortController
16
+ accumulatedText: string
17
+ toolSuffix: string
18
+ timers: Set<ReturnType<typeof setTimeout>>
19
+ draft: { stop(): void; getMessageId(): number | null; update(text: string): Promise<void> } | null
20
+ }
21
+
22
+ export class TurnManager {
23
+ private active = new Map<string, ActiveTurn>()
24
+
25
+ start(sessionId: string, chatId: number): ActiveTurn {
26
+ // If there's an existing turn, end it first
27
+ const existing = this.active.get(sessionId)
28
+ if (existing) {
29
+ this.endTurn(existing)
30
+ }
31
+
32
+ const turn: ActiveTurn = {
33
+ sessionId,
34
+ chatId,
35
+ abortController: new AbortController(),
36
+ accumulatedText: "",
37
+ toolSuffix: "",
38
+ timers: new Set(),
39
+ draft: null,
40
+ }
41
+
42
+ this.active.set(sessionId, turn)
43
+ return turn
44
+ }
45
+
46
+ get(sessionId: string): ActiveTurn | undefined {
47
+ return this.active.get(sessionId)
48
+ }
49
+
50
+ end(sessionId: string): void {
51
+ const turn = this.active.get(sessionId)
52
+ if (turn) {
53
+ this.endTurn(turn)
54
+ this.active.delete(sessionId)
55
+ }
56
+ }
57
+
58
+ addTimer(sessionId: string, timer: ReturnType<typeof setTimeout>): void {
59
+ const turn = this.active.get(sessionId)
60
+ if (turn) {
61
+ turn.timers.add(timer)
62
+ }
63
+ }
64
+
65
+ abortAll(): void {
66
+ for (const [sessionId, turn] of this.active) {
67
+ this.endTurn(turn)
68
+ }
69
+ this.active.clear()
70
+ }
71
+
72
+ get size(): number {
73
+ return this.active.size
74
+ }
75
+
76
+ private endTurn(turn: ActiveTurn): void {
77
+ // Stop draft stream if active
78
+ turn.draft?.stop()
79
+ // Abort all listeners registered with this signal
80
+ turn.abortController.abort()
81
+ // Clear all tracked timers
82
+ for (const timer of turn.timers) {
83
+ clearTimeout(timer)
84
+ }
85
+ turn.timers.clear()
86
+ }
87
+ }