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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-mirror",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone bot that mirrors OpenCode sessions to Telegram topics",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
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" ? 2000 : 4000)
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 = 4000): () => void {
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 4 seconds
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)