opencode-tbot 0.1.4 → 0.1.6

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
@@ -22,7 +22,7 @@ A Telegram plugin for driving [OpenCode](https://opencode.ai) from chat.
22
22
  Run:
23
23
 
24
24
  ```bash
25
- npx opencode-tbot@latest install
25
+ npm install opencode-tbot@latest
26
26
  ```
27
27
 
28
28
  The installer registers the plugin globally and writes the default runtime config.
package/README.zh-CN.md CHANGED
@@ -22,7 +22,7 @@
22
22
  执行:
23
23
 
24
24
  ```bash
25
- npx opencode-tbot@latest install
25
+ npm install opencode-tbot@latest
26
26
  ```
27
27
 
28
28
  安装器会注册全局插件并写入默认运行时配置。
package/dist/plugin.js CHANGED
@@ -214,6 +214,15 @@ function buildOpenCodeSdkConfig(options) {
214
214
  };
215
215
  }
216
216
  var EMPTY_RESPONSE_TEXT = "OpenCode returned empty response.";
217
+ var PROMPT_MESSAGE_POLL_DELAYS_MS = [
218
+ 0,
219
+ 150,
220
+ 300,
221
+ 600,
222
+ 1200,
223
+ 2e3,
224
+ 3200
225
+ ];
217
226
  var STRUCTURED_REPLY_SCHEMA = {
218
227
  type: "json_schema",
219
228
  retryCount: 2,
@@ -327,7 +336,7 @@ var OpenCodeClient = class {
327
336
  url: file.url
328
337
  }))];
329
338
  if (parts.length === 0) throw new Error("Prompt requires text or file attachments.");
330
- const data = unwrapSdkData(await this.client.session.prompt({
339
+ const initialData = unwrapSdkData(await this.client.session.prompt({
331
340
  sessionID: input.sessionId,
332
341
  ...input.agent ? { agent: input.agent } : {},
333
342
  ...input.structured ? { format: STRUCTURED_REPLY_SCHEMA } : {},
@@ -335,18 +344,36 @@ var OpenCodeClient = class {
335
344
  ...input.variant ? { variant: input.variant } : {},
336
345
  parts
337
346
  }, SDK_OPTIONS));
338
- const finishedAt = Date.now();
339
- const bodyMd = input.structured ? extractStructuredMarkdown(data.info?.structured) : null;
340
- const fallbackText = extractTextFromParts(data.parts) || bodyMd || EMPTY_RESPONSE_TEXT;
341
- return {
342
- assistantError: data.info?.error ?? null,
343
- bodyMd,
344
- fallbackText,
345
- info: data.info ?? null,
346
- metrics: extractPromptMetrics(data.info, startedAt, finishedAt),
347
- parts: data.parts,
348
- structured: data.info?.structured ?? null
349
- };
347
+ return buildPromptSessionResult(await this.resolvePromptResponse(input, initialData), {
348
+ emptyResponseText: EMPTY_RESPONSE_TEXT,
349
+ finishedAt: Date.now(),
350
+ startedAt,
351
+ structured: input.structured ?? false
352
+ });
353
+ }
354
+ async resolvePromptResponse(input, data) {
355
+ if (!shouldPollPromptMessage(data, input.structured ?? false)) return data;
356
+ const messageId = data.info?.id;
357
+ if (!messageId) return data;
358
+ for (const delayMs of PROMPT_MESSAGE_POLL_DELAYS_MS) {
359
+ if (delayMs > 0) await delay(delayMs);
360
+ const next = await this.fetchPromptMessage(input.sessionId, messageId);
361
+ if (!next) continue;
362
+ data = next;
363
+ if (!shouldPollPromptMessage(data, input.structured ?? false)) return data;
364
+ }
365
+ return data;
366
+ }
367
+ async fetchPromptMessage(sessionId, messageId) {
368
+ if (typeof this.client.session.message !== "function") return null;
369
+ try {
370
+ return unwrapSdkData(await this.client.session.message({
371
+ sessionID: sessionId,
372
+ messageID: messageId
373
+ }, SDK_OPTIONS));
374
+ } catch {
375
+ return null;
376
+ }
350
377
  }
351
378
  async loadModels() {
352
379
  const [configResponse, providersResponse] = await Promise.all([this.client.config.get(void 0, SDK_OPTIONS), this.client.config.providers(void 0, SDK_OPTIONS)]);
@@ -446,8 +473,39 @@ function normalizeVariants(variants) {
446
473
  }));
447
474
  }
448
475
  function extractTextFromParts(parts) {
476
+ if (!Array.isArray(parts)) return "";
449
477
  return parts.filter((part) => part.type === "text").map((part) => part.text).join("").trim();
450
478
  }
479
+ function buildPromptSessionResult(data, options) {
480
+ const assistantInfo = toAssistantMessage(data.info);
481
+ const bodyMd = options.structured ? extractStructuredMarkdown(assistantInfo?.structured) : null;
482
+ const responseParts = Array.isArray(data.parts) ? data.parts : [];
483
+ const fallbackText = extractTextFromParts(responseParts) || bodyMd || options.emptyResponseText;
484
+ return {
485
+ assistantError: assistantInfo?.error ?? null,
486
+ bodyMd,
487
+ fallbackText,
488
+ info: assistantInfo,
489
+ metrics: extractPromptMetrics(assistantInfo, options.startedAt, options.finishedAt),
490
+ parts: responseParts,
491
+ structured: assistantInfo?.structured ?? null
492
+ };
493
+ }
494
+ function shouldPollPromptMessage(data, structured) {
495
+ const assistantInfo = toAssistantMessage(data.info);
496
+ const bodyMd = structured ? extractStructuredMarkdown(assistantInfo?.structured) : null;
497
+ const hasText = extractTextFromParts(Array.isArray(data.parts) ? data.parts : []).length > 0;
498
+ const hasAssistantError = !!assistantInfo?.error;
499
+ const isCompleted = typeof assistantInfo?.time?.completed === "number" && Number.isFinite(assistantInfo.time.completed);
500
+ return !hasText && !bodyMd && !hasAssistantError && !isCompleted;
501
+ }
502
+ function toAssistantMessage(message) {
503
+ if (!message || typeof message !== "object") return null;
504
+ return !("role" in message) || message.role === "assistant" ? message : null;
505
+ }
506
+ function delay(ms) {
507
+ return new Promise((resolve) => setTimeout(resolve, ms));
508
+ }
451
509
  function extractStructuredMarkdown(structured) {
452
510
  const parsed = StructuredReplySchema.safeParse(structured);
453
511
  if (!parsed.success) return null;
@@ -778,7 +836,10 @@ var OpenRouterVoiceTranscriptionClient = class {
778
836
  temperature: 0
779
837
  } }, { timeoutMs: this.timeoutMs });
780
838
  } catch (error) {
781
- throw new VoiceTranscriptionFailedError(extractErrorMessage(error) ?? "Failed to reach OpenRouter voice transcription.");
839
+ throw new VoiceTranscriptionFailedError(buildTranscriptionErrorMessage(error, {
840
+ format,
841
+ model: this.model
842
+ }));
782
843
  }
783
844
  return { text: extractTranscript(response) };
784
845
  }
@@ -841,8 +902,108 @@ function extractTranscript(response) {
841
902
  function isTextContentItem(value) {
842
903
  return !!value && typeof value === "object" && "type" in value && value.type === "text" && "text" in value && typeof value.text === "string";
843
904
  }
905
+ function buildTranscriptionErrorMessage(error, context) {
906
+ const parsedBody = parseJsonBody(extractStringField(error, "body"));
907
+ const rawMessages = dedupeNonEmptyStrings([
908
+ extractErrorMessage(error),
909
+ extractErrorMessage(readField(error, "error")),
910
+ extractErrorMessage(readField(error, "data")),
911
+ extractErrorMessage(readField(readField(error, "data"), "error")),
912
+ extractErrorMessage(parsedBody),
913
+ extractErrorMessage(readField(parsedBody, "error")),
914
+ extractMetadataRawMessage(error),
915
+ extractMetadataRawMessage(parsedBody)
916
+ ]);
917
+ const messages = rawMessages.some((message) => !isGenericProviderMessage(message)) ? rawMessages.filter((message) => !isGenericProviderMessage(message)) : rawMessages;
918
+ const providerName = extractProviderName(error) ?? extractProviderName(parsedBody);
919
+ const statusCode = extractNumericField(error, "statusCode") ?? extractNumericField(parsedBody, "statusCode");
920
+ const errorCode = extractErrorCode(error, parsedBody);
921
+ return joinNonEmptyParts$1([
922
+ ...messages,
923
+ `model: ${context.model}`,
924
+ `format: ${context.format}`,
925
+ providerName ? `provider: ${providerName}` : null,
926
+ statusCode !== null ? `status: ${statusCode}` : null,
927
+ errorCode !== null ? `code: ${errorCode}` : null
928
+ ]) ?? "Failed to reach OpenRouter voice transcription.";
929
+ }
844
930
  function extractErrorMessage(error) {
845
- return error instanceof Error && error.message.trim().length > 0 ? error.message.trim() : null;
931
+ if (error instanceof Error && error.message.trim().length > 0) return error.message.trim();
932
+ return extractStringField(error, "message");
933
+ }
934
+ function extractMetadataRawMessage(value) {
935
+ const raw = extractStringField(readField(value, "metadata") ?? readField(readField(value, "error"), "metadata") ?? readField(readField(readField(value, "data"), "error"), "metadata"), "raw");
936
+ if (!raw) return null;
937
+ return raw.length <= 280 ? raw : `${raw.slice(0, 277)}...`;
938
+ }
939
+ function extractProviderName(value) {
940
+ const candidates = [
941
+ readField(value, "metadata"),
942
+ readField(readField(value, "error"), "metadata"),
943
+ readField(readField(readField(value, "data"), "error"), "metadata")
944
+ ];
945
+ for (const candidate of candidates) {
946
+ const providerName = extractStringField(candidate, "provider_name") ?? extractStringField(candidate, "providerName") ?? extractStringField(candidate, "provider");
947
+ if (providerName) return providerName;
948
+ }
949
+ return null;
950
+ }
951
+ function extractErrorCode(...values) {
952
+ for (const value of values) {
953
+ const candidates = [
954
+ value,
955
+ readField(value, "error"),
956
+ readField(value, "data"),
957
+ readField(readField(value, "data"), "error")
958
+ ];
959
+ for (const candidate of candidates) {
960
+ const code = extractNumericField(candidate, "code");
961
+ if (code !== null) return code;
962
+ }
963
+ }
964
+ return null;
965
+ }
966
+ function extractNumericField(value, fieldName) {
967
+ if (!value || typeof value !== "object" || !(fieldName in value)) return null;
968
+ const fieldValue = value[fieldName];
969
+ return typeof fieldValue === "number" && Number.isFinite(fieldValue) ? fieldValue : null;
970
+ }
971
+ function extractStringField(value, fieldName) {
972
+ if (!value || typeof value !== "object" || !(fieldName in value)) return null;
973
+ const fieldValue = value[fieldName];
974
+ return typeof fieldValue === "string" && fieldValue.trim().length > 0 ? fieldValue.trim() : null;
975
+ }
976
+ function readField(value, fieldName) {
977
+ return value && typeof value === "object" && fieldName in value ? value[fieldName] : null;
978
+ }
979
+ function parseJsonBody(body) {
980
+ if (!body) return null;
981
+ try {
982
+ return JSON.parse(body);
983
+ } catch {
984
+ return null;
985
+ }
986
+ }
987
+ function dedupeNonEmptyStrings(values) {
988
+ const seen = /* @__PURE__ */ new Set();
989
+ const result = [];
990
+ for (const value of values) {
991
+ const normalized = value?.trim();
992
+ if (!normalized) continue;
993
+ const key = normalized.toLowerCase();
994
+ if (seen.has(key)) continue;
995
+ seen.add(key);
996
+ result.push(normalized);
997
+ }
998
+ return result;
999
+ }
1000
+ function joinNonEmptyParts$1(parts) {
1001
+ const filtered = parts.map((part) => part?.trim()).filter((part) => !!part);
1002
+ return filtered.length > 0 ? filtered.join(" | ") : null;
1003
+ }
1004
+ function isGenericProviderMessage(message) {
1005
+ const normalized = message.trim().toLowerCase();
1006
+ return normalized === "provider returned error" || normalized === "failed to reach openrouter voice transcription.";
846
1007
  }
847
1008
  //#endregion
848
1009
  //#region src/services/voice-transcription/voice-transcription.service.ts
@@ -962,14 +1123,14 @@ var GetPathUseCase = class {
962
1123
  //#endregion
963
1124
  //#region src/use-cases/get-status.usecase.ts
964
1125
  var GetStatusUseCase = class {
965
- constructor(getHealthUseCase, getPathUseCase, listLspUseCase, listMcpUseCase, listSessionsUseCase, sessionRepo, voiceTranscriptionEnabled) {
1126
+ constructor(getHealthUseCase, getPathUseCase, listLspUseCase, listMcpUseCase, listSessionsUseCase, sessionRepo, voiceRecognitionStatus) {
966
1127
  this.getHealthUseCase = getHealthUseCase;
967
1128
  this.getPathUseCase = getPathUseCase;
968
1129
  this.listLspUseCase = listLspUseCase;
969
1130
  this.listMcpUseCase = listMcpUseCase;
970
1131
  this.listSessionsUseCase = listSessionsUseCase;
971
1132
  this.sessionRepo = sessionRepo;
972
- this.voiceTranscriptionEnabled = voiceTranscriptionEnabled;
1133
+ this.voiceRecognitionStatus = voiceRecognitionStatus;
973
1134
  }
974
1135
  async execute(input) {
975
1136
  const [health, path, lsp, mcp] = await Promise.allSettled([
@@ -984,7 +1145,7 @@ var GetStatusUseCase = class {
984
1145
  health: mapSettledResult(health),
985
1146
  path: pathResult,
986
1147
  plugins,
987
- voiceRecognition: { enabled: this.voiceTranscriptionEnabled },
1148
+ voiceRecognition: this.voiceRecognitionStatus,
988
1149
  workspace,
989
1150
  lsp: mapSettledResult(lsp),
990
1151
  mcp: mapSettledResult(mcp)
@@ -1617,7 +1778,10 @@ function createContainer(config, opencodeClient, logger) {
1617
1778
  const listLspUseCase = new ListLspUseCase(sessionRepo, opencodeClient);
1618
1779
  const listMcpUseCase = new ListMcpUseCase(sessionRepo, opencodeClient);
1619
1780
  const listSessionsUseCase = new ListSessionsUseCase(sessionRepo, opencodeClient);
1620
- const getStatusUseCase = new GetStatusUseCase(getHealthUseCase, getPathUseCase, listLspUseCase, listMcpUseCase, listSessionsUseCase, sessionRepo, config.openrouter.configured);
1781
+ const getStatusUseCase = new GetStatusUseCase(getHealthUseCase, getPathUseCase, listLspUseCase, listMcpUseCase, listSessionsUseCase, sessionRepo, {
1782
+ status: config.openrouter.configured ? "configured" : "not_configured",
1783
+ model: config.openrouter.configured ? config.openrouter.model : null
1784
+ });
1621
1785
  const listModelsUseCase = new ListModelsUseCase(sessionRepo, opencodeClient);
1622
1786
  const renameSessionUseCase = new RenameSessionUseCase(sessionRepo, opencodeClient, logger);
1623
1787
  const sendPromptUseCase = new SendPromptUseCase(sessionRepo, opencodeClient, logger, foregroundSessionTracker);
@@ -2678,7 +2842,7 @@ function presentStatusMarkdownSection(title, lines) {
2678
2842
  return [`## ${title}`, ...lines].join("\n");
2679
2843
  }
2680
2844
  function presentStatusPlainOverviewLines(input, copy, layout) {
2681
- const lines = [presentPlainStatusBullet(layout.connectivityLabel, input.health.status === "error" ? layout.errorStatus : formatHealthBadge(input.health.data.healthy, layout)), presentPlainStatusBullet(layout.voiceRecognitionLabel, formatVoiceRecognitionBadge(input.voiceRecognition.enabled))];
2845
+ const lines = [presentPlainStatusBullet(layout.connectivityLabel, input.health.status === "error" ? layout.errorStatus : formatHealthBadge(input.health.data.healthy, layout)), presentPlainStatusBullet(layout.voiceRecognitionLabel, formatVoiceRecognitionBadge(input.voiceRecognition, layout))];
2682
2846
  if (input.health.status === "error") return [
2683
2847
  ...lines,
2684
2848
  ...presentStatusPlainErrorDetailLines(input.health.error, copy, layout),
@@ -2691,7 +2855,7 @@ function presentStatusPlainOverviewLines(input, copy, layout) {
2691
2855
  ];
2692
2856
  }
2693
2857
  function presentStatusMarkdownOverviewLines(input, copy, layout) {
2694
- const lines = [presentMarkdownStatusBullet(layout.connectivityLabel, input.health.status === "error" ? layout.errorStatus : formatHealthBadge(input.health.data.healthy, layout)), presentMarkdownStatusBullet(layout.voiceRecognitionLabel, formatVoiceRecognitionBadge(input.voiceRecognition.enabled))];
2858
+ const lines = [presentMarkdownStatusBullet(layout.connectivityLabel, input.health.status === "error" ? layout.errorStatus : formatHealthBadge(input.health.data.healthy, layout)), presentMarkdownStatusBullet(layout.voiceRecognitionLabel, formatVoiceRecognitionBadge(input.voiceRecognition, layout))];
2695
2859
  if (input.health.status === "error") return [
2696
2860
  ...lines,
2697
2861
  ...presentStatusMarkdownErrorDetailLines(input.health.error, copy, layout),
@@ -2791,8 +2955,9 @@ function splitStatusLines(text) {
2791
2955
  function formatHealthBadge(healthy, layout) {
2792
2956
  return healthy ? "🟢" : layout.errorStatus;
2793
2957
  }
2794
- function formatVoiceRecognitionBadge(enabled) {
2795
- return enabled ? "🟢" : "⚪";
2958
+ function formatVoiceRecognitionBadge(status, layout) {
2959
+ if (status.status === "configured") return status.model ? `\uD83D\uDFE1 ${layout.voiceRecognitionConfiguredLabel} (${status.model})` : `\uD83D\uDFE1 ${layout.voiceRecognitionConfiguredLabel}`;
2960
+ return `\u26AA ${layout.voiceRecognitionNotConfiguredLabel}`;
2796
2961
  }
2797
2962
  function formatLspStatusBadge(status) {
2798
2963
  switch (status.status) {
@@ -2862,7 +3027,9 @@ function getStatusLayoutCopy(copy) {
2862
3027
  rootLabel: "Root",
2863
3028
  statusLabel: "Status",
2864
3029
  tbotVersionLabel: "opencode-tbot Version",
3030
+ voiceRecognitionConfiguredLabel: "configured",
2865
3031
  voiceRecognitionLabel: "Voice Recognition",
3032
+ voiceRecognitionNotConfiguredLabel: "not configured",
2866
3033
  workspaceTitle: "📁 Workspace"
2867
3034
  };
2868
3035
  return {
@@ -2885,7 +3052,9 @@ function getStatusLayoutCopy(copy) {
2885
3052
  rootLabel: "根目录",
2886
3053
  statusLabel: "状态",
2887
3054
  tbotVersionLabel: "opencode-tbot版本",
3055
+ voiceRecognitionConfiguredLabel: "已配置",
2888
3056
  voiceRecognitionLabel: "语音识别",
3057
+ voiceRecognitionNotConfiguredLabel: "未配置",
2889
3058
  workspaceTitle: "📁 工作区"
2890
3059
  };
2891
3060
  }