opencode-tbot 0.1.24 → 0.1.25

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
@@ -12,6 +12,7 @@ A Telegram plugin for driving [OpenCode](https://opencode.ai) from chat.
12
12
 
13
13
  - Text messages are forwarded to the active OpenCode session.
14
14
  - Telegram photos and image documents are uploaded as OpenCode file parts.
15
+ - Image turns run in a temporary forked session, so later text-only prompts do not inherit image context.
15
16
  - Telegram voice messages are explicitly rejected with a localized reply.
16
17
  - Permission requests raised by OpenCode can be approved or rejected from Telegram inline buttons.
17
18
  - Session completion and error events can be reported back to the bound Telegram chat.
@@ -124,6 +125,7 @@ Message handling:
124
125
 
125
126
  - Non-command text is treated as a prompt and sent to OpenCode.
126
127
  - Telegram photos and image documents are forwarded as OpenCode file parts.
128
+ - Image attachments are processed in a temporary fork of the active session so later text-only prompts stay clean.
127
129
  - Telegram voice messages are not supported and receive a localized rejection reply.
128
130
 
129
131
  ## Development
package/dist/plugin.js CHANGED
@@ -283,6 +283,24 @@ var OpenCodeClient = class {
283
283
  });
284
284
  return unwrapSdkData(await this.client.session.abort({ sessionID: sessionId }, SDK_OPTIONS));
285
285
  }
286
+ async deleteSession(sessionId) {
287
+ if (hasRawSdkMethod(this.client, "delete")) return this.requestRaw("delete", {
288
+ url: "/session/{sessionID}",
289
+ path: { sessionID: sessionId }
290
+ });
291
+ return unwrapSdkData(await this.client.session.delete({ sessionID: sessionId }, SDK_OPTIONS));
292
+ }
293
+ async forkSession(sessionId, messageId) {
294
+ if (hasRawSdkMethod(this.client, "post")) return this.requestRaw("post", {
295
+ url: "/session/{sessionID}/fork",
296
+ path: { sessionID: sessionId },
297
+ ...messageId?.trim() ? { body: { messageID: messageId.trim() } } : {}
298
+ });
299
+ return unwrapSdkData(await this.client.session.fork({
300
+ sessionID: sessionId,
301
+ ...messageId?.trim() ? { messageID: messageId.trim() } : {}
302
+ }, SDK_OPTIONS));
303
+ }
286
304
  async getPath() {
287
305
  if (hasRawSdkMethod(this.client, "get")) return this.requestRaw("get", { url: "/path" });
288
306
  return unwrapSdkData(await this.client.path.get(void 0, SDK_OPTIONS));
@@ -1146,29 +1164,73 @@ function extractErrorMessage(error) {
1146
1164
  //#endregion
1147
1165
  //#region src/services/session-activity/foreground-session-tracker.ts
1148
1166
  var ForegroundSessionTracker = class {
1167
+ chatStacks = /* @__PURE__ */ new Map();
1149
1168
  counts = /* @__PURE__ */ new Map();
1150
- begin(sessionId) {
1169
+ sessionChats = /* @__PURE__ */ new Map();
1170
+ begin(chatId, sessionId) {
1151
1171
  const currentCount = this.counts.get(sessionId) ?? 0;
1152
1172
  this.counts.set(sessionId, currentCount + 1);
1173
+ this.incrementChat(chatId, sessionId);
1153
1174
  return () => {
1154
- this.decrement(sessionId);
1175
+ this.decrement(chatId, sessionId);
1155
1176
  };
1156
1177
  }
1157
1178
  clear(sessionId) {
1158
1179
  const wasForeground = this.counts.has(sessionId);
1180
+ const chatCounts = this.sessionChats.get(sessionId);
1159
1181
  this.counts.delete(sessionId);
1182
+ this.sessionChats.delete(sessionId);
1183
+ for (const chatId of chatCounts?.keys() ?? []) {
1184
+ const stack = this.chatStacks.get(chatId);
1185
+ if (!stack) continue;
1186
+ const nextStack = stack.filter((trackedSessionId) => trackedSessionId !== sessionId);
1187
+ if (nextStack.length === 0) {
1188
+ this.chatStacks.delete(chatId);
1189
+ continue;
1190
+ }
1191
+ this.chatStacks.set(chatId, nextStack);
1192
+ }
1160
1193
  return wasForeground;
1161
1194
  }
1195
+ getActiveSessionId(chatId) {
1196
+ return this.chatStacks.get(chatId)?.at(-1) ?? null;
1197
+ }
1162
1198
  isForeground(sessionId) {
1163
1199
  return this.counts.has(sessionId);
1164
1200
  }
1165
- decrement(sessionId) {
1201
+ listChatIds(sessionId) {
1202
+ return [...this.sessionChats.get(sessionId)?.keys() ?? []];
1203
+ }
1204
+ decrement(chatId, sessionId) {
1166
1205
  const currentCount = this.counts.get(sessionId);
1167
- if (!currentCount || currentCount <= 1) {
1168
- this.counts.delete(sessionId);
1169
- return;
1206
+ if (!currentCount || currentCount <= 1) this.counts.delete(sessionId);
1207
+ else this.counts.set(sessionId, currentCount - 1);
1208
+ this.decrementChat(chatId, sessionId);
1209
+ }
1210
+ incrementChat(chatId, sessionId) {
1211
+ const stack = this.chatStacks.get(chatId) ?? [];
1212
+ stack.push(sessionId);
1213
+ this.chatStacks.set(chatId, stack);
1214
+ const chatCounts = this.sessionChats.get(sessionId) ?? /* @__PURE__ */ new Map();
1215
+ const currentCount = chatCounts.get(chatId) ?? 0;
1216
+ chatCounts.set(chatId, currentCount + 1);
1217
+ this.sessionChats.set(sessionId, chatCounts);
1218
+ }
1219
+ decrementChat(chatId, sessionId) {
1220
+ const stack = this.chatStacks.get(chatId);
1221
+ if (stack) {
1222
+ const index = stack.lastIndexOf(sessionId);
1223
+ if (index >= 0) stack.splice(index, 1);
1224
+ if (stack.length === 0) this.chatStacks.delete(chatId);
1225
+ else this.chatStacks.set(chatId, stack);
1170
1226
  }
1171
- this.counts.set(sessionId, currentCount - 1);
1227
+ const chatCounts = this.sessionChats.get(sessionId);
1228
+ if (!chatCounts) return;
1229
+ const currentCount = chatCounts.get(chatId) ?? 0;
1230
+ if (currentCount <= 1) chatCounts.delete(chatId);
1231
+ else chatCounts.set(chatId, currentCount - 1);
1232
+ if (chatCounts.size === 0) this.sessionChats.delete(sessionId);
1233
+ else this.sessionChats.set(sessionId, chatCounts);
1172
1234
  }
1173
1235
  };
1174
1236
  var NOOP_FOREGROUND_SESSION_TRACKER = {
@@ -1178,25 +1240,33 @@ var NOOP_FOREGROUND_SESSION_TRACKER = {
1178
1240
  clear() {
1179
1241
  return false;
1180
1242
  },
1243
+ getActiveSessionId() {
1244
+ return null;
1245
+ },
1181
1246
  isForeground() {
1182
1247
  return false;
1248
+ },
1249
+ listChatIds() {
1250
+ return [];
1183
1251
  }
1184
1252
  };
1185
1253
  //#endregion
1186
1254
  //#region src/use-cases/abort-prompt.usecase.ts
1187
1255
  var AbortPromptUseCase = class {
1188
- constructor(sessionRepo, opencodeClient) {
1256
+ constructor(sessionRepo, opencodeClient, foregroundSessionTracker = NOOP_FOREGROUND_SESSION_TRACKER) {
1189
1257
  this.sessionRepo = sessionRepo;
1190
1258
  this.opencodeClient = opencodeClient;
1259
+ this.foregroundSessionTracker = foregroundSessionTracker;
1191
1260
  }
1192
1261
  async execute(input) {
1193
- const binding = await this.sessionRepo.getByChatId(input.chatId);
1194
- if (!binding?.sessionId) return {
1262
+ const activeSessionId = this.foregroundSessionTracker.getActiveSessionId(input.chatId);
1263
+ const binding = activeSessionId ? null : await this.sessionRepo.getByChatId(input.chatId);
1264
+ const sessionId = activeSessionId ?? binding?.sessionId ?? null;
1265
+ if (!sessionId) return {
1195
1266
  sessionId: null,
1196
1267
  status: "no_session",
1197
1268
  sessionStatus: null
1198
1269
  };
1199
- const sessionId = binding.sessionId;
1200
1270
  const sessionStatus = (await this.opencodeClient.getSessionStatuses())[sessionId] ?? null;
1201
1271
  if (!sessionStatus || sessionStatus.type === "idle") return {
1202
1272
  sessionId,
@@ -1693,6 +1763,7 @@ var SendPromptUseCase = class {
1693
1763
  }
1694
1764
  if (!binding || !binding.sessionId || !binding.projectId) throw new Error("Failed to initialize chat session.");
1695
1765
  let activeBinding = binding;
1766
+ const shouldIsolateImageTurn = hasImageFiles(files);
1696
1767
  const model = activeBinding.modelProviderId && activeBinding.modelId ? {
1697
1768
  providerID: activeBinding.modelProviderId,
1698
1769
  modelID: activeBinding.modelId
@@ -1702,11 +1773,13 @@ var SendPromptUseCase = class {
1702
1773
  activeBinding = await clearStoredAgentSelection(this.sessionRepo, activeBinding);
1703
1774
  this.logger.warn?.({ chatId: input.chatId }, "selected agent is no longer available, falling back to OpenCode default");
1704
1775
  }
1705
- const endForegroundSession = this.foregroundSessionTracker.begin(activeBinding.sessionId);
1776
+ const temporarySessionId = shouldIsolateImageTurn ? await this.createTemporaryImageSession(input.chatId, activeBinding.sessionId) : null;
1777
+ const executionSessionId = temporarySessionId ?? activeBinding.sessionId;
1778
+ const endForegroundSession = this.foregroundSessionTracker.begin(input.chatId, executionSessionId);
1706
1779
  let result;
1707
1780
  try {
1708
1781
  result = await this.opencodeClient.promptSession({
1709
- sessionId: activeBinding.sessionId,
1782
+ sessionId: executionSessionId,
1710
1783
  prompt: promptText,
1711
1784
  ...files.length > 0 ? { files } : {},
1712
1785
  ...selectedAgent ? { agent: selectedAgent.name } : {},
@@ -1716,6 +1789,7 @@ var SendPromptUseCase = class {
1716
1789
  });
1717
1790
  } finally {
1718
1791
  endForegroundSession();
1792
+ if (temporarySessionId) await this.cleanupTemporaryImageSession(input.chatId, activeBinding.sessionId, temporarySessionId);
1719
1793
  }
1720
1794
  await this.sessionRepo.touch(input.chatId);
1721
1795
  return {
@@ -1729,6 +1803,32 @@ var SendPromptUseCase = class {
1729
1803
  this.logger.warn?.({ chatId }, `${reason}, falling back to the current OpenCode project`);
1730
1804
  return nextBinding;
1731
1805
  }
1806
+ async createTemporaryImageSession(chatId, sessionId) {
1807
+ const temporarySession = await this.opencodeClient.forkSession(sessionId);
1808
+ if (!temporarySession.id || temporarySession.id === sessionId) throw new Error("OpenCode did not return a distinct temporary session for the image turn.");
1809
+ this.logger.info?.({
1810
+ chatId,
1811
+ parentSessionId: sessionId,
1812
+ sessionId: temporarySession.id
1813
+ }, "created temporary image session");
1814
+ return temporarySession.id;
1815
+ }
1816
+ async cleanupTemporaryImageSession(chatId, parentSessionId, sessionId) {
1817
+ try {
1818
+ if (!await this.opencodeClient.deleteSession(sessionId)) this.logger.warn?.({
1819
+ chatId,
1820
+ parentSessionId,
1821
+ sessionId
1822
+ }, "failed to delete temporary image session");
1823
+ } catch (error) {
1824
+ this.logger.warn?.({
1825
+ error,
1826
+ chatId,
1827
+ parentSessionId,
1828
+ sessionId
1829
+ }, "failed to delete temporary image session");
1830
+ }
1831
+ }
1732
1832
  };
1733
1833
  function buildPromptText(text, files) {
1734
1834
  const trimmedText = text?.trim() ?? "";
@@ -1745,6 +1845,9 @@ function buildPromptText(text, files) {
1745
1845
  function isImageFile(file) {
1746
1846
  return file.mime.trim().toLowerCase().startsWith("image/");
1747
1847
  }
1848
+ function hasImageFiles(files) {
1849
+ return files.some(isImageFile);
1850
+ }
1748
1851
  //#endregion
1749
1852
  //#region src/use-cases/switch-agent.usecase.ts
1750
1853
  var SwitchAgentUseCase = class {
@@ -1938,7 +2041,7 @@ function createContainer(config, opencodeClient, logger) {
1938
2041
  apiRoot: config.telegramApiRoot
1939
2042
  });
1940
2043
  const uploadFileUseCase = new UploadFileUseCase(telegramFileClient);
1941
- const abortPromptUseCase = new AbortPromptUseCase(sessionRepo, opencodeClient);
2044
+ const abortPromptUseCase = new AbortPromptUseCase(sessionRepo, opencodeClient, foregroundSessionTracker);
1942
2045
  const createSessionUseCase = new CreateSessionUseCase(sessionRepo, opencodeClient, logger);
1943
2046
  const getHealthUseCase = new GetHealthUseCase(opencodeClient);
1944
2047
  const getPathUseCase = new GetPathUseCase(opencodeClient);
@@ -2072,19 +2175,20 @@ async function handleTelegramBotPluginEvent(runtime, event) {
2072
2175
  }
2073
2176
  async function handlePermissionAsked(runtime, request) {
2074
2177
  const bindings = await runtime.container.sessionRepo.listBySessionId(request.sessionID);
2178
+ const chatIds = new Set([...bindings.map((binding) => binding.chatId), ...runtime.container.foregroundSessionTracker.listChatIds(request.sessionID)]);
2075
2179
  const approvals = await runtime.container.permissionApprovalRepo.listByRequestId(request.id);
2076
2180
  const approvedChatIds = new Set(approvals.map((approval) => approval.chatId));
2077
- for (const binding of bindings) {
2078
- if (approvedChatIds.has(binding.chatId)) continue;
2181
+ for (const chatId of chatIds) {
2182
+ if (approvedChatIds.has(chatId)) continue;
2079
2183
  try {
2080
- const message = await runtime.bot.api.sendMessage(binding.chatId, buildPermissionApprovalMessage(request), {
2184
+ const message = await runtime.bot.api.sendMessage(chatId, buildPermissionApprovalMessage(request), {
2081
2185
  parse_mode: "MarkdownV2",
2082
2186
  reply_markup: buildPermissionApprovalKeyboard(request.id)
2083
2187
  });
2084
2188
  await runtime.container.permissionApprovalRepo.set({
2085
2189
  requestId: request.id,
2086
2190
  sessionId: request.sessionID,
2087
- chatId: binding.chatId,
2191
+ chatId,
2088
2192
  messageId: message.message_id,
2089
2193
  status: "pending",
2090
2194
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -2092,7 +2196,7 @@ async function handlePermissionAsked(runtime, request) {
2092
2196
  } catch (error) {
2093
2197
  runtime.container.logger.error({
2094
2198
  error,
2095
- chatId: binding.chatId,
2199
+ chatId,
2096
2200
  requestId: request.id
2097
2201
  }, "failed to deliver permission request to Telegram");
2098
2202
  }
@@ -2118,7 +2222,7 @@ async function handleSessionError(runtime, sessionId, error) {
2118
2222
  runtime.container.logger.error({ error }, "session error received without a session id");
2119
2223
  return;
2120
2224
  }
2121
- if (runtime.container.foregroundSessionTracker.isForeground(sessionId)) {
2225
+ if (runtime.container.foregroundSessionTracker.clear(sessionId)) {
2122
2226
  runtime.container.logger.warn({
2123
2227
  error,
2124
2228
  sessionId
@@ -4727,7 +4831,9 @@ function createHooks(runtime) {
4727
4831
  await handleTelegramBotPluginEvent(runtime, event);
4728
4832
  },
4729
4833
  async "permission.ask"(input, output) {
4730
- if ((await runtime.container.sessionRepo.listBySessionId(input.sessionID)).length > 0) output.status = "ask";
4834
+ const bindings = await runtime.container.sessionRepo.listBySessionId(input.sessionID);
4835
+ const foregroundChatIds = runtime.container.foregroundSessionTracker.listChatIds(input.sessionID);
4836
+ if (bindings.length > 0 || foregroundChatIds.length > 0) output.status = "ask";
4731
4837
  }
4732
4838
  };
4733
4839
  }