opencode-telegram-mirror 0.3.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,202 @@
1
+ /**
2
+ * Message formatting for OpenCode parts
3
+ * Matches kimaki's Discord formatting style
4
+ */
5
+
6
+ import type { Part } from "@opencode-ai/sdk/v2"
7
+
8
+ /**
9
+ * Escapes Telegram markdown special characters
10
+ */
11
+ function escapeMarkdown(text: string): string {
12
+ return text.replace(/([*_`\[\]])/g, "\\$1")
13
+ }
14
+
15
+ /**
16
+ * Get tool summary text (file names, patterns, etc.)
17
+ */
18
+ function getToolSummaryText(part: Part): string {
19
+ if (part.type !== "tool") return ""
20
+
21
+ const input = part.state.input ?? {}
22
+
23
+ if (part.tool === "edit") {
24
+ const filePath = (input.filePath as string) || ""
25
+ const newString = (input.newString as string) || ""
26
+ const oldString = (input.oldString as string) || ""
27
+ const added = newString.split("\n").length
28
+ const removed = oldString.split("\n").length
29
+ const fileName = filePath.split("/").pop() || ""
30
+ return fileName ? `*${escapeMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`
31
+ }
32
+
33
+ if (part.tool === "write") {
34
+ const filePath = (input.filePath as string) || ""
35
+ const content = (input.content as string) || ""
36
+ const lines = content.split("\n").length
37
+ const fileName = filePath.split("/").pop() || ""
38
+ return fileName ? `*${escapeMarkdown(fileName)}* (${lines} line${lines === 1 ? "" : "s"})` : `(${lines} line${lines === 1 ? "" : "s"})`
39
+ }
40
+
41
+ if (part.tool === "webfetch") {
42
+ const url = (input.url as string) || ""
43
+ const urlWithoutProtocol = url.replace(/^https?:\/\//, "")
44
+ return urlWithoutProtocol ? `*${escapeMarkdown(urlWithoutProtocol)}*` : ""
45
+ }
46
+
47
+ if (part.tool === "read") {
48
+ const filePath = (input.filePath as string) || ""
49
+ const fileName = filePath.split("/").pop() || ""
50
+ return fileName ? `*${escapeMarkdown(fileName)}*` : ""
51
+ }
52
+
53
+ if (part.tool === "glob") {
54
+ const pattern = (input.pattern as string) || ""
55
+ return pattern ? `*${escapeMarkdown(pattern)}*` : ""
56
+ }
57
+
58
+ if (part.tool === "grep") {
59
+ const pattern = (input.pattern as string) || ""
60
+ return pattern ? `*${escapeMarkdown(pattern)}*` : ""
61
+ }
62
+
63
+ if (part.tool === "bash" || part.tool === "todoread" || part.tool === "todowrite") {
64
+ return ""
65
+ }
66
+
67
+ if (part.tool === "task") {
68
+ const description = (input.description as string) || ""
69
+ return description ? `_${escapeMarkdown(description)}_` : ""
70
+ }
71
+
72
+ return ""
73
+ }
74
+
75
+ /**
76
+ * Status indicators for todo items
77
+ */
78
+ const TODO_STATUS_ICONS: Record<string, string> = {
79
+ pending: "○",
80
+ in_progress: "◉",
81
+ completed: "✓",
82
+ cancelled: "✗",
83
+ }
84
+
85
+ /**
86
+ * Format todo list from todowrite tool
87
+ * Shows all todos with status indicators
88
+ */
89
+ function formatTodoList(part: Part): string {
90
+ if (part.type !== "tool" || part.tool !== "todowrite") return ""
91
+
92
+ const todos = (part.state.input?.todos as Array<{
93
+ content: string
94
+ status: "pending" | "in_progress" | "completed" | "cancelled"
95
+ priority?: "high" | "medium" | "low"
96
+ }>) ?? []
97
+
98
+ if (todos.length === 0) return ""
99
+
100
+ const lines: string[] = []
101
+
102
+ for (const todo of todos) {
103
+ const icon = TODO_STATUS_ICONS[todo.status] || "○"
104
+ const content = todo.content
105
+
106
+ // Format based on status
107
+ let formatted: string
108
+ if (todo.status === "in_progress") {
109
+ // Active item: bold
110
+ formatted = `${icon} *${escapeMarkdown(content)}*`
111
+ } else if (todo.status === "completed") {
112
+ // Completed: strikethrough
113
+ formatted = `${icon} ~${escapeMarkdown(content)}~`
114
+ } else if (todo.status === "cancelled") {
115
+ // Cancelled: strikethrough + italic
116
+ formatted = `${icon} ~_${escapeMarkdown(content)}_~`
117
+ } else {
118
+ // Pending: plain
119
+ formatted = `${icon} ${escapeMarkdown(content)}`
120
+ }
121
+
122
+ lines.push(formatted)
123
+ }
124
+
125
+ return lines.join("\n")
126
+ }
127
+
128
+ /**
129
+ * Format a single part for Telegram display
130
+ * Matches kimaki's formatting style
131
+ */
132
+ export function formatPart(part: Part): string {
133
+ if (part.type === "text") {
134
+ if (!part.text?.trim()) return ""
135
+ return part.text
136
+ }
137
+
138
+ if (part.type === "reasoning") {
139
+ if (!part.text?.trim()) return ""
140
+ return "> thinking"
141
+ }
142
+
143
+ if (part.type === "file") {
144
+ return `[file] ${part.filename || "File"}`
145
+ }
146
+
147
+ if (part.type === "step-start" || part.type === "step-finish" || part.type === "patch") {
148
+ return ""
149
+ }
150
+
151
+ if (part.type === "agent") {
152
+ return `> agent ${part.id}`
153
+ }
154
+
155
+ if (part.type === "tool") {
156
+ if (part.tool === "todowrite") {
157
+ return formatTodoList(part)
158
+ }
159
+
160
+ // Question tool is handled via buttons, not text
161
+ if (part.tool === "question") {
162
+ return ""
163
+ }
164
+
165
+ if (part.state.status === "pending") {
166
+ return ""
167
+ }
168
+
169
+ const summaryText = getToolSummaryText(part)
170
+ const stateTitle = "title" in part.state ? part.state.title : undefined
171
+
172
+ let toolTitle = ""
173
+ if (part.state.status === "error") {
174
+ toolTitle = part.state.error || "error"
175
+ } else if (part.tool === "bash") {
176
+ const command = (part.state.input?.command as string) || ""
177
+ const description = (part.state.input?.description as string) || ""
178
+ const isSingleLine = !command.includes("\n")
179
+ if (isSingleLine && command.length <= 50) {
180
+ toolTitle = `_${escapeMarkdown(command)}_`
181
+ } else if (description) {
182
+ toolTitle = `_${escapeMarkdown(description)}_`
183
+ } else if (stateTitle) {
184
+ toolTitle = `_${escapeMarkdown(stateTitle as string)}_`
185
+ }
186
+ } else if (stateTitle) {
187
+ toolTitle = `_${escapeMarkdown(stateTitle as string)}_`
188
+ }
189
+
190
+ const icon = (() => {
191
+ if (part.state.status === "error") return "X"
192
+ if (part.tool === "edit" || part.tool === "write") return ">"
193
+ return ">"
194
+ })()
195
+
196
+ return `${icon} ${part.tool} ${toolTitle} ${summaryText}`.trim()
197
+ }
198
+
199
+ return ""
200
+ }
201
+
202
+ export type { Part }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * OpenCode server process manager.
3
+ * Spawns and maintains a single OpenCode API server.
4
+ */
5
+
6
+ import { spawn, type ChildProcess } from "node:child_process"
7
+ import fs from "node:fs"
8
+ import net from "node:net"
9
+ import {
10
+ createOpencodeClient,
11
+ type OpencodeClient,
12
+ type Config,
13
+ } from "@opencode-ai/sdk"
14
+ import {
15
+ createOpencodeClient as createOpencodeClientV2,
16
+ type OpencodeClient as OpencodeClientV2,
17
+ } from "@opencode-ai/sdk/v2"
18
+ import { Result, TaggedError } from "better-result"
19
+ import { createLogger } from "./log"
20
+
21
+ const log = createLogger()
22
+
23
+ export interface OpenCodeServer {
24
+ process: ChildProcess | null // null when connecting to external server
25
+ client: OpencodeClient
26
+ clientV2: OpencodeClientV2
27
+ port: number
28
+ directory: string
29
+ baseUrl: string
30
+ }
31
+
32
+ export class PortLookupError extends TaggedError("PortLookupError")<{
33
+ message: string
34
+ cause: unknown
35
+ }>() {
36
+ constructor(args: { cause: unknown }) {
37
+ const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
38
+ super({ ...args, message: `Failed to get open port: ${causeMessage}` })
39
+ }
40
+ }
41
+
42
+ export class ServerStartError extends TaggedError("ServerStartError")<{
43
+ message: string
44
+ cause: unknown
45
+ }>() {
46
+ constructor(args: { cause: unknown }) {
47
+ const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
48
+ super({ ...args, message: `Server failed to start: ${causeMessage}` })
49
+ }
50
+ }
51
+
52
+ export class DirectoryAccessError extends TaggedError("DirectoryAccessError")<{
53
+ message: string
54
+ directory: string
55
+ cause: unknown
56
+ }>() {
57
+ constructor(args: { directory: string; cause: unknown }) {
58
+ const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
59
+ super({ ...args, message: `Directory not accessible: ${args.directory} (${causeMessage})` })
60
+ }
61
+ }
62
+
63
+ let server: OpenCodeServer | null = null
64
+
65
+ async function getOpenPort(): Promise<Result<number, PortLookupError>> {
66
+ return Result.tryPromise({
67
+ try: () =>
68
+ new Promise<number>((resolve, reject) => {
69
+ const srv = net.createServer()
70
+ srv.listen(0, () => {
71
+ const address = srv.address()
72
+ if (address && typeof address === "object") {
73
+ const port = address.port
74
+ srv.close(() => resolve(port))
75
+ } else {
76
+ reject(new Error("Failed to get port"))
77
+ }
78
+ })
79
+ srv.on("error", reject)
80
+ }),
81
+ catch: (error) => new PortLookupError({ cause: error }),
82
+ })
83
+ }
84
+
85
+ async function waitForServer(
86
+ port: number,
87
+ maxAttempts = 30,
88
+ baseUrl?: string
89
+ ): Promise<Result<boolean, ServerStartError>> {
90
+ const url = baseUrl || `http://127.0.0.1:${port}`
91
+
92
+ for (let i = 0; i < maxAttempts; i++) {
93
+ const responseResult = await Result.tryPromise({
94
+ try: () =>
95
+ fetch(`${url}/session`, {
96
+ signal: AbortSignal.timeout(2000),
97
+ }),
98
+ catch: (error) => new ServerStartError({ cause: error }),
99
+ })
100
+
101
+ if (responseResult.status === "ok") {
102
+ if (responseResult.value.status < 500) {
103
+ return Result.ok(true)
104
+ }
105
+ }
106
+
107
+ await new Promise((r) => setTimeout(r, 1000))
108
+ }
109
+
110
+ return Result.err(
111
+ new ServerStartError({
112
+ cause: new Error(`Server did not start at ${url} after ${maxAttempts} seconds`),
113
+ })
114
+ )
115
+ }
116
+
117
+ /**
118
+ * Connect to an already-running OpenCode server
119
+ */
120
+ export async function connectToServer(
121
+ baseUrl: string,
122
+ directory: string
123
+ ): Promise<Result<OpenCodeServer, ServerStartError>> {
124
+ // Reuse existing server if connected to same URL
125
+ if (server && server.baseUrl === baseUrl) {
126
+ log("info", "Reusing existing connection", { baseUrl })
127
+ return Result.ok(server)
128
+ }
129
+
130
+ log("info", "Connecting to external OpenCode server", { baseUrl })
131
+
132
+ // Extract port from URL
133
+ const url = new URL(baseUrl)
134
+ const port = Number(url.port) || (url.protocol === "https:" ? 443 : 80)
135
+
136
+ // Wait for server to be ready
137
+ const readyResult = await waitForServer(port, 30, baseUrl)
138
+ if (readyResult.status === "error") {
139
+ return Result.err(readyResult.error)
140
+ }
141
+
142
+ log("info", "External server ready", { baseUrl })
143
+
144
+ const fetchWithTimeout = (request: Request) =>
145
+ fetch(request, {
146
+ // @ts-ignore - bun supports timeout
147
+ timeout: false,
148
+ })
149
+
150
+ const client = createOpencodeClient({
151
+ baseUrl,
152
+ fetch: fetchWithTimeout,
153
+ })
154
+
155
+ const clientV2 = createOpencodeClientV2({
156
+ baseUrl,
157
+ fetch: fetchWithTimeout as typeof fetch,
158
+ })
159
+
160
+ server = {
161
+ process: null, // No process - external server
162
+ client,
163
+ clientV2,
164
+ port,
165
+ directory,
166
+ baseUrl,
167
+ }
168
+
169
+ return Result.ok(server)
170
+ }
171
+
172
+ export async function startServer(
173
+ directory: string
174
+ ): Promise<Result<OpenCodeServer, DirectoryAccessError | PortLookupError | ServerStartError>> {
175
+ // Reuse existing server if running
176
+ if (server?.process && !server.process.killed) {
177
+ log("info", "Reusing existing server", { directory, port: server.port })
178
+ return Result.ok(server)
179
+ }
180
+
181
+ // Verify directory exists
182
+ const accessResult = Result.try({
183
+ try: () => fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK),
184
+ catch: (error) => new DirectoryAccessError({ directory, cause: error }),
185
+ })
186
+
187
+ if (accessResult.status === "error") {
188
+ return Result.err(accessResult.error)
189
+ }
190
+
191
+ const envPort = process.env.OPENCODE_PORT
192
+ const parsedPort = envPort ? Number(envPort) : null
193
+ const portResult = parsedPort && !Number.isNaN(parsedPort) ? Result.ok(parsedPort) : await getOpenPort()
194
+
195
+ if (portResult.status === "error") {
196
+ return Result.err(portResult.error)
197
+ }
198
+
199
+ const port = portResult.value
200
+ const opencodePath = process.env.OPENCODE_PATH || `${process.env.HOME}/.opencode/bin/opencode`
201
+
202
+ log("info", "Starting opencode serve", { directory, port })
203
+
204
+ const serverProcess = spawn(opencodePath, ["serve", "--port", port.toString()], {
205
+ stdio: "pipe",
206
+ detached: false,
207
+ cwd: directory,
208
+ env: {
209
+ ...process.env,
210
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
211
+ $schema: "https://opencode.ai/config.json",
212
+ lsp: false,
213
+ formatter: false,
214
+ permission: {
215
+ edit: "allow",
216
+ bash: "allow",
217
+ webfetch: "allow",
218
+ },
219
+ } satisfies Config),
220
+ },
221
+ })
222
+
223
+ serverProcess.stdout?.on("data", (data) => {
224
+ log("debug", "opencode stdout", { data: data.toString().trim().slice(0, 200) })
225
+ })
226
+
227
+ serverProcess.stderr?.on("data", (data) => {
228
+ log("debug", "opencode stderr", { data: data.toString().trim().slice(0, 200) })
229
+ })
230
+
231
+ serverProcess.on("error", (error) => {
232
+ log("error", "Server process error", { directory, error: String(error) })
233
+ })
234
+
235
+ serverProcess.on("exit", (code) => {
236
+ log("info", "Server exited", { directory, code })
237
+ server = null
238
+
239
+ if (code !== 0) {
240
+ log("info", "Restarting server", { directory })
241
+ startServer(directory).then((result) => {
242
+ if (result.status === "error") {
243
+ log("error", "Failed to restart server", { error: result.error.message })
244
+ }
245
+ })
246
+ }
247
+ })
248
+
249
+ const readyResult = await waitForServer(port)
250
+ if (readyResult.status === "error") {
251
+ return Result.err(readyResult.error)
252
+ }
253
+
254
+ log("info", "Server ready", { directory, port })
255
+
256
+ const baseUrl = `http://127.0.0.1:${port}`
257
+ const fetchWithTimeout = (request: Request) =>
258
+ fetch(request, {
259
+ // @ts-ignore - bun supports timeout
260
+ timeout: false,
261
+ })
262
+
263
+ const client = createOpencodeClient({
264
+ baseUrl,
265
+ fetch: fetchWithTimeout,
266
+ })
267
+
268
+ const clientV2 = createOpencodeClientV2({
269
+ baseUrl,
270
+ fetch: fetchWithTimeout as typeof fetch,
271
+ })
272
+
273
+ server = {
274
+ process: serverProcess,
275
+ client,
276
+ clientV2,
277
+ port,
278
+ directory,
279
+ baseUrl,
280
+ }
281
+
282
+ return Result.ok(server)
283
+ }
284
+
285
+ export function getServer(): OpenCodeServer | null {
286
+ return server
287
+ }
288
+
289
+ export async function stopServer(): Promise<Result<void, ServerStartError>> {
290
+ if (!server) {
291
+ return Result.ok(undefined)
292
+ }
293
+
294
+ const serverToStop = server
295
+
296
+ const stopResult = Result.try({
297
+ try: () => {
298
+ serverToStop.process?.kill()
299
+ log("info", "Server stopped", { directory: serverToStop.directory })
300
+ server = null
301
+ },
302
+ catch: (error) => new ServerStartError({ cause: error }),
303
+ })
304
+
305
+ return stopResult.map(() => undefined)
306
+ }