gsd-pi 2.19.0 → 2.20.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.
Files changed (249) hide show
  1. package/README.md +5 -1
  2. package/dist/cli.js +3 -3
  3. package/dist/onboarding.d.ts +3 -1
  4. package/dist/onboarding.js +77 -3
  5. package/dist/remote-questions-config.d.ts +1 -1
  6. package/dist/resources/extensions/google-search/index.ts +164 -47
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +103 -24
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +93 -9
  9. package/dist/resources/extensions/gsd/auto.ts +424 -30
  10. package/dist/resources/extensions/gsd/commands.ts +518 -36
  11. package/dist/resources/extensions/gsd/context-budget.ts +243 -0
  12. package/dist/resources/extensions/gsd/context-store.ts +195 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +41 -3
  14. package/dist/resources/extensions/gsd/db-writer.ts +341 -0
  15. package/dist/resources/extensions/gsd/debug-logger.ts +178 -0
  16. package/dist/resources/extensions/gsd/dispatch-guard.ts +0 -1
  17. package/dist/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  18. package/dist/resources/extensions/gsd/doctor-proactive.ts +286 -0
  19. package/dist/resources/extensions/gsd/doctor.ts +283 -2
  20. package/dist/resources/extensions/gsd/export.ts +81 -2
  21. package/dist/resources/extensions/gsd/files.ts +39 -9
  22. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  23. package/dist/resources/extensions/gsd/gsd-db.ts +752 -0
  24. package/dist/resources/extensions/gsd/guided-flow.ts +26 -1
  25. package/dist/resources/extensions/gsd/history.ts +0 -1
  26. package/dist/resources/extensions/gsd/index.ts +277 -1
  27. package/dist/resources/extensions/gsd/md-importer.ts +526 -0
  28. package/dist/resources/extensions/gsd/metrics.ts +39 -3
  29. package/dist/resources/extensions/gsd/notifications.ts +0 -1
  30. package/dist/resources/extensions/gsd/post-unit-hooks.ts +70 -1
  31. package/dist/resources/extensions/gsd/preferences.ts +125 -150
  32. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -5
  33. package/dist/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  34. package/dist/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  35. package/dist/resources/extensions/gsd/prompts/quick-task.md +48 -0
  36. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  37. package/dist/resources/extensions/gsd/quick.ts +156 -0
  38. package/dist/resources/extensions/gsd/skill-discovery.ts +5 -3
  39. package/dist/resources/extensions/gsd/skill-health.ts +417 -0
  40. package/dist/resources/extensions/gsd/skill-telemetry.ts +127 -0
  41. package/dist/resources/extensions/gsd/state.ts +30 -0
  42. package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
  43. package/dist/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  44. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  45. package/dist/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  46. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  47. package/dist/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  48. package/dist/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  49. package/dist/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  51. package/dist/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  52. package/dist/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  54. package/dist/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  55. package/dist/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  56. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  57. package/dist/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  58. package/dist/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  59. package/dist/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  60. package/dist/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  61. package/dist/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  62. package/dist/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  63. package/dist/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  64. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  65. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  66. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  67. package/dist/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  68. package/dist/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  69. package/dist/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  70. package/dist/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  71. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +262 -1
  72. package/dist/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  73. package/dist/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  74. package/dist/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  75. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  76. package/dist/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  77. package/dist/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  78. package/dist/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  79. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +92 -0
  80. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  81. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +228 -5
  82. package/dist/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  83. package/dist/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  84. package/dist/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  85. package/dist/resources/extensions/gsd/types.ts +29 -0
  86. package/dist/resources/extensions/gsd/undo.ts +0 -1
  87. package/dist/resources/extensions/gsd/unit-runtime.ts +5 -1
  88. package/dist/resources/extensions/gsd/visualizer-data.ts +352 -1
  89. package/dist/resources/extensions/gsd/visualizer-overlay.ts +166 -22
  90. package/dist/resources/extensions/gsd/visualizer-views.ts +464 -2
  91. package/dist/resources/extensions/gsd/worktree-command.ts +18 -0
  92. package/dist/resources/extensions/gsd/worktree-manager.ts +11 -4
  93. package/dist/resources/extensions/remote-questions/config.ts +4 -2
  94. package/dist/resources/extensions/remote-questions/discord-adapter.ts +2 -4
  95. package/dist/resources/extensions/remote-questions/format.ts +154 -8
  96. package/dist/resources/extensions/remote-questions/manager.ts +9 -7
  97. package/dist/resources/extensions/remote-questions/remote-command.ts +100 -4
  98. package/dist/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  99. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  100. package/dist/resources/extensions/remote-questions/types.ts +2 -1
  101. package/dist/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  102. package/dist/resources/extensions/voice/index.ts +4 -3
  103. package/package.json +1 -1
  104. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/agent-session.js +12 -1
  106. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
  109. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts +6 -0
  111. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  112. package/packages/pi-coding-agent/dist/core/lsp/client.js +25 -0
  113. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts +2 -0
  115. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/lsp/index.js +106 -3
  117. package/packages/pi-coding-agent/dist/core/lsp/index.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/lsp/lsp.md +6 -0
  119. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts +35 -0
  120. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/lsp/types.js +6 -0
  122. package/packages/pi-coding-agent/dist/core/lsp/types.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts +3 -1
  124. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/core/lsp/utils.js +45 -0
  126. package/packages/pi-coding-agent/dist/core/lsp/utils.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  128. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  129. package/packages/pi-coding-agent/dist/core/settings-manager.js +43 -11
  130. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  131. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/system-prompt.js +7 -1
  133. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/core/tools/edit.d.ts.map +1 -1
  135. package/packages/pi-coding-agent/dist/core/tools/edit.js +5 -0
  136. package/packages/pi-coding-agent/dist/core/tools/edit.js.map +1 -1
  137. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +2 -0
  138. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  139. package/packages/pi-coding-agent/dist/core/tools/write.d.ts.map +1 -1
  140. package/packages/pi-coding-agent/dist/core/tools/write.js +5 -0
  141. package/packages/pi-coding-agent/dist/core/tools/write.js.map +1 -1
  142. package/packages/pi-coding-agent/src/core/agent-session.ts +13 -1
  143. package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
  144. package/packages/pi-coding-agent/src/core/lsp/client.ts +26 -0
  145. package/packages/pi-coding-agent/src/core/lsp/index.ts +157 -2
  146. package/packages/pi-coding-agent/src/core/lsp/lsp.md +6 -0
  147. package/packages/pi-coding-agent/src/core/lsp/types.ts +53 -0
  148. package/packages/pi-coding-agent/src/core/lsp/utils.ts +56 -0
  149. package/packages/pi-coding-agent/src/core/settings-manager.ts +41 -11
  150. package/packages/pi-coding-agent/src/core/system-prompt.ts +7 -1
  151. package/packages/pi-coding-agent/src/core/tools/edit.ts +3 -0
  152. package/packages/pi-coding-agent/src/core/tools/write.ts +3 -0
  153. package/src/resources/extensions/google-search/index.ts +164 -47
  154. package/src/resources/extensions/gsd/auto-prompts.ts +103 -24
  155. package/src/resources/extensions/gsd/auto-worktree.ts +93 -9
  156. package/src/resources/extensions/gsd/auto.ts +424 -30
  157. package/src/resources/extensions/gsd/commands.ts +518 -36
  158. package/src/resources/extensions/gsd/context-budget.ts +243 -0
  159. package/src/resources/extensions/gsd/context-store.ts +195 -0
  160. package/src/resources/extensions/gsd/dashboard-overlay.ts +41 -3
  161. package/src/resources/extensions/gsd/db-writer.ts +341 -0
  162. package/src/resources/extensions/gsd/debug-logger.ts +178 -0
  163. package/src/resources/extensions/gsd/dispatch-guard.ts +0 -1
  164. package/src/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  165. package/src/resources/extensions/gsd/doctor-proactive.ts +286 -0
  166. package/src/resources/extensions/gsd/doctor.ts +283 -2
  167. package/src/resources/extensions/gsd/export.ts +81 -2
  168. package/src/resources/extensions/gsd/files.ts +39 -9
  169. package/src/resources/extensions/gsd/git-service.ts +6 -0
  170. package/src/resources/extensions/gsd/gsd-db.ts +752 -0
  171. package/src/resources/extensions/gsd/guided-flow.ts +26 -1
  172. package/src/resources/extensions/gsd/history.ts +0 -1
  173. package/src/resources/extensions/gsd/index.ts +277 -1
  174. package/src/resources/extensions/gsd/md-importer.ts +526 -0
  175. package/src/resources/extensions/gsd/metrics.ts +39 -3
  176. package/src/resources/extensions/gsd/notifications.ts +0 -1
  177. package/src/resources/extensions/gsd/post-unit-hooks.ts +70 -1
  178. package/src/resources/extensions/gsd/preferences.ts +125 -150
  179. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -5
  180. package/src/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  181. package/src/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  182. package/src/resources/extensions/gsd/prompts/quick-task.md +48 -0
  183. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  184. package/src/resources/extensions/gsd/quick.ts +156 -0
  185. package/src/resources/extensions/gsd/skill-discovery.ts +5 -3
  186. package/src/resources/extensions/gsd/skill-health.ts +417 -0
  187. package/src/resources/extensions/gsd/skill-telemetry.ts +127 -0
  188. package/src/resources/extensions/gsd/state.ts +30 -0
  189. package/src/resources/extensions/gsd/templates/preferences.md +1 -0
  190. package/src/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  191. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  192. package/src/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  193. package/src/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  194. package/src/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  195. package/src/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  196. package/src/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  197. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  198. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  199. package/src/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  200. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  201. package/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  202. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  203. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  204. package/src/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  205. package/src/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  206. package/src/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  207. package/src/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  208. package/src/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  209. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  210. package/src/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  211. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  212. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  213. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  214. package/src/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  215. package/src/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  216. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  217. package/src/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  218. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +262 -1
  219. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  220. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  221. package/src/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  222. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  223. package/src/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  224. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  225. package/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  226. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +92 -0
  227. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  228. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +228 -5
  229. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  230. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  231. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  232. package/src/resources/extensions/gsd/types.ts +29 -0
  233. package/src/resources/extensions/gsd/undo.ts +0 -1
  234. package/src/resources/extensions/gsd/unit-runtime.ts +5 -1
  235. package/src/resources/extensions/gsd/visualizer-data.ts +352 -1
  236. package/src/resources/extensions/gsd/visualizer-overlay.ts +166 -22
  237. package/src/resources/extensions/gsd/visualizer-views.ts +464 -2
  238. package/src/resources/extensions/gsd/worktree-command.ts +18 -0
  239. package/src/resources/extensions/gsd/worktree-manager.ts +11 -4
  240. package/src/resources/extensions/remote-questions/config.ts +4 -2
  241. package/src/resources/extensions/remote-questions/discord-adapter.ts +2 -4
  242. package/src/resources/extensions/remote-questions/format.ts +154 -8
  243. package/src/resources/extensions/remote-questions/manager.ts +9 -7
  244. package/src/resources/extensions/remote-questions/remote-command.ts +100 -4
  245. package/src/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  246. package/src/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  247. package/src/resources/extensions/remote-questions/types.ts +2 -1
  248. package/src/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  249. package/src/resources/extensions/voice/index.ts +4 -3
@@ -18,7 +18,8 @@ export interface DiscordEmbed {
18
18
  footer?: { text: string };
19
19
  }
20
20
 
21
- const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
21
+ export const DISCORD_NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
22
+ export const SLACK_NUMBER_REACTION_NAMES = ["one", "two", "three", "four", "five"];
22
23
  const MAX_USER_NOTE_LENGTH = 500;
23
24
 
24
25
  export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
@@ -29,7 +30,18 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
29
30
  },
30
31
  ];
31
32
 
33
+ if (prompt.questions.length > 1) {
34
+ blocks.push({
35
+ type: "context",
36
+ elements: [{
37
+ type: "mrkdwn",
38
+ text: "Reply once in thread using one line per question or semicolons (`1; 2; custom note`).",
39
+ }],
40
+ });
41
+ }
42
+
32
43
  for (const q of prompt.questions) {
44
+ const supportsReactions = prompt.questions.length === 1;
33
45
  blocks.push({
34
46
  type: "section",
35
47
  text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` },
@@ -47,15 +59,33 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
47
59
  type: "context",
48
60
  elements: [{
49
61
  type: "mrkdwn",
50
- text: q.allowMultiple
51
- ? "Reply in thread with comma-separated numbers (`1,3`) or free text."
52
- : "Reply in thread with a number (`1`) or free text.",
62
+ text: prompt.questions.length > 1
63
+ ? (q.allowMultiple
64
+ ? "For this question, use comma-separated numbers (`1,3`) or free text."
65
+ : "For this question, use one number (`1`) or free text.")
66
+ : (q.allowMultiple
67
+ ? (supportsReactions
68
+ ? "Reply in thread with comma-separated numbers (`1,3`) or react with matching number emoji."
69
+ : "Reply in thread with comma-separated numbers (`1,3`) or free text.")
70
+ : (supportsReactions
71
+ ? "Reply in thread with a number (`1`) or react with the matching number emoji."
72
+ : "Reply in thread with a number (`1`) or free text.")),
53
73
  }],
54
74
  });
55
75
 
56
76
  blocks.push({ type: "divider" });
57
77
  }
58
78
 
79
+ if (prompt.context?.source) {
80
+ blocks.push({
81
+ type: "context",
82
+ elements: [{
83
+ type: "mrkdwn",
84
+ text: `Source: \`${prompt.context.source}\``,
85
+ }],
86
+ });
87
+ }
88
+
59
89
  return blocks;
60
90
  }
61
91
 
@@ -64,8 +94,8 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]
64
94
  const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => {
65
95
  const supportsReactions = prompt.questions.length === 1;
66
96
  const optionLines = q.options.map((opt, i) => {
67
- const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
68
- if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]);
97
+ const emoji = DISCORD_NUMBER_EMOJIS[i] ?? `${i + 1}.`;
98
+ if (supportsReactions && DISCORD_NUMBER_EMOJIS[i]) reactionEmojis.push(DISCORD_NUMBER_EMOJIS[i]);
69
99
  return `${emoji} **${opt.label}** — ${opt.description}`;
70
100
  });
71
101
 
@@ -130,8 +160,33 @@ export function parseDiscordResponse(
130
160
 
131
161
  const q = questions[0];
132
162
  const picked = reactions
133
- .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
134
- .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
163
+ .filter((r) => DISCORD_NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
164
+ .map((r) => q.options[DISCORD_NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
165
+ .filter(Boolean) as string[];
166
+
167
+ answers[q.id] = picked.length > 0
168
+ ? { answers: q.allowMultiple ? picked : [picked[0]] }
169
+ : { answers: [], user_note: "No clear response via reactions" };
170
+
171
+ return { answers };
172
+ }
173
+
174
+ export function parseSlackReactionResponse(
175
+ reactionNames: string[],
176
+ questions: RemoteQuestion[],
177
+ ): RemoteAnswer {
178
+ const answers: RemoteAnswer["answers"] = {};
179
+ if (questions.length !== 1) {
180
+ for (const q of questions) {
181
+ answers[q.id] = { answers: [], user_note: "Slack reactions are only supported for single-question prompts" };
182
+ }
183
+ return { answers };
184
+ }
185
+
186
+ const q = questions[0];
187
+ const picked = reactionNames
188
+ .filter((name) => SLACK_NUMBER_REACTION_NAMES.includes(name))
189
+ .map((name) => q.options[SLACK_NUMBER_REACTION_NAMES.indexOf(name)]?.label)
135
190
  .filter(Boolean) as string[];
136
191
 
137
192
  answers[q.id] = picked.length > 0
@@ -141,6 +196,97 @@ export function parseDiscordResponse(
141
196
  return { answers };
142
197
  }
143
198
 
199
+ export interface TelegramInlineButton {
200
+ text: string;
201
+ callback_data: string;
202
+ }
203
+
204
+ export interface TelegramInlineKeyboardMarkup {
205
+ inline_keyboard: TelegramInlineButton[][];
206
+ }
207
+
208
+ export interface TelegramMessage {
209
+ text: string;
210
+ parse_mode: "HTML";
211
+ reply_markup?: TelegramInlineKeyboardMarkup;
212
+ }
213
+
214
+ function escapeHtml(s: string): string {
215
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
216
+ }
217
+
218
+ export function formatForTelegram(prompt: RemotePrompt): TelegramMessage {
219
+ const lines: string[] = ["<b>GSD needs your input</b>", ""];
220
+
221
+ for (let qi = 0; qi < prompt.questions.length; qi++) {
222
+ const q = prompt.questions[qi];
223
+ lines.push(`<b>${escapeHtml(q.header)}</b>`);
224
+ lines.push(escapeHtml(q.question));
225
+ lines.push("");
226
+
227
+ for (let i = 0; i < q.options.length; i++) {
228
+ lines.push(`${i + 1}. <b>${escapeHtml(q.options[i].label)}</b> — ${escapeHtml(q.options[i].description)}`);
229
+ }
230
+
231
+ lines.push("");
232
+ if (prompt.questions.length === 1) {
233
+ lines.push(q.allowMultiple
234
+ ? "Reply with comma-separated numbers (1,3) or free text."
235
+ : "Reply with a number or tap a button below.");
236
+ } else {
237
+ lines.push(`Question ${qi + 1}/${prompt.questions.length} — reply with one line per question or use semicolons.`);
238
+ }
239
+
240
+ if (qi < prompt.questions.length - 1) lines.push("");
241
+ }
242
+
243
+ const result: TelegramMessage = {
244
+ text: lines.join("\n"),
245
+ parse_mode: "HTML",
246
+ };
247
+
248
+ // Inline keyboard for single-question with <=5 options
249
+ const isSingle = prompt.questions.length === 1;
250
+ if (isSingle && prompt.questions[0].options.length <= 5) {
251
+ result.reply_markup = {
252
+ inline_keyboard: prompt.questions[0].options.map((opt, i) => [{
253
+ text: `${i + 1}. ${opt.label}`,
254
+ callback_data: `${prompt.id}:${i}`,
255
+ }]),
256
+ };
257
+ }
258
+
259
+ return result;
260
+ }
261
+
262
+ export function parseTelegramResponse(
263
+ callbackData: string | null,
264
+ replyText: string | null,
265
+ questions: RemoteQuestion[],
266
+ promptId: string,
267
+ ): RemoteAnswer {
268
+ // Handle callback_data from inline keyboard button press
269
+ if (callbackData) {
270
+ const match = callbackData.match(new RegExp(`^${promptId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:(\\d+)$`));
271
+ if (match && questions.length === 1) {
272
+ const idx = parseInt(match[1], 10);
273
+ const q = questions[0];
274
+ if (idx >= 0 && idx < q.options.length) {
275
+ return { answers: { [q.id]: { answers: [q.options[idx].label] } } };
276
+ }
277
+ }
278
+ }
279
+
280
+ // Handle text reply — delegate to parseSlackReply (text parsing is format-agnostic)
281
+ if (replyText) return parseSlackReply(replyText, questions);
282
+
283
+ const answers: RemoteAnswer["answers"] = {};
284
+ for (const q of questions) {
285
+ answers[q.id] = { answers: [], user_note: "No response provided" };
286
+ }
287
+ return { answers };
288
+ }
289
+
144
290
  function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } {
145
291
  if (!text) return { answers: [], user_note: "No response provided" };
146
292
 
@@ -5,8 +5,9 @@
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
7
7
  import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
8
- import { SlackAdapter } from "./slack-adapter.js";
9
8
  import { DiscordAdapter } from "./discord-adapter.js";
9
+ import { SlackAdapter } from "./slack-adapter.js";
10
+ import { TelegramAdapter } from "./telegram-adapter.js";
10
11
  import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js";
11
12
 
12
13
  interface ToolResult {
@@ -77,10 +78,10 @@ export async function tryRemoteQuestions(
77
78
 
78
79
  markPromptAnswered(prompt.id, answer);
79
80
 
80
- // Acknowledge receipt with a on Discord (Slack threads are self-evident)
81
- if (config.channel === "discord" && dispatch.ref) {
81
+ // Best-effort acknowledgement gives remote users a visible receipt signal.
82
+ if (dispatch.ref) {
82
83
  try {
83
- await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref);
84
+ await adapter.acknowledgeAnswer?.(dispatch.ref);
84
85
  } catch { /* best-effort */ }
85
86
  }
86
87
 
@@ -119,9 +120,9 @@ function createPrompt(questions: QuestionInput[], config: ResolvedConfig): Remot
119
120
  }
120
121
 
121
122
  function createAdapter(config: ResolvedConfig): ChannelAdapter {
122
- return config.channel === "slack"
123
- ? new SlackAdapter(config.token, config.channelId)
124
- : new DiscordAdapter(config.token, config.channelId);
123
+ if (config.channel === "slack") return new SlackAdapter(config.token, config.channelId);
124
+ if (config.channel === "telegram") return new TelegramAdapter(config.token, config.channelId);
125
+ return new DiscordAdapter(config.token, config.channelId);
125
126
  }
126
127
 
127
128
  async function pollUntilDone(
@@ -181,6 +182,7 @@ const TOKEN_PATTERNS = [
181
182
  /xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
182
183
  /xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
183
184
  /xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens
185
+ /\d{8,10}:[A-Za-z0-9_-]{35}/g, // Telegram bot tokens
184
186
  /[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.)
185
187
  ];
186
188
 
@@ -21,6 +21,7 @@ export async function handleRemote(
21
21
 
22
22
  if (trimmed === "slack") return handleSetupSlack(ctx);
23
23
  if (trimmed === "discord") return handleSetupDiscord(ctx);
24
+ if (trimmed === "telegram") return handleSetupTelegram(ctx);
24
25
  if (trimmed === "status") return handleRemoteStatus(ctx);
25
26
  if (trimmed === "disconnect") return handleDisconnect(ctx);
26
27
 
@@ -36,9 +37,28 @@ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
36
37
  const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } });
37
38
  if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error");
38
39
 
39
- const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
40
+ const channels = await listSlackChannels(token);
41
+ const MANUAL_OPTION = "Enter channel ID manually";
42
+ let channelId: string;
43
+
44
+ if (!channels || channels.length === 0) {
45
+ ctx.ui.notify("Could not list Slack channels — falling back to manual entry.", "warning");
46
+ channelId = await promptSlackChannelId(ctx) ?? "";
47
+ } else {
48
+ const channelOptions = [...channels.map((channel) => channel.label), MANUAL_OPTION];
49
+ const selectedChannel = await ctx.ui.select("Select a Slack channel", channelOptions);
50
+ if (!selectedChannel) return void ctx.ui.notify("Slack setup cancelled.", "info");
51
+
52
+ if (selectedChannel === MANUAL_OPTION) {
53
+ channelId = await promptSlackChannelId(ctx) ?? "";
54
+ } else {
55
+ const chosen = channels.find((channel) => channel.label === selectedChannel);
56
+ if (!chosen) return void ctx.ui.notify("Slack setup cancelled.", "info");
57
+ channelId = chosen.id;
58
+ }
59
+ }
60
+
40
61
  if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info");
41
- if (!isValidChannelId("slack", channelId)) return void ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error");
42
62
 
43
63
  const send = await fetchJson("https://slack.com/api/chat.postMessage", {
44
64
  method: "POST",
@@ -136,6 +156,32 @@ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
136
156
  ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info");
137
157
  }
138
158
 
159
+ async function handleSetupTelegram(ctx: ExtensionCommandContext): Promise<void> {
160
+ const token = await promptMaskedInput(ctx, "Telegram Bot Token", "Paste your bot token from @BotFather");
161
+ if (!token) return void ctx.ui.notify("Telegram setup cancelled.", "info");
162
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) return void ctx.ui.notify("Invalid token format — Telegram bot tokens look like 123456789:ABCdefGHI...", "warning");
163
+
164
+ ctx.ui.notify("Validating token...", "info");
165
+ const auth = await fetchJson(`https://api.telegram.org/bot${token}/getMe`);
166
+ if (!auth?.ok || !auth?.result?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error");
167
+
168
+ const chatId = await promptInput(ctx, "Chat ID", "Paste the Telegram chat ID (e.g. -1001234567890)");
169
+ if (!chatId) return void ctx.ui.notify("Telegram setup cancelled.", "info");
170
+ if (!isValidChannelId("telegram", chatId)) return void ctx.ui.notify("Invalid Telegram chat ID format — expected a numeric ID (can be negative for groups).", "error");
171
+
172
+ const send = await fetchJson(`https://api.telegram.org/bot${token}/sendMessage`, {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({ chat_id: chatId, text: "GSD remote questions connected." }),
176
+ });
177
+ if (!send?.ok) return void ctx.ui.notify(`Could not send to chat: ${send?.description ?? "unknown error"}`, "error");
178
+
179
+ saveProviderToken("telegram_bot", token);
180
+ process.env.TELEGRAM_BOT_TOKEN = token;
181
+ saveRemoteQuestionsConfig("telegram", chatId);
182
+ ctx.ui.notify(`Telegram connected — remote questions enabled for chat ${chatId}.`, "info");
183
+ }
184
+
139
185
  async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise<void> {
140
186
  const status = getRemoteConfigStatus();
141
187
  const config = resolveRemoteConfig();
@@ -161,9 +207,11 @@ async function handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
161
207
  if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info");
162
208
 
163
209
  removeRemoteQuestionsConfig();
164
- removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot");
210
+ const providerMap: Record<string, string> = { slack: "slack_bot", discord: "discord_bot", telegram: "telegram_bot" };
211
+ removeProviderToken(providerMap[channel] ?? channel);
165
212
  if (channel === "slack") delete process.env.SLACK_BOT_TOKEN;
166
213
  if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN;
214
+ if (channel === "telegram") delete process.env.TELEGRAM_BOT_TOKEN;
167
215
  ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info");
168
216
  }
169
217
 
@@ -181,6 +229,7 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
181
229
  " /gsd remote disconnect",
182
230
  " /gsd remote slack",
183
231
  " /gsd remote discord",
232
+ " /gsd remote telegram",
184
233
  ]
185
234
  : [
186
235
  "No remote question channel configured.",
@@ -188,6 +237,7 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
188
237
  "Commands:",
189
238
  " /gsd remote slack",
190
239
  " /gsd remote discord",
240
+ " /gsd remote telegram",
191
241
  " /gsd remote status",
192
242
  ];
193
243
 
@@ -203,6 +253,52 @@ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
203
253
  }
204
254
  }
205
255
 
256
+ async function listSlackChannels(token: string): Promise<Array<{ id: string; label: string }> | null> {
257
+ const headers = { Authorization: `Bearer ${token}` };
258
+ const channels: Array<{ id: string; label: string; name: string }> = [];
259
+ let cursor = "";
260
+
261
+ do {
262
+ const params = new URLSearchParams({
263
+ exclude_archived: "true",
264
+ limit: "200",
265
+ types: "public_channel,private_channel",
266
+ });
267
+ if (cursor) params.set("cursor", cursor);
268
+
269
+ const response = await fetchJson(`https://slack.com/api/users.conversations?${params.toString()}`, { headers });
270
+ if (!response?.ok || !Array.isArray(response.channels)) {
271
+ return channels.length > 0 ? channels.map(({ id, label }) => ({ id, label })) : null;
272
+ }
273
+
274
+ for (const channel of response.channels as Array<{ id?: string; name?: string; is_private?: boolean }>) {
275
+ if (!channel.id || !channel.name) continue;
276
+ channels.push({
277
+ id: channel.id,
278
+ name: channel.name,
279
+ label: channel.is_private ? `[private] ${channel.name}` : `#${channel.name}`,
280
+ });
281
+ }
282
+
283
+ cursor = typeof response.response_metadata?.next_cursor === "string"
284
+ ? response.response_metadata.next_cursor
285
+ : "";
286
+ } while (cursor);
287
+
288
+ channels.sort((a, b) => a.name.localeCompare(b.name));
289
+ return channels.map(({ id, label }) => ({ id, label }));
290
+ }
291
+
292
+ async function promptSlackChannelId(ctx: ExtensionCommandContext): Promise<string | null> {
293
+ const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
294
+ if (!channelId) return null;
295
+ if (!isValidChannelId("slack", channelId)) {
296
+ ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error");
297
+ return null;
298
+ }
299
+ return channelId;
300
+ }
301
+
206
302
  function getAuthStorage(): AuthStorage {
207
303
  const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
208
304
  mkdirSync(dirname(authPath), { recursive: true });
@@ -219,7 +315,7 @@ function removeProviderToken(provider: string): void {
219
315
  auth.set(provider, { type: "api_key", key: "" });
220
316
  }
221
317
 
222
- export function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
318
+ export function saveRemoteQuestionsConfig(channel: "slack" | "discord" | "telegram", channelId: string): void {
223
319
  const prefsPath = getGlobalGSDPreferencesPath();
224
320
  const block = [
225
321
  "remote_questions:",
@@ -3,10 +3,11 @@
3
3
  */
4
4
 
5
5
  import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
6
- import { formatForSlack, parseSlackReply } from "./format.js";
6
+ import { formatForSlack, parseSlackReply, parseSlackReactionResponse, SLACK_NUMBER_REACTION_NAMES } from "./format.js";
7
7
 
8
8
  const SLACK_API = "https://slack.com/api";
9
9
  const PER_REQUEST_TIMEOUT_MS = 15_000;
10
+ const SLACK_ACK_REACTION = "white_check_mark";
10
11
 
11
12
  export class SlackAdapter implements ChannelAdapter {
12
13
  readonly name = "slack" as const;
@@ -36,6 +37,17 @@ export class SlackAdapter implements ChannelAdapter {
36
37
 
37
38
  const ts = String(res.ts);
38
39
  const channel = String(res.channel);
40
+ if (prompt.questions.length === 1) {
41
+ const reactionNames = SLACK_NUMBER_REACTION_NAMES.slice(0, prompt.questions[0].options.length);
42
+ for (const name of reactionNames) {
43
+ try {
44
+ await this.slackApi("reactions.add", { channel, timestamp: ts, name });
45
+ } catch {
46
+ // Best-effort only
47
+ }
48
+ }
49
+ }
50
+
39
51
  return {
40
52
  ref: {
41
53
  id: prompt.id,
@@ -51,6 +63,11 @@ export class SlackAdapter implements ChannelAdapter {
51
63
  async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
52
64
  if (!this.botUserId) await this.validate();
53
65
 
66
+ if (prompt.questions.length === 1) {
67
+ const reactionAnswer = await this.checkReactions(prompt, ref);
68
+ if (reactionAnswer) return reactionAnswer;
69
+ }
70
+
54
71
  const res = await this.slackApi("conversations.replies", {
55
72
  channel: ref.channelId,
56
73
  ts: ref.threadTs!,
@@ -66,9 +83,48 @@ export class SlackAdapter implements ChannelAdapter {
66
83
  return parseSlackReply(String(userReplies[0].text), prompt.questions);
67
84
  }
68
85
 
86
+ async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
87
+ try {
88
+ await this.slackApi("reactions.add", {
89
+ channel: ref.channelId,
90
+ timestamp: ref.messageId,
91
+ name: SLACK_ACK_REACTION,
92
+ });
93
+ } catch {
94
+ // Best-effort only
95
+ }
96
+ }
97
+
98
+ private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
99
+ const res = await this.slackApi("reactions.get", {
100
+ channel: ref.channelId,
101
+ timestamp: ref.messageId,
102
+ full: "true",
103
+ });
104
+
105
+ if (!res.ok) return null;
106
+
107
+ const message = (res.message ?? {}) as {
108
+ reactions?: Array<{ name?: string; count?: number; users?: string[] }>;
109
+ };
110
+ const reactions = Array.isArray(message.reactions) ? message.reactions : [];
111
+ const picked = reactions
112
+ .filter((reaction) => reaction.name && SLACK_NUMBER_REACTION_NAMES.includes(reaction.name))
113
+ .filter((reaction) => {
114
+ const count = Number(reaction.count ?? 0);
115
+ const users = Array.isArray(reaction.users) ? reaction.users.map(String) : [];
116
+ const botIncluded = this.botUserId ? users.includes(this.botUserId) : false;
117
+ return count > (botIncluded ? 1 : 0);
118
+ })
119
+ .map((reaction) => String(reaction.name));
120
+
121
+ if (picked.length === 0) return null;
122
+ return parseSlackReactionResponse(picked, prompt.questions);
123
+ }
124
+
69
125
  private async slackApi(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
70
126
  const url = `${SLACK_API}/${method}`;
71
- const isGet = method === "conversations.replies" || method === "auth.test";
127
+ const isGet = method === "conversations.replies" || method === "auth.test" || method === "reactions.get";
72
128
 
73
129
  let response: Response;
74
130
  if (isGet) {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Remote Questions — Telegram adapter
3
+ */
4
+
5
+ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
6
+ import { formatForTelegram, parseTelegramResponse } from "./format.js";
7
+
8
+ const TELEGRAM_API = "https://api.telegram.org";
9
+ const PER_REQUEST_TIMEOUT_MS = 15_000;
10
+
11
+ export class TelegramAdapter implements ChannelAdapter {
12
+ readonly name = "telegram" as const;
13
+ private botUserId: number | null = null;
14
+ private lastUpdateId = 0;
15
+ private lastSentText = "";
16
+ private readonly token: string;
17
+ private readonly chatId: string;
18
+
19
+ constructor(token: string, chatId: string) {
20
+ this.token = token;
21
+ this.chatId = chatId;
22
+ }
23
+
24
+ async validate(): Promise<void> {
25
+ const res = await this.telegramApi("getMe");
26
+ if (!res.ok || !res.result?.id) throw new Error("Telegram auth failed: invalid bot token");
27
+ this.botUserId = res.result.id;
28
+ }
29
+
30
+ async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
31
+ const payload = formatForTelegram(prompt);
32
+ this.lastSentText = payload.text;
33
+
34
+ const params: Record<string, unknown> = {
35
+ chat_id: this.chatId,
36
+ text: payload.text,
37
+ parse_mode: payload.parse_mode,
38
+ };
39
+ if (payload.reply_markup) {
40
+ params.reply_markup = payload.reply_markup;
41
+ }
42
+
43
+ const res = await this.telegramApi("sendMessage", params);
44
+ if (!res.ok || !res.result?.message_id) {
45
+ throw new Error(`Telegram sendMessage failed: ${JSON.stringify(res)}`);
46
+ }
47
+
48
+ const messageId = String(res.result.message_id);
49
+ const messageUrl = this.buildMessageUrl(this.chatId, messageId);
50
+
51
+ return {
52
+ ref: {
53
+ id: prompt.id,
54
+ channel: "telegram",
55
+ messageId,
56
+ channelId: this.chatId,
57
+ threadUrl: messageUrl,
58
+ },
59
+ };
60
+ }
61
+
62
+ async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
63
+ if (!this.botUserId) await this.validate();
64
+
65
+ const res = await this.telegramApi("getUpdates", {
66
+ offset: this.lastUpdateId + 1,
67
+ timeout: 0,
68
+ allowed_updates: ["message", "callback_query"],
69
+ });
70
+
71
+ if (!res.ok || !Array.isArray(res.result)) return null;
72
+
73
+ for (const update of res.result) {
74
+ // Advance offset for all updates to prevent reprocessing
75
+ if (update.update_id > this.lastUpdateId) {
76
+ this.lastUpdateId = update.update_id;
77
+ }
78
+
79
+ // Handle callback_query (inline keyboard button press)
80
+ if (update.callback_query) {
81
+ const cq = update.callback_query;
82
+ const msg = cq.message;
83
+ if (
84
+ msg &&
85
+ String(msg.chat?.id) === ref.channelId &&
86
+ String(msg.message_id) === ref.messageId &&
87
+ cq.from?.id !== this.botUserId
88
+ ) {
89
+ // Dismiss the loading spinner on the button
90
+ try {
91
+ await this.telegramApi("answerCallbackQuery", { callback_query_id: cq.id });
92
+ } catch { /* best-effort */ }
93
+
94
+ return parseTelegramResponse(cq.data ?? null, null, prompt.questions, prompt.id);
95
+ }
96
+ }
97
+
98
+ // Handle text reply (reply_to_message)
99
+ if (update.message) {
100
+ const msg = update.message;
101
+ if (
102
+ String(msg.chat?.id) === ref.channelId &&
103
+ msg.reply_to_message &&
104
+ String(msg.reply_to_message.message_id) === ref.messageId &&
105
+ msg.from?.id !== this.botUserId &&
106
+ msg.text
107
+ ) {
108
+ return parseTelegramResponse(null, msg.text, prompt.questions, prompt.id);
109
+ }
110
+ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Acknowledge receipt by editing the original message to append a checkmark.
118
+ * Best-effort — failures are silently ignored.
119
+ */
120
+ async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
121
+ try {
122
+ await this.telegramApi("editMessageText", {
123
+ chat_id: ref.channelId,
124
+ message_id: parseInt(ref.messageId, 10),
125
+ text: this.lastSentText + "\n\n✅ Answered",
126
+ parse_mode: "HTML",
127
+ });
128
+ } catch {
129
+ // Best-effort — don't let acknowledgement failures affect the flow
130
+ }
131
+ }
132
+
133
+ private buildMessageUrl(chatId: string, messageId: string): string | undefined {
134
+ // Supergroups have chat IDs starting with -100
135
+ if (chatId.startsWith("-100")) {
136
+ return `https://t.me/c/${chatId.slice(4)}/${messageId}`;
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ private async telegramApi(method: string, params?: Record<string, unknown>): Promise<any> {
142
+ const url = `${TELEGRAM_API}/bot${this.token}/${method}`;
143
+ const init: RequestInit = {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS),
147
+ };
148
+
149
+ if (params) {
150
+ init.body = JSON.stringify(params);
151
+ }
152
+
153
+ const response = await fetch(url, init);
154
+ if (!response.ok) {
155
+ const text = await response.text().catch(() => "");
156
+ const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text;
157
+ throw new Error(`Telegram API HTTP ${response.status}: ${safeText}`);
158
+ }
159
+ return response.json();
160
+ }
161
+ }
@@ -2,7 +2,7 @@
2
2
  * Remote Questions — shared types
3
3
  */
4
4
 
5
- export type RemoteChannel = "slack" | "discord";
5
+ export type RemoteChannel = "slack" | "discord" | "telegram";
6
6
 
7
7
  export interface RemoteQuestionOption {
8
8
  label: string;
@@ -72,4 +72,5 @@ export interface ChannelAdapter {
72
72
  validate(): Promise<void>;
73
73
  sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
74
74
  pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
75
+ acknowledgeAnswer?(ref: RemotePromptRef): Promise<void>;
75
76
  }