@llblab/pi-telegram 0.45.11 → 0.47.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 (204) hide show
  1. package/BACKLOG.md +5 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +6 -6
  4. package/dist/api/activity.d.ts +6 -0
  5. package/dist/api/activity.js +6 -0
  6. package/dist/api/commands.d.ts +6 -0
  7. package/dist/api/commands.js +6 -0
  8. package/dist/api/delivery.d.ts +6 -0
  9. package/dist/api/delivery.js +6 -0
  10. package/dist/api/inbound.d.ts +6 -0
  11. package/dist/api/inbound.js +6 -0
  12. package/dist/api/keyboard.d.ts +6 -0
  13. package/dist/api/keyboard.js +6 -0
  14. package/dist/api/outbound.d.ts +6 -0
  15. package/dist/api/outbound.js +6 -0
  16. package/dist/api/sections.d.ts +7 -0
  17. package/dist/api/sections.js +6 -0
  18. package/dist/api/status.d.ts +6 -0
  19. package/dist/api/status.js +6 -0
  20. package/dist/api/updates.d.ts +6 -0
  21. package/dist/api/updates.js +6 -0
  22. package/dist/api/voice.d.ts +6 -0
  23. package/dist/api/voice.js +6 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.js +6 -0
  26. package/dist/lib/activity-verbosity.d.ts +52 -0
  27. package/dist/lib/activity-verbosity.js +596 -0
  28. package/dist/lib/activity.d.ts +220 -0
  29. package/dist/lib/activity.js +574 -0
  30. package/dist/lib/agent-messages.d.ts +28 -0
  31. package/dist/lib/agent-messages.js +86 -0
  32. package/dist/lib/bindings.d.ts +250 -0
  33. package/dist/lib/bindings.js +936 -0
  34. package/dist/lib/bus-api.d.ts +18 -0
  35. package/dist/lib/bus-api.js +254 -0
  36. package/dist/lib/bus-follower.d.ts +464 -0
  37. package/dist/lib/bus-follower.js +1686 -0
  38. package/dist/lib/bus-leader.d.ts +292 -0
  39. package/dist/lib/bus-leader.js +2242 -0
  40. package/dist/lib/bus-transport.d.ts +64 -0
  41. package/dist/lib/bus-transport.js +140 -0
  42. package/dist/lib/bus.d.ts +518 -0
  43. package/dist/lib/bus.js +2055 -0
  44. package/dist/lib/channel-posts.d.ts +170 -0
  45. package/dist/lib/channel-posts.js +611 -0
  46. package/dist/lib/command-templates.d.ts +70 -0
  47. package/dist/lib/command-templates.js +744 -0
  48. package/dist/lib/commands.d.ts +541 -0
  49. package/dist/lib/commands.js +1155 -0
  50. package/dist/lib/config.d.ts +252 -0
  51. package/dist/lib/config.js +889 -0
  52. package/dist/lib/delivery.d.ts +163 -0
  53. package/dist/lib/delivery.js +542 -0
  54. package/dist/lib/extension.d.ts +7 -0
  55. package/dist/lib/extension.js +1608 -0
  56. package/dist/lib/generative-app-worker.mjs +104 -0
  57. package/dist/lib/generative-apps.d.ts +212 -0
  58. package/dist/lib/generative-apps.js +1006 -0
  59. package/dist/lib/inbound.d.ts +91 -0
  60. package/dist/lib/inbound.js +502 -0
  61. package/dist/lib/journal.d.ts +665 -0
  62. package/dist/lib/journal.js +3701 -0
  63. package/dist/lib/keyboard.d.ts +23 -0
  64. package/dist/lib/keyboard.js +37 -0
  65. package/dist/lib/lifecycle.d.ts +157 -0
  66. package/dist/lib/lifecycle.js +395 -0
  67. package/dist/lib/locks.d.ts +164 -0
  68. package/dist/lib/locks.js +1208 -0
  69. package/dist/lib/logging.d.ts +50 -0
  70. package/dist/lib/logging.js +274 -0
  71. package/dist/lib/media.d.ts +181 -0
  72. package/dist/lib/media.js +602 -0
  73. package/dist/lib/menu-model.d.ts +217 -0
  74. package/dist/lib/menu-model.js +663 -0
  75. package/dist/lib/menu-queue.d.ts +37 -0
  76. package/dist/lib/menu-queue.js +408 -0
  77. package/dist/lib/menu-settings.d.ts +116 -0
  78. package/dist/lib/menu-settings.js +595 -0
  79. package/dist/lib/menu-status.d.ts +32 -0
  80. package/dist/lib/menu-status.js +112 -0
  81. package/dist/lib/menu-thinking.d.ts +29 -0
  82. package/dist/lib/menu-thinking.js +82 -0
  83. package/dist/lib/menu.d.ts +171 -0
  84. package/dist/lib/menu.js +323 -0
  85. package/dist/lib/model.d.ts +127 -0
  86. package/dist/lib/model.js +409 -0
  87. package/dist/lib/outbound-attachments.d.ts +241 -0
  88. package/dist/lib/outbound-attachments.js +639 -0
  89. package/dist/lib/outbound-buttons.d.ts +69 -0
  90. package/dist/lib/outbound-buttons.js +248 -0
  91. package/dist/lib/outbound-markup.d.ts +42 -0
  92. package/dist/lib/outbound-markup.js +678 -0
  93. package/dist/lib/outbound-voice.d.ts +55 -0
  94. package/dist/lib/outbound-voice.js +152 -0
  95. package/dist/lib/outbound.d.ts +185 -0
  96. package/dist/lib/outbound.js +516 -0
  97. package/dist/lib/ownership.d.ts +77 -0
  98. package/dist/lib/ownership.js +174 -0
  99. package/dist/lib/paths.d.ts +40 -0
  100. package/dist/lib/paths.js +94 -0
  101. package/dist/lib/pi.d.ts +72 -0
  102. package/dist/lib/pi.js +121 -0
  103. package/dist/lib/polling.d.ts +351 -0
  104. package/dist/lib/polling.js +1196 -0
  105. package/dist/lib/preview.d.ts +192 -0
  106. package/dist/lib/preview.js +614 -0
  107. package/dist/lib/prompt-templates.d.ts +24 -0
  108. package/dist/lib/prompt-templates.js +118 -0
  109. package/dist/lib/prompts.d.ts +57 -0
  110. package/dist/lib/prompts.js +167 -0
  111. package/dist/lib/queue.d.ts +766 -0
  112. package/dist/lib/queue.js +1965 -0
  113. package/dist/lib/recovery.d.ts +86 -0
  114. package/dist/lib/recovery.js +285 -0
  115. package/dist/lib/rendering.d.ts +20 -0
  116. package/dist/lib/rendering.js +983 -0
  117. package/dist/lib/replies.d.ts +214 -0
  118. package/dist/lib/replies.js +737 -0
  119. package/dist/lib/routing.d.ts +207 -0
  120. package/dist/lib/routing.js +2282 -0
  121. package/dist/lib/runtime.d.ts +172 -0
  122. package/dist/lib/runtime.js +402 -0
  123. package/dist/lib/sections.d.ts +165 -0
  124. package/dist/lib/sections.js +327 -0
  125. package/dist/lib/setup.d.ts +82 -0
  126. package/dist/lib/setup.js +150 -0
  127. package/dist/lib/skills.d.ts +8 -0
  128. package/dist/lib/skills.js +12 -0
  129. package/dist/lib/status.d.ts +442 -0
  130. package/dist/lib/status.js +1006 -0
  131. package/dist/lib/sync.d.ts +179 -0
  132. package/dist/lib/sync.js +782 -0
  133. package/dist/lib/target.d.ts +20 -0
  134. package/dist/lib/target.js +27 -0
  135. package/dist/lib/telegram-api.d.ts +541 -0
  136. package/dist/lib/telegram-api.js +1159 -0
  137. package/dist/lib/text-groups.d.ts +89 -0
  138. package/dist/lib/text-groups.js +317 -0
  139. package/dist/lib/thread-cleanup-manager.d.ts +288 -0
  140. package/dist/lib/thread-cleanup-manager.js +560 -0
  141. package/dist/lib/thread-display.d.ts +46 -0
  142. package/dist/lib/thread-display.js +257 -0
  143. package/dist/lib/thread-naming.d.ts +46 -0
  144. package/dist/lib/thread-naming.js +78 -0
  145. package/dist/lib/thread-reconciler.d.ts +239 -0
  146. package/dist/lib/thread-reconciler.js +644 -0
  147. package/dist/lib/threads.d.ts +621 -0
  148. package/dist/lib/threads.js +3679 -0
  149. package/dist/lib/time-injection.d.ts +15 -0
  150. package/dist/lib/time-injection.js +56 -0
  151. package/dist/lib/turns.d.ts +109 -0
  152. package/dist/lib/turns.js +500 -0
  153. package/dist/lib/updates.d.ts +1290 -0
  154. package/dist/lib/updates.js +3634 -0
  155. package/dist/lib/voice.d.ts +117 -0
  156. package/dist/lib/voice.js +174 -0
  157. package/dist/lib/workspace-admission.d.ts +264 -0
  158. package/dist/lib/workspace-admission.js +1136 -0
  159. package/dist/lib/workspace-retirement.d.ts +219 -0
  160. package/dist/lib/workspace-retirement.js +587 -0
  161. package/dist/lib/workspace-slots.d.ts +30 -0
  162. package/dist/lib/workspace-slots.js +54 -0
  163. package/dist/package.json +126 -0
  164. package/dist/pi-telegram/index.js +1 -0
  165. package/dist/skills/generated-control-surface/SKILL.md +107 -0
  166. package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
  167. package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
  168. package/dist/skills/generative-apps/SKILL.md +115 -0
  169. package/dist/skills/show-me/SKILL.md +166 -0
  170. package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
  171. package/dist/skills/telegram-bridge/SKILL.md +129 -0
  172. package/dist/skills/telegram-bridge/references/configuration.md +15 -0
  173. package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  174. package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
  175. package/docs/architecture.md +1 -1
  176. package/docs/compact-matrix-literal.md +10 -3
  177. package/docs/generative-apps.md +15 -13
  178. package/docs/multi-instance-bus.md +17 -3
  179. package/docs/outbound.md +1 -1
  180. package/docs/public-api.md +2 -2
  181. package/lib/bindings.ts +89 -6
  182. package/lib/bus-follower.ts +11 -3
  183. package/lib/bus-leader.ts +57 -20
  184. package/lib/bus.ts +12 -2
  185. package/lib/commands.ts +2 -2
  186. package/lib/config.ts +14 -7
  187. package/lib/delivery.ts +38 -6
  188. package/lib/extension.ts +26 -1
  189. package/lib/generative-apps.ts +379 -13
  190. package/lib/menu-settings.ts +21 -5
  191. package/lib/outbound-markup.ts +16 -7
  192. package/lib/pi.ts +4 -0
  193. package/lib/sync.ts +3 -0
  194. package/lib/telegram-api.ts +12 -1
  195. package/lib/thread-cleanup-manager.ts +36 -6
  196. package/lib/thread-display.ts +96 -18
  197. package/lib/threads.ts +150 -30
  198. package/package.json +56 -13
  199. package/scripts/build-dist.mjs +44 -0
  200. package/scripts/measure-bus.mjs +8 -1
  201. package/scripts/measure-workspace.mjs +8 -1
  202. package/skills/generative-apps/SKILL.md +1 -1
  203. package/skills/show-me/SKILL.md +1 -1
  204. package/skills/telegram-bridge/SKILL.md +1 -1
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Telegram model menu UI helpers
3
+ * Zones: telegram ui, model controls, menu composition
4
+ * Owns model-menu state, scoped model pages, model callback planning, and model-menu message rendering
5
+ */
6
+ import { getCanonicalModelId, modelsMatch, parseTelegramCliScopedModelPatterns, resolveScopedModelPatterns, sortScopedModels, } from "./model.js";
7
+ const TELEGRAM_MODEL_MENU_CACHE_TTL_MS = 5000;
8
+ const TELEGRAM_MODEL_MENU_STATE_TTL_MS = 10 * 60 * 1000;
9
+ const MAX_STORED_TELEGRAM_MODEL_MENUS = 50;
10
+ export const TELEGRAM_MODEL_PAGE_SIZE = 6;
11
+ const TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE = 4;
12
+ export const MODEL_MENU_TITLE = "<b>🤖 Choose a model:</b>";
13
+ export const MODEL_PAGE_MENU_TITLE = "<b>Choose a page:</b>";
14
+ export const MODEL_DETAIL_MENU_TITLE = "<b>🤖 Model:</b>";
15
+ function truncateTelegramButtonLabel(label, maxLength = 56) {
16
+ return label.length <= maxLength
17
+ ? label
18
+ : `${label.slice(0, maxLength - 1)}…`;
19
+ }
20
+ function getTelegramCliScopedModelPatterns() {
21
+ return parseTelegramCliScopedModelPatterns(process.argv.slice(2));
22
+ }
23
+ function parseTelegramModelMenuCallbackAction(data) {
24
+ if (!data?.startsWith("model:"))
25
+ return undefined;
26
+ const [, action, value] = data.split(":");
27
+ if (action === "noop" ||
28
+ action === "scope" ||
29
+ action === "page" ||
30
+ action === "pages" ||
31
+ action === "open" ||
32
+ action === "pick" ||
33
+ action === "pick-selected" ||
34
+ action === "scope-enable" ||
35
+ action === "scope-disable" ||
36
+ action === "scope-toggle") {
37
+ return { action, value };
38
+ }
39
+ return undefined;
40
+ }
41
+ function applyTelegramMenuRenderPayload(state, payload) {
42
+ state.mode = payload.nextMode;
43
+ return payload;
44
+ }
45
+ async function editTelegramMenuMessage(state, payload, deps) {
46
+ const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
47
+ await deps.editInteractiveMessage(state.chatId, state.messageId, appliedPayload.text, appliedPayload.mode, appliedPayload.replyMarkup);
48
+ }
49
+ function sendTelegramMenuMessage(state, payload, deps) {
50
+ const appliedPayload = applyTelegramMenuRenderPayload(state, payload);
51
+ return deps.sendInteractiveMessage(state.chatId, appliedPayload.text, appliedPayload.mode, appliedPayload.replyMarkup, state.threadId !== undefined
52
+ ? { target: { chatId: state.chatId, threadId: state.threadId } }
53
+ : undefined);
54
+ }
55
+ export function formatScopedModelButtonText(entry, currentModel) {
56
+ let label = `${modelsMatch(entry.model, currentModel) ? "🟢 " : ""}${entry.model.provider}/${entry.model.id}`;
57
+ if (entry.thinkingLevel) {
58
+ label += ` (${entry.thinkingLevel})`;
59
+ }
60
+ return truncateTelegramButtonLabel(label);
61
+ }
62
+ export function formatStatusButtonLabel(label, value) {
63
+ return truncateTelegramButtonLabel(`${label}: ${value}`, 64);
64
+ }
65
+ export function getModelMenuItems(state) {
66
+ return state.scope === "scoped" && state.scopedModels.length > 0
67
+ ? state.scopedModels
68
+ : state.allModels;
69
+ }
70
+ function getTelegramMenuStateKey(chatId, messageId) {
71
+ return `${chatId}:${messageId}`;
72
+ }
73
+ export function pruneStoredTelegramModelMenus(menus, options) {
74
+ const now = options.now ?? Date.now();
75
+ for (const [messageId, entry] of menus.entries()) {
76
+ if (now - entry.updatedAt <= options.maxAgeMs)
77
+ continue;
78
+ menus.delete(messageId);
79
+ }
80
+ while (menus.size > options.maxStoredMenus) {
81
+ const oldestKey = menus.keys().next().value;
82
+ if (oldestKey === undefined)
83
+ return;
84
+ menus.delete(oldestKey);
85
+ }
86
+ }
87
+ export function storeTelegramModelMenuState(menus, state, options) {
88
+ const now = options.now ?? Date.now();
89
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
90
+ menus.set(getTelegramMenuStateKey(state.chatId, state.messageId), {
91
+ state,
92
+ updatedAt: now,
93
+ });
94
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
95
+ }
96
+ export function getStoredTelegramModelMenuState(menus, messageId, options, chatId) {
97
+ if (messageId === undefined)
98
+ return undefined;
99
+ const now = options.now ?? Date.now();
100
+ pruneStoredTelegramModelMenus(menus, { ...options, now });
101
+ const key = typeof chatId === "number"
102
+ ? getTelegramMenuStateKey(chatId, messageId)
103
+ : [...menus.keys()].find((candidate) => candidate.endsWith(`:${messageId}`));
104
+ if (!key)
105
+ return undefined;
106
+ const entry = menus.get(key);
107
+ if (!entry)
108
+ return undefined;
109
+ menus.delete(key);
110
+ entry.updatedAt = now;
111
+ menus.set(key, entry);
112
+ return entry.state;
113
+ }
114
+ export function createTelegramModelMenuRuntime(options = {}) {
115
+ const menus = new Map();
116
+ let cachedInputs;
117
+ const getStoreOptions = () => ({
118
+ maxAgeMs: options.maxAgeMs ?? TELEGRAM_MODEL_MENU_STATE_TTL_MS,
119
+ maxStoredMenus: options.maxStoredMenus ?? MAX_STORED_TELEGRAM_MODEL_MENUS,
120
+ now: options.now,
121
+ });
122
+ return {
123
+ storeState: (state) => {
124
+ storeTelegramModelMenuState(menus, state, getStoreOptions());
125
+ },
126
+ getState: (messageId, chatId) => getStoredTelegramModelMenuState(menus, messageId, getStoreOptions(), chatId),
127
+ clear: () => {
128
+ menus.clear();
129
+ cachedInputs = undefined;
130
+ },
131
+ clearCachedInputs: () => {
132
+ cachedInputs = undefined;
133
+ },
134
+ buildState: async (stateOptions) => {
135
+ const result = await buildTelegramModelMenuStateRuntime({
136
+ ...stateOptions,
137
+ cachedInputs,
138
+ cacheTtlMs: TELEGRAM_MODEL_MENU_CACHE_TTL_MS,
139
+ });
140
+ cachedInputs = result.cachedInputs;
141
+ return result.state;
142
+ },
143
+ };
144
+ }
145
+ export function createTelegramModelMenuStateBuilder(deps) {
146
+ return async (chatId, ctx, threadId) => {
147
+ const settingsManager = await deps.createSettingsManager(ctx.cwd);
148
+ return deps.runtime.buildState({
149
+ chatId,
150
+ threadId,
151
+ activeModel: deps.getActiveModel(ctx),
152
+ ctx,
153
+ reloadSettings: async () => {
154
+ await settingsManager.reload?.();
155
+ },
156
+ getConfiguredScopedModelPatterns: () => settingsManager.getEnabledModels(),
157
+ });
158
+ };
159
+ }
160
+ export async function resolveCachedTelegramModelMenuInputs(cachedInputs, deps) {
161
+ const now = deps.now ?? Date.now();
162
+ if (cachedInputs && cachedInputs.expiresAt > now)
163
+ return cachedInputs;
164
+ await deps.reloadSettings();
165
+ const availableModels = deps.refreshAvailableModels();
166
+ const cliScopedModelPatterns = deps.getCliScopedModelPatterns();
167
+ const configuredScopedModelPatterns = cliScopedModelPatterns ?? deps.getConfiguredScopedModelPatterns() ?? [];
168
+ return {
169
+ expiresAt: now + deps.cacheTtlMs,
170
+ availableModels,
171
+ configuredScopedModelPatterns,
172
+ cliScopedModelPatterns,
173
+ };
174
+ }
175
+ export function buildTelegramModelMenuState(params) {
176
+ const allModels = sortScopedModels(params.availableModels.map((model) => ({ model })), params.activeModel);
177
+ const scopedModels = params.configuredScopedModelPatterns.length > 0
178
+ ? sortScopedModels(resolveScopedModelPatterns(params.configuredScopedModelPatterns, params.availableModels), params.activeModel)
179
+ : [];
180
+ let note;
181
+ if (params.configuredScopedModelPatterns.length > 0 &&
182
+ scopedModels.length === 0) {
183
+ note = params.cliScopedModelPatterns
184
+ ? "No CLI scoped models matched the current auth configuration. Showing all available models."
185
+ : "No scoped models matched the current auth configuration. Showing all available models.";
186
+ }
187
+ return {
188
+ chatId: params.chatId,
189
+ threadId: params.threadId,
190
+ messageId: 0,
191
+ page: 0,
192
+ scope: scopedModels.length > 0 ? "scoped" : "all",
193
+ scopedModels,
194
+ allModels,
195
+ note,
196
+ selectedModelIndex: undefined,
197
+ scopedModelPatterns: params.configuredScopedModelPatterns,
198
+ canMutateScope: !params.cliScopedModelPatterns,
199
+ mode: "status",
200
+ };
201
+ }
202
+ export async function buildTelegramModelMenuStateRuntime(options) {
203
+ const cachedInputs = await resolveCachedTelegramModelMenuInputs(options.cachedInputs, {
204
+ cacheTtlMs: options.cacheTtlMs,
205
+ reloadSettings: options.reloadSettings,
206
+ refreshAvailableModels: () => {
207
+ options.ctx.modelRegistry.refresh();
208
+ return options.ctx.modelRegistry.getAvailable();
209
+ },
210
+ getConfiguredScopedModelPatterns: options.getConfiguredScopedModelPatterns,
211
+ getCliScopedModelPatterns: options.getCliScopedModelPatterns ?? getTelegramCliScopedModelPatterns,
212
+ });
213
+ return {
214
+ cachedInputs,
215
+ state: buildTelegramModelMenuState({
216
+ chatId: options.chatId,
217
+ threadId: options.threadId,
218
+ activeModel: options.activeModel,
219
+ availableModels: cachedInputs.availableModels,
220
+ configuredScopedModelPatterns: cachedInputs.configuredScopedModelPatterns,
221
+ cliScopedModelPatterns: cachedInputs.cliScopedModelPatterns,
222
+ }),
223
+ };
224
+ }
225
+ export function applyTelegramModelScopeSelection(state, value) {
226
+ if (value !== "all" && value !== "scoped")
227
+ return "invalid";
228
+ if (value === state.scope)
229
+ return "unchanged";
230
+ state.scope = value;
231
+ state.page = 0;
232
+ return "changed";
233
+ }
234
+ export function applyTelegramModelPageSelection(state, value) {
235
+ const page = Number(value);
236
+ if (!Number.isFinite(page))
237
+ return "invalid";
238
+ if (page === state.page)
239
+ return "unchanged";
240
+ state.page = page;
241
+ return "changed";
242
+ }
243
+ export function getTelegramModelSelection(state, value) {
244
+ const index = Number(value);
245
+ if (!Number.isFinite(index))
246
+ return { kind: "invalid" };
247
+ const selection = getModelMenuItems(state)[index];
248
+ if (!selection)
249
+ return { kind: "missing" };
250
+ return { kind: "selected", selection };
251
+ }
252
+ export function applyTelegramModelDetailSelection(state, value) {
253
+ const index = Number(value);
254
+ if (!Number.isFinite(index))
255
+ return "invalid";
256
+ const selection = getModelMenuItems(state)[index];
257
+ if (!selection)
258
+ return "invalid";
259
+ state.selectedModelIndex = index;
260
+ state.selectedModelKey = getCanonicalModelId(selection.model);
261
+ state.mode = "model-detail";
262
+ return "changed";
263
+ }
264
+ export function getTelegramSelectedDetailModel(state) {
265
+ const indexedSelection = getTelegramModelSelection(state, state.selectedModelIndex?.toString());
266
+ if (indexedSelection.kind === "selected") {
267
+ if (!state.selectedModelKey)
268
+ return indexedSelection;
269
+ const indexedKey = getCanonicalModelId(indexedSelection.selection.model).toLowerCase();
270
+ if (indexedKey === state.selectedModelKey.toLowerCase()) {
271
+ return indexedSelection;
272
+ }
273
+ }
274
+ if (state.selectedModelKey) {
275
+ const lowerKey = state.selectedModelKey.toLowerCase();
276
+ const selection = state.allModels.find((entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey);
277
+ if (selection)
278
+ return { kind: "selected", selection };
279
+ }
280
+ return indexedSelection;
281
+ }
282
+ export function isTelegramModelScoped(state, model) {
283
+ const key = getCanonicalModelId(model);
284
+ return state.scopedModels.some((entry) => getCanonicalModelId(entry.model) === key);
285
+ }
286
+ export function focusTelegramModelListPage(state, model, pageSize = TELEGRAM_MODEL_PAGE_SIZE) {
287
+ const key = getCanonicalModelId(model).toLowerCase();
288
+ const index = getModelMenuItems(state).findIndex((entry) => getCanonicalModelId(entry.model).toLowerCase() === key);
289
+ state.page = index < 0 ? 0 : Math.floor(index / pageSize);
290
+ state.mode = "model";
291
+ }
292
+ function formatScopedModelPattern(entry) {
293
+ const key = getCanonicalModelId(entry.model);
294
+ return entry.thinkingLevel ? `${key}:${entry.thinkingLevel}` : key;
295
+ }
296
+ export function setTelegramModelScope(state, model, enabled) {
297
+ const key = getCanonicalModelId(model);
298
+ const lowerKey = key.toLowerCase();
299
+ const scopedModelPatterns = state.scopedModelPatterns ?? [];
300
+ const allModels = state.allModels.map((entry) => entry.model);
301
+ const patterns = [];
302
+ for (const pattern of scopedModelPatterns) {
303
+ const resolved = resolveScopedModelPatterns([pattern], allModels);
304
+ const matchesModel = resolved.some((entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey);
305
+ if (enabled || !matchesModel) {
306
+ if (pattern.toLowerCase() !== lowerKey)
307
+ patterns.push(pattern);
308
+ continue;
309
+ }
310
+ for (const entry of resolved) {
311
+ if (getCanonicalModelId(entry.model).toLowerCase() === lowerKey)
312
+ continue;
313
+ const expandedPattern = formatScopedModelPattern(entry);
314
+ const duplicated = patterns.some((item) => item.toLowerCase() === expandedPattern.toLowerCase());
315
+ if (!duplicated)
316
+ patterns.push(expandedPattern);
317
+ }
318
+ }
319
+ if (enabled)
320
+ patterns.push(key);
321
+ state.scopedModelPatterns = patterns;
322
+ state.scopedModels = sortScopedModels(resolveScopedModelPatterns(patterns, state.allModels.map((entry) => entry.model)), model);
323
+ if (state.scope === "scoped" && state.scopedModels.length === 0) {
324
+ state.scope = "all";
325
+ }
326
+ return { patterns, enabled };
327
+ }
328
+ export function buildTelegramModelCallbackPlan(params) {
329
+ const action = parseTelegramModelMenuCallbackAction(params.data);
330
+ if (!action)
331
+ return { kind: "ignore" };
332
+ if (action.action === "noop")
333
+ return { kind: "answer" };
334
+ if (action.action === "scope") {
335
+ const scopeResult = applyTelegramModelScopeSelection(params.state, action.value);
336
+ if (scopeResult === "invalid") {
337
+ return { kind: "answer", text: "Unknown model scope." };
338
+ }
339
+ if (scopeResult === "unchanged") {
340
+ return { kind: "answer" };
341
+ }
342
+ return {
343
+ kind: "update-menu",
344
+ text: params.state.scope === "scoped" ? "Scoped models" : "All models",
345
+ };
346
+ }
347
+ if (action.action === "pages") {
348
+ if (action.value === "back") {
349
+ params.state.mode = "model";
350
+ return { kind: "update-menu" };
351
+ }
352
+ params.state.mode = "model-pages";
353
+ return { kind: "update-menu" };
354
+ }
355
+ if (action.action === "page") {
356
+ const pageResult = applyTelegramModelPageSelection(params.state, action.value);
357
+ if (pageResult === "invalid") {
358
+ return { kind: "answer", text: "Invalid page." };
359
+ }
360
+ params.state.mode = "model";
361
+ if (pageResult === "unchanged") {
362
+ return { kind: "update-menu" };
363
+ }
364
+ return { kind: "update-menu" };
365
+ }
366
+ if (action.action === "open") {
367
+ const detailResult = applyTelegramModelDetailSelection(params.state, action.value);
368
+ if (detailResult === "invalid") {
369
+ return { kind: "answer", text: "Selected model is no longer available." };
370
+ }
371
+ return { kind: "update-menu" };
372
+ }
373
+ if (action.action === "scope-toggle" ||
374
+ action.action === "scope-enable" ||
375
+ action.action === "scope-disable") {
376
+ if (params.state.canMutateScope === false) {
377
+ return {
378
+ kind: "answer",
379
+ text: "Model scope is controlled by CLI --models.",
380
+ };
381
+ }
382
+ const selectionResult = getTelegramSelectedDetailModel(params.state);
383
+ if (selectionResult.kind !== "selected") {
384
+ return { kind: "answer", text: "Selected model is no longer available." };
385
+ }
386
+ const model = selectionResult.selection.model;
387
+ const enabled = action.action === "scope-toggle"
388
+ ? !isTelegramModelScoped(params.state, model)
389
+ : action.action === "scope-enable";
390
+ if (enabled === isTelegramModelScoped(params.state, model)) {
391
+ return { kind: "answer" };
392
+ }
393
+ const result = setTelegramModelScope(params.state, model, enabled);
394
+ return {
395
+ kind: "persist-scope",
396
+ patterns: result.patterns,
397
+ text: result.enabled
398
+ ? "Added to scoped models"
399
+ : "Removed from scoped models",
400
+ };
401
+ }
402
+ if (action.action !== "pick" && action.action !== "pick-selected") {
403
+ return { kind: "answer" };
404
+ }
405
+ const selectionResult = action.action === "pick-selected"
406
+ ? getTelegramSelectedDetailModel(params.state)
407
+ : getTelegramModelSelection(params.state, action.value);
408
+ if (selectionResult.kind === "invalid") {
409
+ return { kind: "answer", text: "Invalid model selection." };
410
+ }
411
+ if (selectionResult.kind === "missing") {
412
+ return { kind: "answer", text: "Selected model is no longer available." };
413
+ }
414
+ const selection = selectionResult.selection;
415
+ if (modelsMatch(selection.model, params.activeModel)) {
416
+ if (action.action === "pick-selected") {
417
+ focusTelegramModelListPage(params.state, selection.model);
418
+ }
419
+ return {
420
+ kind: "refresh-status",
421
+ selection,
422
+ callbackText: `Model: ${selection.model.id}`,
423
+ shouldApplyThinkingLevel: !!selection.thinkingLevel &&
424
+ selection.thinkingLevel !== params.currentThinkingLevel,
425
+ };
426
+ }
427
+ if (!params.isIdle) {
428
+ if (!params.canRestartBusyRun) {
429
+ return {
430
+ kind: "answer",
431
+ text: "Pi is busy. Send /abort, /next, or /stop.",
432
+ };
433
+ }
434
+ return {
435
+ kind: "switch-model",
436
+ selection,
437
+ mode: params.hasActiveToolExecutions
438
+ ? "restart-after-tool"
439
+ : "restart-now",
440
+ callbackText: params.hasActiveToolExecutions
441
+ ? `Switched to ${selection.model.id}. Restarting after the current tool finishes…`
442
+ : `Switching to ${selection.model.id} and continuing…`,
443
+ };
444
+ }
445
+ return {
446
+ kind: "switch-model",
447
+ selection,
448
+ mode: "idle",
449
+ callbackText: `Switched to ${selection.model.id}`,
450
+ };
451
+ }
452
+ export async function openTelegramModelMenu(deps) {
453
+ if (!deps.isIdle() && !deps.canOfferInFlightModelSwitch()) {
454
+ await deps.sendBusyMessage();
455
+ return;
456
+ }
457
+ const state = await deps.getModelMenuState();
458
+ if (state.allModels.length === 0) {
459
+ await deps.sendNoModelsMessage();
460
+ return;
461
+ }
462
+ const messageId = await deps.sendModelMenu(state, deps.getActiveModel());
463
+ if (messageId === undefined)
464
+ return;
465
+ state.messageId = messageId;
466
+ state.mode = "model";
467
+ deps.storeModelMenuState(state);
468
+ }
469
+ export async function handleTelegramModelMenuCallbackAction(callbackQueryId, params, deps) {
470
+ const plan = buildTelegramModelCallbackPlan(params);
471
+ if (plan.kind === "ignore")
472
+ return false;
473
+ if (plan.kind === "answer") {
474
+ await deps.answerCallbackQuery(callbackQueryId, plan.text);
475
+ return true;
476
+ }
477
+ if (plan.kind === "update-menu") {
478
+ await deps.updateModelMenuMessage();
479
+ await deps.answerCallbackQuery(callbackQueryId, plan.text);
480
+ return true;
481
+ }
482
+ if (plan.kind === "persist-scope") {
483
+ if (!deps.persistScopedModelPatterns) {
484
+ await deps.answerCallbackQuery(callbackQueryId, "Scoped model persistence is unavailable.");
485
+ return true;
486
+ }
487
+ await deps.persistScopedModelPatterns(plan.patterns);
488
+ await deps.updateModelMenuMessage();
489
+ await deps.answerCallbackQuery(callbackQueryId, plan.text);
490
+ return true;
491
+ }
492
+ if (plan.kind === "refresh-status") {
493
+ if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) {
494
+ deps.setThinkingLevel(plan.selection.thinkingLevel);
495
+ }
496
+ await deps.updateModelMenuMessage();
497
+ await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
498
+ return true;
499
+ }
500
+ const changed = await deps.setModel(plan.selection.model);
501
+ if (changed === false) {
502
+ await deps.answerCallbackQuery(callbackQueryId, "Model is not available.");
503
+ return true;
504
+ }
505
+ deps.setCurrentModel(plan.selection.model);
506
+ if (plan.selection.thinkingLevel) {
507
+ deps.setThinkingLevel(plan.selection.thinkingLevel);
508
+ }
509
+ await deps.updateModelMenuMessage();
510
+ if (plan.mode === "restart-after-tool") {
511
+ deps.stagePendingModelSwitch(plan.selection);
512
+ await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
513
+ return true;
514
+ }
515
+ if (plan.mode === "restart-now") {
516
+ const restarted = await deps.restartInterruptedTelegramTurn(plan.selection);
517
+ if (!restarted) {
518
+ await deps.answerCallbackQuery(callbackQueryId, "Pi is busy. Send /abort, /next, or /stop.");
519
+ return true;
520
+ }
521
+ }
522
+ await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
523
+ return true;
524
+ }
525
+ export function getTelegramModelMenuPage(state, pageSize) {
526
+ const items = getModelMenuItems(state);
527
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
528
+ const page = Math.max(0, Math.min(state.page, pageCount - 1));
529
+ const start = page * pageSize;
530
+ return {
531
+ page,
532
+ pageCount,
533
+ start,
534
+ items: items.slice(start, start + pageSize),
535
+ };
536
+ }
537
+ export function buildModelMenuReplyMarkup(state, currentModel, pageSize) {
538
+ const menuPage = getTelegramModelMenuPage(state, pageSize);
539
+ const rows = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
540
+ if (state.scopedModels.length > 0) {
541
+ rows.push([
542
+ {
543
+ text: state.scope === "scoped" ? "🟡 Scoped" : "⚫️ Scoped",
544
+ callback_data: "model:scope:scoped",
545
+ },
546
+ {
547
+ text: state.scope === "all" ? "🟣 All" : "⚫️ All",
548
+ callback_data: "model:scope:all",
549
+ },
550
+ ]);
551
+ }
552
+ const previousPage = menuPage.page === 0 ? menuPage.pageCount - 1 : menuPage.page - 1;
553
+ const nextPage = menuPage.page === menuPage.pageCount - 1 ? 0 : menuPage.page + 1;
554
+ if (menuPage.pageCount > 1) {
555
+ rows.push([
556
+ { text: "⬅️", callback_data: `model:page:${previousPage}` },
557
+ {
558
+ text: `${menuPage.page + 1}/${menuPage.pageCount}`,
559
+ callback_data: "model:pages",
560
+ },
561
+ { text: "➡️", callback_data: `model:page:${nextPage}` },
562
+ ]);
563
+ }
564
+ rows.push(...menuPage.items.map((entry, index) => [
565
+ {
566
+ text: formatScopedModelButtonText(entry, currentModel),
567
+ callback_data: `model:open:${menuPage.start + index}`,
568
+ },
569
+ ]));
570
+ return { inline_keyboard: rows };
571
+ }
572
+ export function buildModelDetailMenuReplyMarkup(state, currentModel) {
573
+ const selection = getTelegramSelectedDetailModel(state);
574
+ if (selection.kind !== "selected") {
575
+ return {
576
+ inline_keyboard: [
577
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
578
+ ],
579
+ };
580
+ }
581
+ const model = selection.selection.model;
582
+ const active = modelsMatch(model, currentModel);
583
+ const scoped = isTelegramModelScoped(state, model);
584
+ return {
585
+ inline_keyboard: [
586
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
587
+ [
588
+ {
589
+ text: active ? "🟢 Active" : "☑️ Activate",
590
+ callback_data: "model:pick-selected",
591
+ },
592
+ ],
593
+ [
594
+ {
595
+ text: scoped ? "🟡 Scoped" : "⚫️ Scoped",
596
+ callback_data: "model:scope-enable",
597
+ },
598
+ {
599
+ text: scoped ? "⚫️ All" : "🟣 All",
600
+ callback_data: "model:scope-disable",
601
+ },
602
+ ],
603
+ ],
604
+ };
605
+ }
606
+ export function buildModelDetailMenuText(state) {
607
+ const selection = getTelegramSelectedDetailModel(state);
608
+ if (selection.kind !== "selected")
609
+ return MODEL_DETAIL_MENU_TITLE;
610
+ const model = selection.selection.model;
611
+ return `${MODEL_DETAIL_MENU_TITLE}\n${getCanonicalModelId(model)}`;
612
+ }
613
+ export function buildModelPageMenuReplyMarkup(state, pageSize) {
614
+ const menuPage = getTelegramModelMenuPage(state, pageSize);
615
+ const rows = [[{ text: "⬆️ Back", callback_data: "model:pages:back" }]];
616
+ for (let page = 0; page < menuPage.pageCount; page += TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE) {
617
+ rows.push(Array.from({
618
+ length: Math.min(TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE, menuPage.pageCount - page),
619
+ }, (_unused, offset) => {
620
+ const pageIndex = page + offset;
621
+ return {
622
+ text: pageIndex === menuPage.page
623
+ ? `🟣 ${pageIndex + 1}`
624
+ : String(pageIndex + 1),
625
+ callback_data: `model:page:${pageIndex}`,
626
+ };
627
+ }));
628
+ }
629
+ return { inline_keyboard: rows };
630
+ }
631
+ export function buildTelegramModelPageMenuRenderPayload(state) {
632
+ return {
633
+ nextMode: "model-pages",
634
+ text: MODEL_PAGE_MENU_TITLE,
635
+ mode: "html",
636
+ replyMarkup: buildModelPageMenuReplyMarkup(state, TELEGRAM_MODEL_PAGE_SIZE),
637
+ };
638
+ }
639
+ export function buildTelegramModelMenuRenderPayload(state, activeModel) {
640
+ if (state.mode === "model-pages") {
641
+ return buildTelegramModelPageMenuRenderPayload(state);
642
+ }
643
+ if (state.mode === "model-detail") {
644
+ return {
645
+ nextMode: "model-detail",
646
+ text: buildModelDetailMenuText(state),
647
+ mode: "html",
648
+ replyMarkup: buildModelDetailMenuReplyMarkup(state, activeModel),
649
+ };
650
+ }
651
+ return {
652
+ nextMode: "model",
653
+ text: MODEL_MENU_TITLE,
654
+ mode: "html",
655
+ replyMarkup: buildModelMenuReplyMarkup(state, activeModel, TELEGRAM_MODEL_PAGE_SIZE),
656
+ };
657
+ }
658
+ export async function updateTelegramModelMenuMessage(state, activeModel, deps) {
659
+ await editTelegramMenuMessage(state, buildTelegramModelMenuRenderPayload(state, activeModel), deps);
660
+ }
661
+ export function sendTelegramModelMenuMessage(state, activeModel, deps) {
662
+ return sendTelegramMenuMessage(state, buildTelegramModelMenuRenderPayload(state, activeModel), deps);
663
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Telegram queue menu UI helpers
3
+ * Zones: telegram ui, queue controls, menu composition
4
+ * Owns queue-menu rendering, queue item callbacks, and queue-menu runtime adapters while core queue mechanics stay in queue
5
+ */
6
+ import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
7
+ import type { TelegramModelMenuState } from "./menu.ts";
8
+ import type { MenuModel } from "./model.ts";
9
+ import * as Queue from "./queue.ts";
10
+ type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
11
+ interface TelegramQueueMenuCallbackQuery {
12
+ id: string;
13
+ data?: string;
14
+ message?: {
15
+ chat?: {
16
+ id?: number;
17
+ };
18
+ message_id?: number;
19
+ };
20
+ }
21
+ interface TelegramQueueMenuRuntime<Context> {
22
+ openQueueMenu: (chatId: number, replyToMessageId: number, ctx: Context) => Promise<void>;
23
+ handleCallbackQuery: (query: TelegramQueueMenuCallbackQuery, ctx: Context) => Promise<boolean>;
24
+ }
25
+ export declare function createTelegramQueueMenuRuntime<Context, TModel extends MenuModel = MenuModel>(deps: {
26
+ telegramQueueStore: Queue.TelegramQueueStateStore<Context>;
27
+ queueMutationRuntime: Queue.TelegramQueueMutationController<Context>;
28
+ sendInteractiveMessage: (chatId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup) => Promise<number | undefined>;
29
+ editInteractiveMessage: (chatId: number, messageId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup) => Promise<void>;
30
+ answerCallbackQuery: (callbackQueryId: string, text?: string) => Promise<void>;
31
+ getModelMenuState: (chatId: number, ctx: Context) => Promise<TelegramModelMenuState<TModel>>;
32
+ getStoredModelMenuState: (messageId: number | undefined, chatId?: number) => TelegramModelMenuState<TModel> | undefined;
33
+ storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
34
+ updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: Context) => Promise<void>;
35
+ updateStatus: (ctx: Context) => void;
36
+ }): TelegramQueueMenuRuntime<Context>;
37
+ export {};