opencode-telegram-mirror 0.4.0 → 0.4.3

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/README.md CHANGED
@@ -44,7 +44,7 @@ npm install -g opencode-telegram-mirror
44
44
  opencode-telegram-mirror .
45
45
  ```
46
46
 
47
- That's it! Your OpenCode sessions will now be mirrored to Telegram.
47
+ That's it! Your OpenCode session will now be mirrored to Telegram.
48
48
 
49
49
  ## How it works
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-mirror",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
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
@@ -98,6 +98,10 @@ interface BotState {
98
98
  assistantMessageIds: Set<string>;
99
99
  pendingParts: Map<string, Part[]>;
100
100
  sentPartIds: Set<string>;
101
+ typingIndicators: Map<
102
+ string,
103
+ { stop: () => void; timeout: ReturnType<typeof setTimeout> | null; mode: "idle" | "tool" }
104
+ >;
101
105
  }
102
106
 
103
107
  async function main() {
@@ -259,6 +263,7 @@ async function main() {
259
263
  assistantMessageIds: new Set(),
260
264
  pendingParts: new Map(),
261
265
  sentPartIds: new Set(),
266
+ typingIndicators: new Map(),
262
267
  }
263
268
 
264
269
  if (initialThreadTitle && config.threadId) {
@@ -644,7 +649,7 @@ async function handleTelegramMessage(
644
649
  directory: state.directory,
645
650
  })
646
651
  if (abortResult.data) {
647
- await state.telegram.sendMessage("Interrupted.")
652
+ log("info", "Abort request sent", { sessionId: state.sessionId })
648
653
  } else {
649
654
  log("error", "Failed to abort session", {
650
655
  sessionId: state.sessionId,
@@ -690,7 +695,7 @@ async function handleTelegramMessage(
690
695
  directory: state.directory,
691
696
  })
692
697
  if (abortResult.data) {
693
- await state.telegram.sendMessage("Interrupted.")
698
+ log("info", "Abort request sent", { sessionId: state.sessionId })
694
699
  } else {
695
700
  log("error", "Failed to abort session", {
696
701
  sessionId: state.sessionId,
@@ -831,11 +836,13 @@ async function handleTelegramMessage(
831
836
  > = []
832
837
 
833
838
  if (msg.photo && msg.photo.length > 0) {
839
+ const stopTyping = state.telegram.startTyping()
834
840
  const bestPhoto = msg.photo[msg.photo.length - 1]
835
841
  const dataUrlResult = await state.telegram.downloadFileAsDataUrl(
836
842
  bestPhoto.file_id,
837
843
  "image/jpeg"
838
844
  )
845
+ stopTyping()
839
846
  if (dataUrlResult.status === "ok") {
840
847
  parts.push({
841
848
  type: "file",
@@ -857,6 +864,8 @@ async function handleTelegramMessage(
857
864
  return
858
865
  }
859
866
 
867
+ const stopTyping = state.telegram.startTyping()
868
+
860
869
  log("info", "Processing voice message", {
861
870
  duration: msg.voice.duration,
862
871
  fileId: msg.voice.file_id,
@@ -864,6 +873,7 @@ async function handleTelegramMessage(
864
873
 
865
874
  const fileUrlResult = await state.telegram.getFileUrl(msg.voice.file_id)
866
875
  if (fileUrlResult.status === "error") {
876
+ stopTyping()
867
877
  log("error", "Failed to get voice file URL", {
868
878
  error: fileUrlResult.error.message,
869
879
  })
@@ -873,6 +883,7 @@ async function handleTelegramMessage(
873
883
 
874
884
  const audioResponse = await fetch(fileUrlResult.value)
875
885
  if (!audioResponse.ok) {
886
+ stopTyping()
876
887
  log("error", "Failed to download voice file", { status: audioResponse.status })
877
888
  await state.telegram.sendMessage("Failed to download voice message.")
878
889
  return
@@ -880,6 +891,7 @@ async function handleTelegramMessage(
880
891
 
881
892
  const audioBuffer = await audioResponse.arrayBuffer()
882
893
  const transcriptionResult = await transcribeVoice(audioBuffer, log)
894
+ stopTyping()
883
895
 
884
896
  if (transcriptionResult.status === "error") {
885
897
  log("error", "Voice transcription failed", {
@@ -1014,16 +1026,36 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1014
1026
  const sessionId =
1015
1027
  ev.properties?.sessionID ??
1016
1028
  ev.properties?.info?.sessionID ??
1017
- ev.properties?.part?.sessionID
1029
+ ev.properties?.part?.sessionID ??
1030
+ ev.properties?.session?.id
1018
1031
  const sessionTitle = ev.properties?.session?.title
1019
1032
 
1020
1033
  // Log errors in full and send to Telegram
1021
1034
  if (ev.type === "session.error") {
1022
1035
  const errorMsg = JSON.stringify(ev.properties, null, 2)
1036
+ const error = ev.properties?.error as
1037
+ | { name?: string; data?: { message?: string } }
1038
+ | undefined
1039
+ const errorName = error?.name
1040
+ const errorText = error?.data?.message
1041
+ const isInterrupted =
1042
+ errorName === "MessageAbortedError" || errorText === "The operation was aborted."
1043
+
1023
1044
  log("error", "OpenCode session error", {
1024
1045
  sessionId,
1025
1046
  error: ev.properties,
1026
1047
  })
1048
+
1049
+ if (isInterrupted) {
1050
+ const sendResult = await state.telegram.sendMessage("Interrupted.")
1051
+ if (sendResult.status === "error") {
1052
+ log("error", "Failed to send interrupt message", {
1053
+ error: sendResult.error.message,
1054
+ })
1055
+ }
1056
+ return
1057
+ }
1058
+
1027
1059
  // Send error to Telegram for visibility
1028
1060
  const sendResult = await state.telegram.sendMessage(
1029
1061
  `OpenCode Error:\n${errorMsg.slice(0, 3500)}`
@@ -1046,6 +1078,23 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1046
1078
 
1047
1079
  if (!sessionId || sessionId !== state.sessionId) return
1048
1080
 
1081
+ // Stop typing when session becomes idle
1082
+ if (ev.type === "session.idle") {
1083
+ for (const [key, entry] of state.typingIndicators) {
1084
+ if (key.startsWith(`${sessionId}:`)) {
1085
+ if (entry.timeout) clearTimeout(entry.timeout)
1086
+ entry.stop()
1087
+ state.typingIndicators.delete(key)
1088
+ }
1089
+ }
1090
+ return
1091
+ }
1092
+
1093
+ // Send typing action on every session event to keep indicator active during long operations
1094
+ if (ev.type !== "session.error") {
1095
+ state.telegram.sendTypingAction()
1096
+ }
1097
+
1049
1098
  if (sessionTitle && state.threadId) {
1050
1099
  const trimmedTitle = sessionTitle.trim()
1051
1100
  const shouldUpdate = trimmedTitle && trimmedTitle !== state.threadTitle
@@ -1076,6 +1125,12 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1076
1125
  const key = `${info.sessionID}:${info.id}`
1077
1126
  state.assistantMessageIds.add(key)
1078
1127
  log("debug", "Registered assistant message", { key })
1128
+ const entry = state.typingIndicators.get(key)
1129
+ if (entry && entry.mode === "tool") {
1130
+ if (entry.timeout) clearTimeout(entry.timeout)
1131
+ entry.stop()
1132
+ state.typingIndicators.delete(key)
1133
+ }
1079
1134
  }
1080
1135
  }
1081
1136
 
@@ -1093,6 +1148,39 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1093
1148
  return
1094
1149
  }
1095
1150
 
1151
+ const stopTypingIndicator = (targetKey: string) => {
1152
+ const entry = state.typingIndicators.get(targetKey)
1153
+ if (!entry) return
1154
+ if (entry.timeout) clearTimeout(entry.timeout)
1155
+ entry.stop()
1156
+ state.typingIndicators.delete(targetKey)
1157
+ }
1158
+
1159
+ const startTypingIndicator = (targetKey: string, mode: "idle" | "tool") => {
1160
+ const existing = state.typingIndicators.get(targetKey)
1161
+ if (existing && existing.mode === mode) return
1162
+ if (existing) {
1163
+ if (existing.timeout) clearTimeout(existing.timeout)
1164
+ existing.stop()
1165
+ }
1166
+
1167
+ const stop = state.telegram.startTyping(mode === "tool" ? 1500 : 2500)
1168
+ state.typingIndicators.set(targetKey, { stop, timeout: null, mode })
1169
+ }
1170
+
1171
+ const bumpTypingIndicator = (targetKey: string, mode: "idle" | "tool") => {
1172
+ const existing = state.typingIndicators.get(targetKey)
1173
+ if (!existing || existing.mode !== mode) {
1174
+ startTypingIndicator(targetKey, mode)
1175
+ return
1176
+ }
1177
+
1178
+ if (existing.timeout) clearTimeout(existing.timeout)
1179
+ existing.timeout = setTimeout(() => {
1180
+ stopTypingIndicator(targetKey)
1181
+ }, 12000)
1182
+ }
1183
+
1096
1184
  log("debug", "Processing message part", {
1097
1185
  key,
1098
1186
  partType: part.type,
@@ -1106,12 +1194,11 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1106
1194
  state.pendingParts.set(key, existing)
1107
1195
 
1108
1196
  if (part.type !== "step-finish") {
1109
- const typingResult = await state.telegram.sendTypingAction()
1110
- if (typingResult.status === "error") {
1111
- log("debug", "Typing action failed", {
1112
- error: typingResult.error.message,
1113
- })
1114
- }
1197
+ const typingMode =
1198
+ part.type === "tool" && (part.tool === "edit" || part.tool === "write")
1199
+ ? "tool"
1200
+ : "idle"
1201
+ bumpTypingIndicator(key, typingMode)
1115
1202
  }
1116
1203
 
1117
1204
  // Send tools/reasoning immediately (except edit/write tools - wait for completion to get diff data)
@@ -1139,6 +1226,7 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1139
1226
 
1140
1227
  // On step-finish, send remaining parts
1141
1228
  if (part.type === "step-finish") {
1229
+ stopTypingIndicator(key)
1142
1230
  for (const p of existing) {
1143
1231
  if (p.type === "step-start" || p.type === "step-finish") continue
1144
1232
  if (state.sentPartIds.has(p.id)) continue
@@ -1241,6 +1329,24 @@ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
1241
1329
  }
1242
1330
  }
1243
1331
 
1332
+ if (ev.type === "message.updated") {
1333
+ const info = ev.properties.info
1334
+ if (info?.role === "assistant") {
1335
+ const key = `${info.sessionID}:${info.id}`
1336
+ const entry = state.typingIndicators.get(key)
1337
+ if (entry && entry.mode === "tool") {
1338
+ const stopTypingIndicator = (targetKey: string) => {
1339
+ const existing = state.typingIndicators.get(targetKey)
1340
+ if (!existing) return
1341
+ if (existing.timeout) clearTimeout(existing.timeout)
1342
+ existing.stop()
1343
+ state.typingIndicators.delete(targetKey)
1344
+ }
1345
+ stopTypingIndicator(key)
1346
+ }
1347
+ }
1348
+ }
1349
+
1244
1350
  const threadId = state.threadId ?? 0
1245
1351
 
1246
1352
  if (ev.type === "question.asked") {
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)