From ff277c0d492face410ae0b789dbe4259723fb207 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 16:56:21 +0000 Subject: [PATCH] fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_question cards failed to deliver on Telegram whenever any option had a non-trivial value (e.g. an ISO datetime, a URL, or a long token). Telegram limits inline-keyboard callback_data to 64 bytes, and the previous encoding embedded both the questionId and the full option value in each button's actionId plus a second copy as value, producing payloads well over the cap. The adapter threw ValidationError, delivery was marked permanently failed, and the agent sat waiting on an answer that never reached the user. Fix: - Button id is now `ncq::` and button value is the stringified index. Callback payloads shrink from ~100 bytes to ~40 and fit Telegram's cap for any option list with <100 items. - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/ etc., and the Discord Gateway interaction handler) resolve the index back to the real option value via `getAskQuestionRender(questionId)` before dispatching to the host's onAction — so response handlers (pending_questions, pending_approvals) are unchanged and still receive the canonical value. - `resolveSelectedOption` helper has a backward-compat fallback: non-numeric tails are treated as literal values so any card delivered under the old encoding still resolves if the user clicks it after deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..7123c0f 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig { * chunk boundary will render as two independent blocks on the receiving * platform, which is the same behavior as manually re-opening a fence. */ +/** + * Decode the actual option value from a button callback. Buttons are encoded + * with an integer index (to keep under Telegram's 64-byte callback_data cap), + * and the real value is looked up via `getAskQuestionRender(questionId)`. + * Falls back to treating the tail as a literal value so old in-flight cards + * (encoded before this shortening landed) still resolve. + */ +function resolveSelectedOption( + render: { options: NormalizedOption[] } | undefined, + eventValue: string | undefined, + tail: string | undefined, +): string { + const candidate = eventValue ?? tail ?? ''; + if (render && /^\d+$/.test(candidate)) { + const idx = Number(candidate); + if (render.options[idx]) return render.options[idx].value; + } + return candidate; +} + export function splitForLimit(text: string, limit: number): string[] { if (text.length <= limit) return [text]; const chunks: string[] = []; @@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const parts = event.actionId.split(':'); if (parts.length < 3) return; const questionId = parts[1]; - const selectedOption = event.value || ''; + const tail = parts.slice(2).join(':'); const userId = event.user?.userId || ''; // Resolve render metadata BEFORE dispatching onAction (which deletes the row). const render = getAskQuestionRender(questionId); + // New format: button id/value is an integer index into options (kept + // short to fit Telegram's 64-byte callback_data cap). Old format: + // the full value is embedded in actionId/value directly. + const selectedOption = resolveSelectedOption(render, event.value, tail); const title = render?.title ?? '❓ Question'; const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; @@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter children: [ CardText(question), Actions( - options.map((opt) => - Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), + // Encode button id/value with the option index rather than the + // full value. Telegram caps callback_data at 64 bytes, and + // long values (e.g. ISO datetimes, URLs) push the JSON payload + // well past that. The onAction handlers resolve the index back + // to the real value via getAskQuestionRender(questionId). + options.map((opt, idx) => + Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }), ), ), ], @@ -507,12 +536,12 @@ async function handleForwardedEvent( // Parse the selected option from custom_id let questionId: string | undefined; - let selectedOption: string | undefined; + let tail: string | undefined; if (customId?.startsWith('ncq:')) { const colonIdx = customId.indexOf(':', 4); // after "ncq:" if (colonIdx !== -1) { questionId = customId.slice(4, colonIdx); - selectedOption = customId.slice(colonIdx + 1); + tail = customId.slice(colonIdx + 1); } } @@ -521,6 +550,9 @@ async function handleForwardedEvent( ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; const render = questionId ? getAskQuestionRender(questionId) : undefined; + // Discord custom_id mirrors the new index-based encoding (see Button + // construction). Decode back to the real option value for downstream. + const selectedOption = resolveSelectedOption(render, tail, tail); const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); const matchedOpt = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;