WritingAI
A tapped button answers the message it sits on, not the current one
Carry the prompt id and entity id in every button payload and reject a tap on a closed prompt, so a stale Yes never cancels the wrong order.
A customer on Messenger asks the bot to cancel order 4471. The bot replies "Cancel order 4471 for 38.50 GBP?" with two buttons, Yes and No. Before tapping either, the customer types "how much was 4472?". The bot looks that order up, answers "Order 4472 was 12.00 GBP", and conversation state now holds 4472 as the current order. The customer scrolls up and taps Yes on the cancel message. The channel delivers a postback whose payload reads CANCEL_YES, the handler applies it to the current order, and 4472 is cancelled. The customer wanted 4471 gone and now has the wrong order cancelled and the right one still open.
A button tap is an answer to the message the button sits on, not to whatever the conversation is asking now. Every channel that renders buttons sends back a payload string you chose when you sent the message, and that string is the only link between the tap and its question. A payload that is a bare verb throws the link away, so the handler has to guess, and it guesses from conversation state that may have moved on three messages ago.
Put the question in the payload
The payload must name the prompt and the entity, not the verb alone. When the bot sends a question with buttons, it first writes a prompt row: id, conversation_id, kind (such as cancel_confirm), entity_id, state (open or closed), sent_at and, once the channel returns one, the channel's message id. The payload then reads cancel:ord_4471:pr_8812. Some channels cap the payload length, so use short ids rather than embedding JSON. The handler parses the payload, loads prompt pr_8812, and acts on ord_4471 from the payload, never on the order held in conversation state.
// simplified: a button tap is applied only to the prompt it was sent with
type Prompt = { id: string; entityId: string; state: 'open' | 'closed';
closedReason?: 'answered' | 'superseded' | 'expired'; result?: string };
async function onButtonTap(convId: string, payload: string) {
const [verb, entityId, promptId] = payload.split(':');
const prompt = await prompts.get(promptId);
if (!prompt || prompt.entityId !== entityId) return reply(convId, UNKNOWN_TAP);
if (prompt.state === 'closed') {
// name the question the tap belonged to, then ask the current one again
return reply(convId, staleTapMessage(prompt), await currentPrompt(convId));
}
await db.transaction(async (tx) => {
const result = await runAction(tx, verb, entityId);
await tx.prompts.close(promptId, 'answered', result);
});
}A prompt closes in three ways. It is answered, and the close happens in the same transaction as the action's effect so a redelivered tap finds it already closed. It is superseded, because the bot handled any later message in the conversation: the moment "how much was 4472?" is processed, pr_8812 closes with reason superseded, before the answer is even sent. Or it expires on a timer, say 24 hours, because a Yes tapped a day later on "charge your saved card 38.50 GBP?" should not charge anything. A partial unique index on conversation_id where state = 'open' keeps one question open per conversation and makes the supersede rule hard to skip.
flowchart TD
T[Button tap arrives] --> P[Parse prompt id and entity]
P --> L[Load prompt row]
L --> Q{Prompt still open}:::accent
Q -->|yes| A[Run action on payload entity]
A --> C[Close prompt as answered]
Q -->|no| S[Name the question it answered]
S --> R[Send current question again]What the customer sees when the tap is stale
Ignoring a stale tap is the wrong fix. The customer tapped a button and expects something to happen; silence reads as a broken bot, and the next thing they do is tap it again. The reply has to name the question the tap belonged to and then ask it afresh, or ask the live one if there is a newer open prompt: "That Yes was for cancelling order 4471, which I had stopped asking about. Do you still want to cancel 4471 for 38.50 GBP?" with fresh buttons carrying a new prompt id. When the prompt closed because it was answered, the reply comes from the stored result instead: "Order 4471 is already cancelled", which also makes a tap the channel delivers twice harmless without a separate dedupe path for taps.
I have led an omni-channel chatbot on Messenger, WhatsApp, Telegram and web chat, and the rule I hold to is that a tap handler never reads the entity from conversation state. The web chat you build yourself is where this rule is most often broken, because its buttons can call any function directly. Give it the same payload contract as the other channels, so one handler serves all four and the stale tap behaves the same everywhere.
Add the prompt table first and put its id and the entity id in every button payload you send. Then change the tap handler to reject a closed prompt with a reply that names the question and re-asks the current one. Only after that is it worth tuning the expiry timer per prompt kind.