@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.
- package/package.json +21 -0
- package/src/bot.ts +203 -0
- package/src/config.ts +32 -0
- package/src/event-bus.ts +90 -0
- package/src/handlers/cancel.ts +30 -0
- package/src/handlers/permissions.ts +82 -0
- package/src/handlers/questions.ts +107 -0
- package/src/handlers/typing.ts +27 -0
- package/src/index.ts +255 -0
- package/src/pending-requests.ts +73 -0
- package/src/sdk.ts +113 -0
- package/src/send/chunker.ts +59 -0
- package/src/send/draft-stream.ts +161 -0
- package/src/send/format.ts +93 -0
- package/src/send/tool-progress.ts +22 -0
- package/src/session-manager.ts +143 -0
- package/src/turn-manager.ts +87 -0
|
@@ -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(/^>\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, "&")
|
|
87
|
+
.replace(/</g, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
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
|
+
}
|