opencode-telegram-mirror 0.4.2 → 0.5.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 +1 -1
- package/src/main.ts +129 -1
- package/src/telegram.ts +3 -2
- package/test/test-title-gen.ts +126 -0
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -83,6 +83,73 @@ async function updateStatusMessage(
|
|
|
83
83
|
log("debug", "Status message update", { messageId, state, success })
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Generate a session title using OpenCode with a lightweight model.
|
|
88
|
+
* Returns { type: "title", value: string } if successful,
|
|
89
|
+
* or { type: "unknown", value: string } if more context is needed.
|
|
90
|
+
*/
|
|
91
|
+
type TitleResult =
|
|
92
|
+
| { type: "unknown"; value: string }
|
|
93
|
+
| { type: "title"; value: string }
|
|
94
|
+
|
|
95
|
+
async function generateSessionTitle(
|
|
96
|
+
server: OpenCodeServer,
|
|
97
|
+
userMessage: string
|
|
98
|
+
): Promise<TitleResult> {
|
|
99
|
+
const tempSession = await server.client.session.create({ title: "title-gen" })
|
|
100
|
+
|
|
101
|
+
if (!tempSession.data) {
|
|
102
|
+
return { type: "unknown", value: "failed to create temp session" }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const response = await server.client.session.prompt({
|
|
107
|
+
sessionID: tempSession.data.id,
|
|
108
|
+
model: { providerID: "opencode", modelID: "glm-4.7-free" },
|
|
109
|
+
system: `You generate short titles for coding sessions based on user messages.
|
|
110
|
+
|
|
111
|
+
If the message provides enough context to understand the task, respond with:
|
|
112
|
+
{"type":"title","value":"<title here>"}
|
|
113
|
+
|
|
114
|
+
If the message is just a branch name, file path, or lacks context to understand what the user wants to do, respond with:
|
|
115
|
+
{"type":"unknown","value":"<brief reason>"}
|
|
116
|
+
|
|
117
|
+
Title rules (when generating):
|
|
118
|
+
- max 50 characters
|
|
119
|
+
- summarize the user's intent
|
|
120
|
+
- one line, no quotes or colons
|
|
121
|
+
- if a Linear ticket ID exists in the message (e.g. APP-550, ENG-123), always prefix the title with it
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
- "feat/add-login" -> {"type":"unknown","value":"branch name only, need task description"}
|
|
125
|
+
- "fix the auth bug in login.ts" -> {"type":"title","value":"Fix auth bug in login"}
|
|
126
|
+
- "src/components/Button.tsx" -> {"type":"unknown","value":"file path only, need task description"}
|
|
127
|
+
- "add dark mode toggle to settings" -> {"type":"title","value":"Add dark mode toggle to settings"}
|
|
128
|
+
- "APP-550: fix auth bug" -> {"type":"title","value":"APP-550: Fix auth bug"}
|
|
129
|
+
- "feat/APP-123-add-user-profile" -> {"type":"unknown","value":"branch name only, need task description"}
|
|
130
|
+
- "working on APP-123 to add user profiles" -> {"type":"title","value":"APP-123: Add user profiles"}
|
|
131
|
+
- "https://linear.app/team/issue/ENG-456/fix-button" -> {"type":"title","value":"ENG-456: Fix button"}
|
|
132
|
+
|
|
133
|
+
Respond with only valid JSON, nothing else.`,
|
|
134
|
+
parts: [{ type: "text", text: userMessage }],
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const textPart = response.data?.parts?.find(
|
|
138
|
+
(p: { type: string }) => p.type === "text"
|
|
139
|
+
) as { type: "text"; text: string } | undefined
|
|
140
|
+
const text = textPart?.text?.trim() || ""
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text) as TitleResult
|
|
144
|
+
} catch {
|
|
145
|
+
// If LLM didn't return valid JSON, treat response as title
|
|
146
|
+
return { type: "title", value: text.slice(0, 50) }
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
await server.client.session.delete({ sessionID: tempSession.data.id })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
86
153
|
interface BotState {
|
|
87
154
|
server: OpenCodeServer;
|
|
88
155
|
telegram: TelegramClient;
|
|
@@ -94,6 +161,7 @@ interface BotState {
|
|
|
94
161
|
updatesUrl: string | null;
|
|
95
162
|
botUserId: number | null;
|
|
96
163
|
sessionId: string | null;
|
|
164
|
+
needsTitle: boolean;
|
|
97
165
|
|
|
98
166
|
assistantMessageIds: Set<string>;
|
|
99
167
|
pendingParts: Map<string, Part[]>;
|
|
@@ -260,6 +328,7 @@ async function main() {
|
|
|
260
328
|
updatesUrl: config.updatesUrl || null,
|
|
261
329
|
botUserId: botInfo.id,
|
|
262
330
|
sessionId,
|
|
331
|
+
needsTitle: !initialThreadTitle,
|
|
263
332
|
assistantMessageIds: new Set(),
|
|
264
333
|
pendingParts: new Map(),
|
|
265
334
|
sentPartIds: new Set(),
|
|
@@ -384,6 +453,7 @@ Do not start implementing until you have clarity on what needs to be done.`
|
|
|
384
453
|
|
|
385
454
|
if (sessionResult.data?.id) {
|
|
386
455
|
state.sessionId = sessionResult.data.id
|
|
456
|
+
state.needsTitle = true
|
|
387
457
|
setSessionId(sessionResult.data.id, log)
|
|
388
458
|
log("info", "Created OpenCode session", { sessionId: state.sessionId })
|
|
389
459
|
|
|
@@ -748,6 +818,7 @@ async function handleTelegramMessage(
|
|
|
748
818
|
})
|
|
749
819
|
if (result.data) {
|
|
750
820
|
state.sessionId = result.data.id
|
|
821
|
+
state.needsTitle = true
|
|
751
822
|
setSessionId(result.data.id, log)
|
|
752
823
|
log("info", "Created session for command", { sessionId: result.data.id })
|
|
753
824
|
} else {
|
|
@@ -821,6 +892,7 @@ async function handleTelegramMessage(
|
|
|
821
892
|
|
|
822
893
|
if (result.data) {
|
|
823
894
|
state.sessionId = result.data.id
|
|
895
|
+
state.needsTitle = true
|
|
824
896
|
setSessionId(result.data.id, log)
|
|
825
897
|
log("info", "Created session", { sessionId: result.data.id })
|
|
826
898
|
} else {
|
|
@@ -836,11 +908,13 @@ async function handleTelegramMessage(
|
|
|
836
908
|
> = []
|
|
837
909
|
|
|
838
910
|
if (msg.photo && msg.photo.length > 0) {
|
|
911
|
+
const stopTyping = state.telegram.startTyping()
|
|
839
912
|
const bestPhoto = msg.photo[msg.photo.length - 1]
|
|
840
913
|
const dataUrlResult = await state.telegram.downloadFileAsDataUrl(
|
|
841
914
|
bestPhoto.file_id,
|
|
842
915
|
"image/jpeg"
|
|
843
916
|
)
|
|
917
|
+
stopTyping()
|
|
844
918
|
if (dataUrlResult.status === "ok") {
|
|
845
919
|
parts.push({
|
|
846
920
|
type: "file",
|
|
@@ -862,6 +936,8 @@ async function handleTelegramMessage(
|
|
|
862
936
|
return
|
|
863
937
|
}
|
|
864
938
|
|
|
939
|
+
const stopTyping = state.telegram.startTyping()
|
|
940
|
+
|
|
865
941
|
log("info", "Processing voice message", {
|
|
866
942
|
duration: msg.voice.duration,
|
|
867
943
|
fileId: msg.voice.file_id,
|
|
@@ -869,6 +945,7 @@ async function handleTelegramMessage(
|
|
|
869
945
|
|
|
870
946
|
const fileUrlResult = await state.telegram.getFileUrl(msg.voice.file_id)
|
|
871
947
|
if (fileUrlResult.status === "error") {
|
|
948
|
+
stopTyping()
|
|
872
949
|
log("error", "Failed to get voice file URL", {
|
|
873
950
|
error: fileUrlResult.error.message,
|
|
874
951
|
})
|
|
@@ -878,6 +955,7 @@ async function handleTelegramMessage(
|
|
|
878
955
|
|
|
879
956
|
const audioResponse = await fetch(fileUrlResult.value)
|
|
880
957
|
if (!audioResponse.ok) {
|
|
958
|
+
stopTyping()
|
|
881
959
|
log("error", "Failed to download voice file", { status: audioResponse.status })
|
|
882
960
|
await state.telegram.sendMessage("Failed to download voice message.")
|
|
883
961
|
return
|
|
@@ -885,6 +963,7 @@ async function handleTelegramMessage(
|
|
|
885
963
|
|
|
886
964
|
const audioBuffer = await audioResponse.arrayBuffer()
|
|
887
965
|
const transcriptionResult = await transcribeVoice(audioBuffer, log)
|
|
966
|
+
stopTyping()
|
|
888
967
|
|
|
889
968
|
if (transcriptionResult.status === "error") {
|
|
890
969
|
log("error", "Voice transcription failed", {
|
|
@@ -919,6 +998,38 @@ async function handleTelegramMessage(
|
|
|
919
998
|
})
|
|
920
999
|
|
|
921
1000
|
log("info", "Prompt sent", { sessionId: state.sessionId })
|
|
1001
|
+
|
|
1002
|
+
if (state.needsTitle && state.sessionId) {
|
|
1003
|
+
const textContent = parts
|
|
1004
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
1005
|
+
.map((p) => p.text)
|
|
1006
|
+
.join("\n")
|
|
1007
|
+
|
|
1008
|
+
if (textContent) {
|
|
1009
|
+
generateSessionTitle(state.server, textContent)
|
|
1010
|
+
.then(async (result) => {
|
|
1011
|
+
if (result.type === "title" && state.sessionId) {
|
|
1012
|
+
log("info", "Generated session title", { title: result.value })
|
|
1013
|
+
const updateResult = await state.server.client.session.update({
|
|
1014
|
+
sessionID: state.sessionId,
|
|
1015
|
+
title: result.value,
|
|
1016
|
+
})
|
|
1017
|
+
if (updateResult.data) {
|
|
1018
|
+
state.threadTitle = result.value
|
|
1019
|
+
state.needsTitle = false
|
|
1020
|
+
if (state.threadId) {
|
|
1021
|
+
await state.telegram.editForumTopic(state.threadId, result.value)
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
} else {
|
|
1025
|
+
log("debug", "Title generation deferred", { reason: result.value })
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
.catch((err) => {
|
|
1029
|
+
log("error", "Title generation failed", { error: String(err) })
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
922
1033
|
}
|
|
923
1034
|
|
|
924
1035
|
async function handleTelegramCallback(
|
|
@@ -1071,6 +1182,23 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
|
|
|
1071
1182
|
|
|
1072
1183
|
if (!sessionId || sessionId !== state.sessionId) return
|
|
1073
1184
|
|
|
1185
|
+
// Stop typing when session becomes idle
|
|
1186
|
+
if (ev.type === "session.idle") {
|
|
1187
|
+
for (const [key, entry] of state.typingIndicators) {
|
|
1188
|
+
if (key.startsWith(`${sessionId}:`)) {
|
|
1189
|
+
if (entry.timeout) clearTimeout(entry.timeout)
|
|
1190
|
+
entry.stop()
|
|
1191
|
+
state.typingIndicators.delete(key)
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Send typing action on every session event to keep indicator active during long operations
|
|
1198
|
+
if (ev.type !== "session.error") {
|
|
1199
|
+
state.telegram.sendTypingAction()
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1074
1202
|
if (sessionTitle && state.threadId) {
|
|
1075
1203
|
const trimmedTitle = sessionTitle.trim()
|
|
1076
1204
|
const shouldUpdate = trimmedTitle && trimmedTitle !== state.threadTitle
|
|
@@ -1140,7 +1268,7 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
|
|
|
1140
1268
|
existing.stop()
|
|
1141
1269
|
}
|
|
1142
1270
|
|
|
1143
|
-
const stop = state.telegram.startTyping(mode === "tool" ?
|
|
1271
|
+
const stop = state.telegram.startTyping(mode === "tool" ? 1500 : 2500)
|
|
1144
1272
|
state.typingIndicators.set(targetKey, { stop, timeout: null, mode })
|
|
1145
1273
|
}
|
|
1146
1274
|
|
package/src/telegram.ts
CHANGED
|
@@ -505,11 +505,12 @@ export class TelegramClient {
|
|
|
505
505
|
* Send typing indicator to show the bot is working
|
|
506
506
|
* Returns a stop function to cancel the typing indicator
|
|
507
507
|
*/
|
|
508
|
-
startTyping(intervalMs =
|
|
508
|
+
startTyping(intervalMs = 2500): () => void {
|
|
509
509
|
// Send immediately
|
|
510
510
|
this.sendTypingAction()
|
|
511
511
|
|
|
512
|
-
// Telegram typing indicator lasts ~5 seconds, so refresh every
|
|
512
|
+
// Telegram typing indicator lasts ~5 seconds, so refresh every 2.5 seconds by default
|
|
513
|
+
// to ensure continuous typing even with network delays
|
|
513
514
|
const interval = setInterval(() => {
|
|
514
515
|
this.sendTypingAction()
|
|
515
516
|
}, intervalMs)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Quick test for session title generation
|
|
4
|
+
* Usage: bun run test/test-title-gen.ts
|
|
5
|
+
*
|
|
6
|
+
* Requires OPENCODE_URL env var or will start its own server
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { startServer, connectToServer, stopServer, type OpenCodeServer } from "../src/opencode"
|
|
10
|
+
|
|
11
|
+
type TitleResult =
|
|
12
|
+
| { type: "unknown"; value: string }
|
|
13
|
+
| { type: "title"; value: string }
|
|
14
|
+
|
|
15
|
+
async function generateSessionTitle(
|
|
16
|
+
server: OpenCodeServer,
|
|
17
|
+
userMessage: string
|
|
18
|
+
): Promise<TitleResult> {
|
|
19
|
+
const tempSession = await server.client.session.create({ title: "title-gen" })
|
|
20
|
+
|
|
21
|
+
if (!tempSession.data) {
|
|
22
|
+
return { type: "unknown", value: "failed to create temp session" }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await server.client.session.prompt({
|
|
27
|
+
sessionID: tempSession.data.id,
|
|
28
|
+
model: { providerID: "opencode", modelID: "glm-4.7-free" },
|
|
29
|
+
system: `You generate short titles for coding sessions based on user messages.
|
|
30
|
+
|
|
31
|
+
If the message provides enough context to understand the task, respond with:
|
|
32
|
+
{"type":"title","value":"<title here>"}
|
|
33
|
+
|
|
34
|
+
If the message is just a branch name, file path, or lacks context to understand what the user wants to do, respond with:
|
|
35
|
+
{"type":"unknown","value":"<brief reason>"}
|
|
36
|
+
|
|
37
|
+
Title rules (when generating):
|
|
38
|
+
- max 50 characters
|
|
39
|
+
- summarize the user's intent
|
|
40
|
+
- one line, no quotes or colons
|
|
41
|
+
- if a Linear ticket ID exists in the message (e.g. APP-550, ENG-123), always prefix the title with it
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
- "feat/add-login" -> {"type":"unknown","value":"branch name only, need task description"}
|
|
45
|
+
- "fix the auth bug in login.ts" -> {"type":"title","value":"Fix auth bug in login"}
|
|
46
|
+
- "src/components/Button.tsx" -> {"type":"unknown","value":"file path only, need task description"}
|
|
47
|
+
- "add dark mode toggle to settings" -> {"type":"title","value":"Add dark mode toggle to settings"}
|
|
48
|
+
- "APP-550: fix auth bug" -> {"type":"title","value":"APP-550: Fix auth bug"}
|
|
49
|
+
- "feat/APP-123-add-user-profile" -> {"type":"unknown","value":"branch name only, need task description"}
|
|
50
|
+
- "working on APP-123 to add user profiles" -> {"type":"title","value":"APP-123: Add user profiles"}
|
|
51
|
+
- "https://linear.app/team/issue/ENG-456/fix-button" -> {"type":"title","value":"ENG-456: Fix button"}
|
|
52
|
+
|
|
53
|
+
Respond with only valid JSON, nothing else.`,
|
|
54
|
+
parts: [{ type: "text", text: userMessage }],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const textPart = response.data?.parts?.find(
|
|
58
|
+
(p: { type: string }) => p.type === "text"
|
|
59
|
+
) as { type: "text"; text: string } | undefined
|
|
60
|
+
const text = textPart?.text?.trim() || ""
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text) as TitleResult
|
|
64
|
+
} catch {
|
|
65
|
+
return { type: "title", value: text.slice(0, 50) }
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
await server.client.session.delete({ sessionID: tempSession.data.id })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const testCases = [
|
|
73
|
+
"feat/add-login",
|
|
74
|
+
"fix the auth bug in login.ts",
|
|
75
|
+
"src/components/Button.tsx",
|
|
76
|
+
"add dark mode toggle to settings",
|
|
77
|
+
"APP-550: fix auth bug",
|
|
78
|
+
"feat/APP-123-add-user-profile",
|
|
79
|
+
"working on APP-123 to add user profiles",
|
|
80
|
+
"https://linear.app/team/issue/ENG-456/fix-button",
|
|
81
|
+
"andrew/test-branch",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
const openCodeUrl = process.env.OPENCODE_URL
|
|
86
|
+
let server: OpenCodeServer
|
|
87
|
+
let startedServer = false
|
|
88
|
+
|
|
89
|
+
if (openCodeUrl) {
|
|
90
|
+
console.log(`Connecting to OpenCode at ${openCodeUrl}...`)
|
|
91
|
+
const result = await connectToServer(openCodeUrl, process.cwd())
|
|
92
|
+
if (result.status === "error") {
|
|
93
|
+
console.error("Failed to connect:", result.error.message)
|
|
94
|
+
process.exit(1)
|
|
95
|
+
}
|
|
96
|
+
server = result.value
|
|
97
|
+
} else {
|
|
98
|
+
console.log("Starting OpenCode server...")
|
|
99
|
+
const result = await startServer(process.cwd())
|
|
100
|
+
if (result.status === "error") {
|
|
101
|
+
console.error("Failed to start:", result.error.message)
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
server = result.value
|
|
105
|
+
startedServer = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log("\n=== Testing Title Generation ===\n")
|
|
109
|
+
|
|
110
|
+
for (const testCase of testCases) {
|
|
111
|
+
console.log(`Input: "${testCase}"`)
|
|
112
|
+
try {
|
|
113
|
+
const result = await generateSessionTitle(server, testCase)
|
|
114
|
+
console.log(`Result: ${JSON.stringify(result)}`)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.log(`Error: ${err}`)
|
|
117
|
+
}
|
|
118
|
+
console.log()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (startedServer) {
|
|
122
|
+
await stopServer()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch(console.error)
|