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.
- package/AGENTS.md +196 -0
- package/README.md +230 -0
- package/bun.lock +67 -0
- package/package.json +27 -0
- package/src/config.ts +99 -0
- package/src/database.ts +120 -0
- package/src/diff-service.ts +176 -0
- package/src/log.ts +23 -0
- package/src/main.ts +1182 -0
- package/src/message-formatting.ts +202 -0
- package/src/opencode.ts +306 -0
- package/src/permission-handler.ts +242 -0
- package/src/question-handler.ts +391 -0
- package/src/system-message.ts +73 -0
- package/src/telegram.ts +705 -0
- package/test/fixtures/commands-test.json +157 -0
- package/test/fixtures/sample-updates.json +9098 -0
- package/test/mock-server.ts +271 -0
- package/test/run-test.ts +160 -0
- package/tsconfig.json +26 -0
package/src/database.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple state persistence for Telegram bot
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Database } from "bun:sqlite"
|
|
6
|
+
import type { LogFn } from "./log"
|
|
7
|
+
|
|
8
|
+
const DB_PATH = process.env.TELEGRAM_DB_PATH || "/tmp/telegram-opencode.db"
|
|
9
|
+
|
|
10
|
+
let db: Database | null = null
|
|
11
|
+
|
|
12
|
+
export function getDb(log: LogFn): Database {
|
|
13
|
+
if (!db) {
|
|
14
|
+
log("debug", "Opening SQLite database", { path: DB_PATH })
|
|
15
|
+
db = new Database(DB_PATH, { create: true })
|
|
16
|
+
|
|
17
|
+
db.run(
|
|
18
|
+
"CREATE TABLE IF NOT EXISTS state (" +
|
|
19
|
+
"key TEXT PRIMARY KEY, " +
|
|
20
|
+
"value TEXT NOT NULL)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log("info", "SQLite database initialized", { path: DB_PATH })
|
|
24
|
+
}
|
|
25
|
+
return db
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the stored session ID
|
|
30
|
+
*/
|
|
31
|
+
export function getSessionId(log: LogFn): string | null {
|
|
32
|
+
const database = getDb(log)
|
|
33
|
+
type Row = { value: string }
|
|
34
|
+
const row = database
|
|
35
|
+
.query<Row, [string]>("SELECT value FROM state WHERE key = ?")
|
|
36
|
+
.get("session_id")
|
|
37
|
+
return row?.value ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Store the session ID
|
|
42
|
+
*/
|
|
43
|
+
export function setSessionId(sessionId: string, log: LogFn): void {
|
|
44
|
+
const database = getDb(log)
|
|
45
|
+
database.run(
|
|
46
|
+
"INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)",
|
|
47
|
+
["session_id", sessionId]
|
|
48
|
+
)
|
|
49
|
+
log("info", "Stored session ID", { sessionId })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the last processed Telegram update_id
|
|
54
|
+
*/
|
|
55
|
+
export function getLastUpdateId(log: LogFn): number {
|
|
56
|
+
const database = getDb(log)
|
|
57
|
+
type Row = { value: string }
|
|
58
|
+
const row = database
|
|
59
|
+
.query<Row, [string]>("SELECT value FROM state WHERE key = ?")
|
|
60
|
+
.get("last_update_id")
|
|
61
|
+
return row ? Number.parseInt(row.value, 10) : 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Store the last processed Telegram update_id
|
|
66
|
+
*/
|
|
67
|
+
export function setLastUpdateId(updateId: number, log: LogFn): void {
|
|
68
|
+
const database = getDb(log)
|
|
69
|
+
database.run(
|
|
70
|
+
"INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)",
|
|
71
|
+
["last_update_id", String(updateId)]
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the stored control message ID
|
|
77
|
+
*/
|
|
78
|
+
export function getControlMessageId(log: LogFn): number | null {
|
|
79
|
+
const database = getDb(log)
|
|
80
|
+
type Row = { value: string }
|
|
81
|
+
const row = database
|
|
82
|
+
.query<Row, [string]>("SELECT value FROM state WHERE key = ?")
|
|
83
|
+
.get("control_message_id")
|
|
84
|
+
return row ? Number.parseInt(row.value, 10) : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Store the control message ID
|
|
89
|
+
*/
|
|
90
|
+
export function setControlMessageId(messageId: number, log: LogFn): void {
|
|
91
|
+
const database = getDb(log)
|
|
92
|
+
database.run(
|
|
93
|
+
"INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)",
|
|
94
|
+
["control_message_id", String(messageId)]
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the stored session variant
|
|
100
|
+
*/
|
|
101
|
+
export function getSessionVariant(log: LogFn): string | null {
|
|
102
|
+
const database = getDb(log)
|
|
103
|
+
type Row = { value: string }
|
|
104
|
+
const row = database
|
|
105
|
+
.query<Row, [string]>("SELECT value FROM state WHERE key = ?")
|
|
106
|
+
.get("session_variant")
|
|
107
|
+
return row?.value ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Store the session variant
|
|
112
|
+
*/
|
|
113
|
+
export function setSessionVariant(variant: string, log: LogFn): void {
|
|
114
|
+
const database = getDb(log)
|
|
115
|
+
database.run(
|
|
116
|
+
"INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)",
|
|
117
|
+
["session_variant", variant]
|
|
118
|
+
)
|
|
119
|
+
log("info", "Stored session variant", { variant })
|
|
120
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff service for uploading diffs to the diff viewer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Result, TaggedError } from "better-result"
|
|
6
|
+
import type { LogFn } from "./log"
|
|
7
|
+
|
|
8
|
+
// Configure via environment variable - optional, if not set diff uploads are disabled
|
|
9
|
+
const DIFF_VIEWER_URL = process.env.DIFF_VIEWER_URL || null
|
|
10
|
+
|
|
11
|
+
export interface DiffFile {
|
|
12
|
+
path: string
|
|
13
|
+
oldContent: string
|
|
14
|
+
newContent: string
|
|
15
|
+
additions: number
|
|
16
|
+
deletions: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiffUploadResult {
|
|
20
|
+
id: string
|
|
21
|
+
url: string
|
|
22
|
+
viewerUrl: string // URL for the mini-app viewer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class DiffUploadError extends TaggedError("DiffUploadError")<{
|
|
26
|
+
message: string
|
|
27
|
+
cause: unknown
|
|
28
|
+
}>() {
|
|
29
|
+
constructor(args: { cause: unknown }) {
|
|
30
|
+
const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
31
|
+
super({ ...args, message: `Diff upload failed: ${causeMessage}` })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DiffUploadResultValue = DiffUploadResult
|
|
36
|
+
export type DiffUploadResultError = DiffUploadError
|
|
37
|
+
export type DiffUploadResultReturn = Result<DiffUploadResultValue | null, DiffUploadResultError>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Count additions and deletions between two strings
|
|
41
|
+
*/
|
|
42
|
+
function countChanges(oldContent: string, newContent: string): { additions: number; deletions: number } {
|
|
43
|
+
const oldLines = oldContent.split("\n")
|
|
44
|
+
const newLines = newContent.split("\n")
|
|
45
|
+
|
|
46
|
+
// Simple line-based diff counting
|
|
47
|
+
const oldSet = new Set(oldLines)
|
|
48
|
+
const newSet = new Set(newLines)
|
|
49
|
+
|
|
50
|
+
let additions = 0
|
|
51
|
+
let deletions = 0
|
|
52
|
+
|
|
53
|
+
for (const line of newLines) {
|
|
54
|
+
if (!oldSet.has(line)) additions++
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const line of oldLines) {
|
|
58
|
+
if (!newSet.has(line)) deletions++
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { additions, deletions }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Upload a diff to the diff viewer service
|
|
66
|
+
*/
|
|
67
|
+
export async function uploadDiff(
|
|
68
|
+
files: DiffFile[],
|
|
69
|
+
options?: { title?: string; log?: LogFn }
|
|
70
|
+
): Promise<DiffUploadResultReturn> {
|
|
71
|
+
const log = options?.log ?? (() => {})
|
|
72
|
+
|
|
73
|
+
// If DIFF_VIEWER_URL is not configured, skip diff upload
|
|
74
|
+
if (!DIFF_VIEWER_URL) {
|
|
75
|
+
log("debug", "Diff upload skipped - DIFF_VIEWER_URL not configured")
|
|
76
|
+
return Result.ok(null)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const uploadResult = await Result.tryPromise({
|
|
80
|
+
try: async () => {
|
|
81
|
+
log("debug", "Uploading diff", { fileCount: files.length, url: DIFF_VIEWER_URL })
|
|
82
|
+
|
|
83
|
+
const response = await fetch(`${DIFF_VIEWER_URL}/api/diff`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
title: options?.title,
|
|
88
|
+
files,
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`Diff upload failed: ${response.status}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const data = (await response.json()) as { id: string; url: string }
|
|
97
|
+
|
|
98
|
+
log("info", "Diff uploaded", { id: data.id, url: data.url })
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: data.id,
|
|
102
|
+
url: data.url,
|
|
103
|
+
viewerUrl: `${DIFF_VIEWER_URL}/diff/${data.id}`,
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
catch: (error) => new DiffUploadError({ cause: error }),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (uploadResult.status === "error") {
|
|
110
|
+
log("error", "Error uploading diff", { error: uploadResult.error.message })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return uploadResult
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a diff file from edit tool input
|
|
118
|
+
*/
|
|
119
|
+
export function createDiffFromEdit(input: {
|
|
120
|
+
filePath: string
|
|
121
|
+
oldString: string
|
|
122
|
+
newString: string
|
|
123
|
+
}): DiffFile {
|
|
124
|
+
const { additions, deletions } = countChanges(input.oldString, input.newString)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
path: input.filePath,
|
|
128
|
+
oldContent: input.oldString,
|
|
129
|
+
newContent: input.newString,
|
|
130
|
+
additions,
|
|
131
|
+
deletions,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generate inline diff preview (truncated for Telegram message)
|
|
137
|
+
*/
|
|
138
|
+
export function generateInlineDiffPreview(
|
|
139
|
+
oldContent: string,
|
|
140
|
+
newContent: string,
|
|
141
|
+
maxLines = 10
|
|
142
|
+
): string {
|
|
143
|
+
const oldLines = oldContent.split("\n")
|
|
144
|
+
const newLines = newContent.split("\n")
|
|
145
|
+
|
|
146
|
+
const diffLines: string[] = []
|
|
147
|
+
let lineCount = 0
|
|
148
|
+
|
|
149
|
+
// Find removed lines
|
|
150
|
+
for (const line of oldLines) {
|
|
151
|
+
if (!newLines.includes(line) && line.trim()) {
|
|
152
|
+
diffLines.push(`-${line}`)
|
|
153
|
+
lineCount++
|
|
154
|
+
if (lineCount >= maxLines) break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Find added lines
|
|
159
|
+
if (lineCount < maxLines) {
|
|
160
|
+
for (const line of newLines) {
|
|
161
|
+
if (!oldLines.includes(line) && line.trim()) {
|
|
162
|
+
diffLines.push(`+${line}`)
|
|
163
|
+
lineCount++
|
|
164
|
+
if (lineCount >= maxLines) break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (diffLines.length === 0) {
|
|
170
|
+
return ""
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `\`\`\`diff\n${diffLines.join("\n")}\n\`\`\``
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { DIFF_VIEWER_URL }
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdout logging for the Telegram plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type LogFn = (
|
|
6
|
+
level: "debug" | "info" | "warn" | "error",
|
|
7
|
+
message: string,
|
|
8
|
+
extra?: Record<string, unknown>
|
|
9
|
+
) => void
|
|
10
|
+
|
|
11
|
+
export function createLogger(): LogFn {
|
|
12
|
+
return (level, message, extra) => {
|
|
13
|
+
const timestamp = new Date().toISOString()
|
|
14
|
+
const extraStr = extra ? ` ${JSON.stringify(extra)}` : ""
|
|
15
|
+
const line = `${timestamp} [${level}] ${message}${extraStr}`
|
|
16
|
+
|
|
17
|
+
if (level === "error" || level === "warn") {
|
|
18
|
+
console.error(line)
|
|
19
|
+
} else {
|
|
20
|
+
console.log(line)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|