polygram 0.12.0-rc.8 → 0.12.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.
- package/config.example.json +4 -3
- package/lib/claude-bin.js +14 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/db.js +1 -1
- package/lib/error/classify.js +33 -10
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/abort.js +87 -40
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/config-callback.js +25 -6
- package/lib/handlers/config-ui.js +39 -10
- package/lib/handlers/dispatcher.js +83 -0
- package/lib/handlers/download.js +101 -58
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +136 -0
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/questions.js +289 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/handlers/slash-commands.js +43 -30
- package/lib/history-preload.js +6 -0
- package/lib/history.js +7 -1
- package/lib/model-costs.js +4 -0
- package/lib/process/channels-bridge-protocol.js +22 -1
- package/lib/process/channels-bridge.mjs +128 -7
- package/lib/process/channels-tool-dispatcher.js +105 -12
- package/lib/process/cli-process.js +1277 -70
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process/process.js +22 -0
- package/lib/process-guard.js +57 -1
- package/lib/process-manager.js +120 -35
- package/lib/questions/questions.js +187 -0
- package/lib/questions/store.js +105 -0
- package/lib/rewind/execute.js +89 -0
- package/lib/rewind/fork.js +112 -0
- package/lib/rewind/rewind.js +174 -0
- package/lib/sdk/callbacks.js +165 -167
- package/lib/session-key.js +29 -0
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/parse.js +9 -2
- package/lib/telegram/typing.js +17 -2
- package/lib/tmux/startup-gate.js +44 -14
- package/migrations/012-pending-questions.sql +30 -0
- package/package.json +1 -1
- package/polygram.js +224 -78
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gateInbound — the ONE intake gate (0.13 D5, docs/0.13-channels-lifecycle-design.md §3 D4+D5).
|
|
5
|
+
*
|
|
6
|
+
* Pre-0.13, polygram had four gate depths (seam S11): the fresh-message chain in
|
|
7
|
+
* dispatchRegularMessage, edit-redelivery's bare shouldHandle, the mid-turn edit
|
|
8
|
+
* injector's none, and boot-replay's none. The divergences were themselves bugs:
|
|
9
|
+
* an edit to "/stop" was injected into the very turn it tried to kill; an edit
|
|
10
|
+
* during a free-text "Other" capture never became the answer; any group member's
|
|
11
|
+
* bare "stop" aborted others' turns pre-gate.
|
|
12
|
+
*
|
|
13
|
+
* Every entry point now runs the same ordered chain, with a tier flag declaring
|
|
14
|
+
* per stage whether it EVALUATES, EXECUTES side effects, or is SKIPPED:
|
|
15
|
+
*
|
|
16
|
+
* stage | fresh | edit | redelivery
|
|
17
|
+
* -----------------|------------------|------------------|--------------------------
|
|
18
|
+
* abort | eval + execute* | eval + execute* | eval, never exec → blocked
|
|
19
|
+
* admin / pair | eval + dispatch | eval + dispatch | eval, never exec → blocked
|
|
20
|
+
* rewind | eval + execute | skip | skip
|
|
21
|
+
* question-consume | eval + execute | eval + execute | skip (already consumed once)
|
|
22
|
+
* shouldHandle | evaluate | evaluate | evaluate
|
|
23
|
+
* final | dispatch | return 'pass' | return 'pass'
|
|
24
|
+
*
|
|
25
|
+
* * identity-gated: DM ‖ paired ‖ @mention ‖ reply-to-bot. Closes the
|
|
26
|
+
* pre-existing bystander-abort hole (abort ran before shouldHandle with
|
|
27
|
+
* zero identity checks — any group member's "stop" killed the in-flight
|
|
28
|
+
* turn) BEFORE the edit tier gains abort semantics.
|
|
29
|
+
*
|
|
30
|
+
* Return shape: { action: 'dispatched'|'handled'|'blocked'|'pass', stage?, reason? }
|
|
31
|
+
* dispatched — handed to dispatchHandleMessage (fresh final, admin stage)
|
|
32
|
+
* handled — a stage consumed it (abort executed, question answered, rewind)
|
|
33
|
+
* blocked — gate dropped it (caller logs; redelivery callers emit no-redeliver)
|
|
34
|
+
* pass — edit/redelivery tiers: caller owns the next step
|
|
35
|
+
*
|
|
36
|
+
* Late-bound deps are getters (botUsername/mentionRe are assigned after bot
|
|
37
|
+
* init; rewind/question handlers are wired late in main()) — the established
|
|
38
|
+
* `let x = null; wired in main()` pattern, made explicit.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
|
|
42
|
+
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
43
|
+
|
|
44
|
+
function createGateInbound({
|
|
45
|
+
config,
|
|
46
|
+
getBotUsername,
|
|
47
|
+
getMentionRe,
|
|
48
|
+
pairings = null,
|
|
49
|
+
isAbortRequest,
|
|
50
|
+
handleAbortIfRequested,
|
|
51
|
+
getRewindHandler = () => null,
|
|
52
|
+
isRewindCommand = () => false,
|
|
53
|
+
getQuestionHandlers = () => null,
|
|
54
|
+
shouldHandle,
|
|
55
|
+
getSessionKey,
|
|
56
|
+
dispatchHandleMessage,
|
|
57
|
+
bot,
|
|
58
|
+
botName,
|
|
59
|
+
logEvent = () => {},
|
|
60
|
+
logger = console,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (typeof shouldHandle !== 'function') throw new TypeError('gateInbound: shouldHandle required');
|
|
63
|
+
if (typeof dispatchHandleMessage !== 'function') throw new TypeError('gateInbound: dispatchHandleMessage required');
|
|
64
|
+
if (typeof getSessionKey !== 'function') throw new TypeError('gateInbound: getSessionKey required');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The abort identity gate: is this sender plausibly addressing the BOT
|
|
68
|
+
* (vs a teammate)? DM ‖ paired ‖ @mention ‖ reply-to-bot. Mirrors the
|
|
69
|
+
* shouldHandle signals but is evaluated BEFORE shouldHandle because abort
|
|
70
|
+
* (deliberately) outranks the mention gate for addressed senders.
|
|
71
|
+
*/
|
|
72
|
+
function isAddressedIdentity(msg, chatId) {
|
|
73
|
+
if (msg.chat?.type === 'private') return true;
|
|
74
|
+
const botUsername = getBotUsername?.() || '';
|
|
75
|
+
const text = msg.text || msg.caption || '';
|
|
76
|
+
if (botUsername && text.includes(`@${botUsername}`)) return true;
|
|
77
|
+
if (botUsername && msg.reply_to_message?.from?.username === botUsername) return true;
|
|
78
|
+
if (pairings && msg.from?.id
|
|
79
|
+
&& pairings.hasLivePairing({ bot_name: botName, user_id: msg.from.id, chat_id: chatId })) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return async function gateInbound(msg, { tier = 'fresh' } = {}) {
|
|
86
|
+
const chatId = msg.chat.id.toString();
|
|
87
|
+
const chatConfig = config.chats[chatId];
|
|
88
|
+
if (!chatConfig) return { action: 'blocked', stage: 'chat', reason: 'unconfigured chat' };
|
|
89
|
+
|
|
90
|
+
const mentionRe = getMentionRe?.();
|
|
91
|
+
const rawText = msg.text || '';
|
|
92
|
+
const cleanText = mentionRe ? rawText.replace(mentionRe, '').trim() : rawText.trim();
|
|
93
|
+
const threadId = msg.message_thread_id?.toString();
|
|
94
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
95
|
+
|
|
96
|
+
// ── abort ────────────────────────────────────────────────────────────
|
|
97
|
+
if (typeof isAbortRequest === 'function' && isAbortRequest(cleanText)) {
|
|
98
|
+
if (tier === 'redelivery') {
|
|
99
|
+
// An auto-redelivered abort would execute in a context the user never
|
|
100
|
+
// intended (their original "stop" targeted work long since settled).
|
|
101
|
+
return { action: 'blocked', stage: 'abort', reason: 'abort-shaped content is never auto-re-executed' };
|
|
102
|
+
}
|
|
103
|
+
if (!isAddressedIdentity(msg, chatId)) {
|
|
104
|
+
logEvent('abort-identity-blocked', {
|
|
105
|
+
chat_id: chatId, msg_id: msg.message_id, user_id: msg.from?.id ?? null, tier,
|
|
106
|
+
});
|
|
107
|
+
return { action: 'blocked', stage: 'abort', reason: 'abort from unaddressed sender' };
|
|
108
|
+
}
|
|
109
|
+
const handled = await handleAbortIfRequested(msg, chatId, chatConfig, cleanText);
|
|
110
|
+
if (handled) return { action: 'handled', stage: 'abort' };
|
|
111
|
+
// The predicate matched but the handler declined (defensive) — fall through.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── admin command / pair claim ───────────────────────────────────────
|
|
115
|
+
const botAllowsCommands = !!config.bot?.allowConfigCommands;
|
|
116
|
+
const isAdminCmd = botAllowsCommands && ADMIN_CMD_RE.test(cleanText);
|
|
117
|
+
const isPairClaim = PAIR_CLAIM_RE.test(cleanText);
|
|
118
|
+
if (isAdminCmd || isPairClaim) {
|
|
119
|
+
if (tier === 'redelivery') {
|
|
120
|
+
return { action: 'blocked', stage: 'admin', reason: 'admin/pair-shaped content is never auto-re-executed' };
|
|
121
|
+
}
|
|
122
|
+
msg.text = cleanText;
|
|
123
|
+
// 0.13 D5: through the dispatcher wrapper — the admin path gains
|
|
124
|
+
// handler-error events, the in-flight counter, and terminal
|
|
125
|
+
// handler_status on throw (pre-P2 it called bare handleMessage and
|
|
126
|
+
// errors bubbled to grammy's bot.catch with the row left 'dispatched').
|
|
127
|
+
dispatchHandleMessage(sessionKey, chatId, msg, bot);
|
|
128
|
+
return { action: 'dispatched', stage: 'admin' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── /rewind ── fresh only (an edited or replayed /rewind is nonsensical) ──
|
|
132
|
+
if (tier === 'fresh') {
|
|
133
|
+
const rewindHandler = getRewindHandler?.();
|
|
134
|
+
if (rewindHandler && isRewindCommand(cleanText)) {
|
|
135
|
+
try {
|
|
136
|
+
// Operator identity: explicit operatorUserId, else the admin user — a PRIVATE
|
|
137
|
+
// adminChatId equals that user's Telegram id. A group adminChatId (negative) is
|
|
138
|
+
// not a user id → never matches a positive sender id → default-deny.
|
|
139
|
+
const opId = config.bot?.operatorUserId;
|
|
140
|
+
const adminChatId = config.bot?.adminChatId;
|
|
141
|
+
const operatorUid = opId != null ? Number(opId) : (adminChatId != null ? Number(adminChatId) : null);
|
|
142
|
+
const isOperatorIdentity = operatorUid != null && msg.from?.id != null && Number(msg.from.id) === operatorUid;
|
|
143
|
+
const paired = pairings && msg.from?.id
|
|
144
|
+
? pairings.hasLivePairing({ bot_name: botName, user_id: msg.from.id, chat_id: chatId })
|
|
145
|
+
: false;
|
|
146
|
+
const accessMode = chatConfig?.rewindAccess === 'paired' ? 'paired' : 'operator';
|
|
147
|
+
const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
|
|
148
|
+
const r = await rewindHandler.tryConsume({
|
|
149
|
+
sessionKey, chatId, threadId, msg, cleanText,
|
|
150
|
+
botUsername: getBotUsername?.() || '',
|
|
151
|
+
rewindSafe, isOperatorIdentity, paired, accessMode,
|
|
152
|
+
});
|
|
153
|
+
if (r.consumed) return { action: 'handled', stage: 'rewind' };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// The text IS a recognized /rewind — on an internal error, consume it
|
|
156
|
+
// anyway; falling through would send "/rewind" to claude as a prompt.
|
|
157
|
+
logger.error?.(`[${botName}] rewind tryConsume failed: ${err?.message || err}`);
|
|
158
|
+
return { action: 'handled', stage: 'rewind', reason: 'consumed-on-error' };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── question-consume / ownsOpenOther ── fresh + edit; redelivery skips
|
|
164
|
+
// (a replayed row already had its question-capture moment when fresh)
|
|
165
|
+
const questionHandlers = tier !== 'redelivery' ? getQuestionHandlers?.() : null;
|
|
166
|
+
const ownsOpenOther = questionHandlers
|
|
167
|
+
? questionHandlers.isAwaitingOtherFrom(sessionKey, msg.from?.id)
|
|
168
|
+
: false;
|
|
169
|
+
|
|
170
|
+
// ── shouldHandle (mention/pairing gate) ── all tiers
|
|
171
|
+
if (!ownsOpenOther && !shouldHandle(msg, chatConfig, getBotUsername?.() || '')) {
|
|
172
|
+
return { action: 'blocked', stage: 'shouldHandle' };
|
|
173
|
+
}
|
|
174
|
+
if (getBotUsername?.()) msg.text = cleanText;
|
|
175
|
+
|
|
176
|
+
if (questionHandlers) {
|
|
177
|
+
const r = await questionHandlers.tryConsumeAsAnswer({ sessionKey, fromId: msg.from?.id, text: cleanText });
|
|
178
|
+
if (r.consumed) return { action: 'handled', stage: 'question-consume' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── final ──
|
|
182
|
+
if (tier !== 'fresh') return { action: 'pass', sessionKey, chatId, cleanText };
|
|
183
|
+
dispatchHandleMessage(sessionKey, chatId, msg, bot);
|
|
184
|
+
return { action: 'dispatched' };
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-question handlers (0.12 ask feature) — the integration glue between
|
|
3
|
+
* the pure state machine (lib/questions/questions.js), the store
|
|
4
|
+
* (lib/questions/store.js), Telegram, and the bridge answer write-back.
|
|
5
|
+
*
|
|
6
|
+
* renderAsk — claude called `ask`: issue a row, send the keyboard.
|
|
7
|
+
* handleQuestionCallback — a `q:` button tap: validate, mutate, advance/resolve.
|
|
8
|
+
* tryConsumeAsAnswer — dispatcher hook: a typed message while a question is
|
|
9
|
+
* open (free-text "Other", or a nudge for the wrong user).
|
|
10
|
+
* expireQuestion — timeout sweep / cancel: answer {timedout}/{cancelled}, strip.
|
|
11
|
+
*
|
|
12
|
+
* Security (review): per-row 128-bit token in callback_data + claim-on-first-tap
|
|
13
|
+
* respondent authz + plain-text body (no parse_mode) for agent content.
|
|
14
|
+
*
|
|
15
|
+
* Anti-hang invariant (review-hardened): claude's `ask` tool call must be answered
|
|
16
|
+
* EXACTLY once and never hang. Therefore every terminal path hands the result to
|
|
17
|
+
* claude *first* (guarded) and only then marks the row terminal — `finalize()`. If
|
|
18
|
+
* the write-back THROWS, the row is LEFT pending so the timeout sweep can recover
|
|
19
|
+
* with {timedout} (never resolved-but-hung). If the write-back is an undelivered
|
|
20
|
+
* NO-OP (returns false — session gone / no live bridge), it is surfaced loudly and
|
|
21
|
+
* the row is still resolved (a dead session can't be delivered; the bridge's own
|
|
22
|
+
* 20-min ceiling backstops the rare live-proc case). A failed Telegram send is also
|
|
23
|
+
* hang-safe: we answer {cancelled} rather than leaving a pending row with no
|
|
24
|
+
* on-screen keyboard. renderAsk that throws BEFORE issuing a row answers {cancelled}
|
|
25
|
+
* itself (no row exists for the sweep to find); tryConsumeAsAnswer never throws out
|
|
26
|
+
* of the dispatcher (a store error degrades to "not an answer", never a dropped msg).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const Q = require('../questions/questions');
|
|
32
|
+
const { tokensEqual } = require('../questions/store');
|
|
33
|
+
|
|
34
|
+
function createQuestionHandlers({
|
|
35
|
+
questions, // store (lib/questions/store.js)
|
|
36
|
+
tg,
|
|
37
|
+
bot,
|
|
38
|
+
botName,
|
|
39
|
+
logEvent = () => {},
|
|
40
|
+
answerQuestion, // (sessionKey, toolCallId, result) → write question_answer to the bridge
|
|
41
|
+
logger = console,
|
|
42
|
+
} = {}) {
|
|
43
|
+
|
|
44
|
+
function strip(chatId, msgId, threadId, text) {
|
|
45
|
+
if (msgId == null) return Promise.resolve();
|
|
46
|
+
return tg(bot, 'editMessageText', {
|
|
47
|
+
chat_id: chatId, message_id: msgId, text,
|
|
48
|
+
...(threadId && { message_thread_id: threadId }),
|
|
49
|
+
}, { source: 'question-edit', botName })
|
|
50
|
+
.catch((e) => logger.error?.(`[${botName}] question strip failed: ${e.message}`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function sendCurrent(row, state) {
|
|
54
|
+
const view = Q.renderCurrent(state, `q:${row.id}:${row.callback_token}`);
|
|
55
|
+
if (!view) return null;
|
|
56
|
+
// PLAIN-TEXT (no parse_mode): option labels/descriptions are agent-authored.
|
|
57
|
+
const sent = await tg(bot, 'sendMessage', {
|
|
58
|
+
chat_id: row.chat_id, text: view.text, reply_markup: view.reply_markup,
|
|
59
|
+
...(row.thread_id && { message_thread_id: row.thread_id }),
|
|
60
|
+
}, { source: 'question', botName }).catch((e) => {
|
|
61
|
+
logger.error?.(`[${botName}] question send failed: ${e.message}`);
|
|
62
|
+
return null;
|
|
63
|
+
});
|
|
64
|
+
return sent?.message_id ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hand the result to claude FIRST (guarded), then mark the row terminal. On
|
|
69
|
+
* write-back failure: leave the row pending (the timeout sweep recovers it) and
|
|
70
|
+
* return false — NEVER resolved-but-hung. Returns true when claude was answered.
|
|
71
|
+
*/
|
|
72
|
+
function finalize(row, result, status = 'answered') {
|
|
73
|
+
let delivered;
|
|
74
|
+
try {
|
|
75
|
+
delivered = answerQuestion?.(row.session_key, row.tool_call_id, result);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
logger.error?.(`[${botName}] answerQuestion failed for ${row.tool_call_id}: ${e.message}`);
|
|
78
|
+
return false; // threw → leave the row pending; the timeout sweep recovers it
|
|
79
|
+
}
|
|
80
|
+
// A *false* return is a silent no-op (session gone / no live bridge), NOT a
|
|
81
|
+
// throw. Surface it loudly and still resolve: a dead session can never be
|
|
82
|
+
// delivered, so leaving it pending would have the 30s sweep re-strip +
|
|
83
|
+
// re-answer it forever. The rare live-proc-but-unwritable-bridge case is
|
|
84
|
+
// backstopped by the bridge's own 20-min answer ceiling.
|
|
85
|
+
if (delivered === false) {
|
|
86
|
+
logger.error?.(`[${botName}] answerQuestion undelivered (session gone / no bridge) for ${row.tool_call_id}`);
|
|
87
|
+
logEvent('question-answer-undelivered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
|
|
88
|
+
}
|
|
89
|
+
questions.resolve(row.id, status);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── claude called ask → render the first question ──────────────────
|
|
94
|
+
async function renderAsk({ sessionKey, chatId, threadId = null, turnId = null, toolCallId, questions: qs }) {
|
|
95
|
+
let row = null;
|
|
96
|
+
try {
|
|
97
|
+
// Idempotency: a bridge retry of the same tool_call_id must not double-render.
|
|
98
|
+
if (questions.getByToolCallId(toolCallId)) return null;
|
|
99
|
+
|
|
100
|
+
if (!Array.isArray(qs) || qs.length === 0) {
|
|
101
|
+
try { answerQuestion?.(sessionKey, toolCallId, { answers: [] }); } catch (e) {
|
|
102
|
+
logger.error?.(`[${botName}] answerQuestion(empty) failed: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// One open question per session: cancel + unblock any prior open ask first.
|
|
107
|
+
const prior = questions.getOpenForSession(sessionKey);
|
|
108
|
+
if (prior) {
|
|
109
|
+
finalize(prior, { cancelled: true }, 'cancelled');
|
|
110
|
+
const pIds = JSON.parse(prior.message_ids_json || '[]');
|
|
111
|
+
if (pIds[0]) strip(prior.chat_id, pIds[0], prior.thread_id, 'This question was replaced.');
|
|
112
|
+
}
|
|
113
|
+
const state = Q.initState(qs);
|
|
114
|
+
row = questions.issue({
|
|
115
|
+
bot_name: botName, session_key: sessionKey, chat_id: chatId, thread_id: threadId,
|
|
116
|
+
turn_id: turnId, tool_call_id: toolCallId, questions: qs, state,
|
|
117
|
+
});
|
|
118
|
+
const msgId = await sendCurrent(row, state);
|
|
119
|
+
if (msgId == null) {
|
|
120
|
+
// Couldn't deliver the keyboard — don't strand claude on a pending row.
|
|
121
|
+
finalize(row, { cancelled: true, error: 'failed to deliver the question' }, 'cancelled');
|
|
122
|
+
logEvent('question-send-failed', { session_key: sessionKey, tool_call_id: toolCallId, phase: 'first' });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
questions.setMessageIds(row.id, [msgId]);
|
|
126
|
+
logEvent('question-asked', { session_key: sessionKey, chat_id: chatId, tool_call_id: toolCallId, count: qs.length });
|
|
127
|
+
return row;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
logger.error?.(`[${botName}] renderAsk failed for ${toolCallId}: ${e.message}`);
|
|
130
|
+
// Anti-hang: a throw BEFORE the row was issued (store error, etc.) leaves
|
|
131
|
+
// claude blocked with no row for the sweep to recover → answer {cancelled}
|
|
132
|
+
// now. If the row WAS issued (throw in a later step), it is pending and the
|
|
133
|
+
// timeout sweep will recover it with {timedout}.
|
|
134
|
+
if (!row) {
|
|
135
|
+
try { answerQuestion?.(sessionKey, toolCallId, { cancelled: true, error: 'failed to render question' }); } catch (e2) {
|
|
136
|
+
logger.error?.(`[${botName}] renderAsk fallback answer failed for ${toolCallId}: ${e2.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── a `q:<id>:<token>:<action>` button tap ─────────────────────────
|
|
144
|
+
async function handleQuestionCallback(ctx) {
|
|
145
|
+
const data = ctx.callbackQuery?.data || '';
|
|
146
|
+
const m = String(data).match(/^q:(\d+):([^:]+):(.+)$/);
|
|
147
|
+
if (!m) return;
|
|
148
|
+
const id = parseInt(m[1], 10);
|
|
149
|
+
const token = m[2];
|
|
150
|
+
const actionStr = m[3];
|
|
151
|
+
|
|
152
|
+
const row = questions.getById(id);
|
|
153
|
+
if (!row) { await ack(ctx, 'This question expired.', true); return; }
|
|
154
|
+
if (!tokensEqual(row.callback_token, token)) {
|
|
155
|
+
logEvent('question-token-mismatch', { id, from_user: ctx.from?.id });
|
|
156
|
+
await ack(ctx, 'Bad token.', true); return;
|
|
157
|
+
}
|
|
158
|
+
if (row.status !== 'pending') { await ack(ctx, `Already ${row.status}.`, true); return; }
|
|
159
|
+
|
|
160
|
+
// Respondent authorization: first tapper claims the question; others rejected.
|
|
161
|
+
const auth = questions.claimOrCheck(id, ctx.from?.id);
|
|
162
|
+
if (!auth.ok) {
|
|
163
|
+
logEvent('question-foreign-responder', { id, from_user: ctx.from?.id, owner: row.from_id });
|
|
164
|
+
await ack(ctx, 'This question is for someone else.', true); return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const state = JSON.parse(row.state_json);
|
|
168
|
+
const res = Q.applyTap(state, Q.parseAction(actionStr));
|
|
169
|
+
const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
|
|
170
|
+
|
|
171
|
+
if (res.kind === 'reject') { await ack(ctx, res.message, true); return; }
|
|
172
|
+
|
|
173
|
+
if (res.kind === 'toggled') {
|
|
174
|
+
questions.updateState(id, res.state, false);
|
|
175
|
+
const view = Q.renderCurrent(res.state, `q:${id}:${token}`);
|
|
176
|
+
await tg(bot, 'editMessageReplyMarkup', {
|
|
177
|
+
chat_id: row.chat_id, message_id: msgId, reply_markup: view.reply_markup,
|
|
178
|
+
...(row.thread_id && { message_thread_id: row.thread_id }),
|
|
179
|
+
}, { source: 'question-edit', botName })
|
|
180
|
+
.catch((e) => logger.error?.(`[${botName}] toggle re-render failed (q ${id}): ${e.message}`));
|
|
181
|
+
await ack(ctx);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (res.kind === 'awaiting-other') {
|
|
186
|
+
questions.updateState(id, res.state, true);
|
|
187
|
+
await strip(row.chat_id, msgId, row.thread_id, 'Send your answer as a message.');
|
|
188
|
+
await ack(ctx, 'Type your answer ↓');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// advanced — record + receipt, then next question or finish.
|
|
193
|
+
await advance(ctx, row, res, false);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── a typed message while a question is open (Other / nudge) ────────
|
|
197
|
+
async function tryConsumeAsAnswer({ sessionKey, fromId, text }) {
|
|
198
|
+
try {
|
|
199
|
+
const row = questions.getOpenForSession(sessionKey);
|
|
200
|
+
if (!row) return { consumed: false };
|
|
201
|
+
// Only an in-progress free-text capture diverts typed messages. A question
|
|
202
|
+
// awaiting a BUTTON tap does not swallow ordinary chatter (review: do not eat
|
|
203
|
+
// every group member's message for the whole question lifetime).
|
|
204
|
+
if (!row.awaiting_other) return { consumed: false };
|
|
205
|
+
// /stop, /new and other commands are never consumed as a free-text answer.
|
|
206
|
+
if (/^\/(stop|new|reset|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
|
|
207
|
+
// Identity: only the claimed owner supplies the free-text answer.
|
|
208
|
+
const auth = questions.claimOrCheck(row.id, fromId);
|
|
209
|
+
if (!auth.ok) {
|
|
210
|
+
tg(bot, 'sendMessage', { chat_id: row.chat_id, text: 'Please answer the open question above first.',
|
|
211
|
+
...(row.thread_id && { message_thread_id: row.thread_id }) }, { source: 'question-nudge', botName })
|
|
212
|
+
.catch((e) => logger.error?.(`[${botName}] question nudge failed: ${e.message}`));
|
|
213
|
+
return { consumed: true };
|
|
214
|
+
}
|
|
215
|
+
const state = JSON.parse(row.state_json);
|
|
216
|
+
const res = Q.applyFreeText(state, text);
|
|
217
|
+
if (res.kind !== 'advanced') return { consumed: false };
|
|
218
|
+
await advance({ from: { id: fromId } }, row, res, true);
|
|
219
|
+
return { consumed: true };
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// Never throw out of the message dispatcher: a store/parse error here must
|
|
222
|
+
// degrade to "not an answer" so the user's message still reaches normal
|
|
223
|
+
// dispatch instead of being silently dropped.
|
|
224
|
+
logger.error?.(`[${botName}] tryConsumeAsAnswer failed: ${e.message}`);
|
|
225
|
+
return { consumed: false };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Does `fromId` own an open free-text ("Other") capture for this session? The
|
|
230
|
+
// dispatcher uses this to let the owner's typed answer bypass a group's mention
|
|
231
|
+
// gate — only the claimed owner, never a bystander.
|
|
232
|
+
function isAwaitingOtherFrom(sessionKey, fromId) {
|
|
233
|
+
if (fromId == null) return false;
|
|
234
|
+
try {
|
|
235
|
+
const row = questions.getOpenForSession(sessionKey);
|
|
236
|
+
return !!(row && row.awaiting_other && row.from_id != null && Number(row.from_id) === Number(fromId));
|
|
237
|
+
} catch { return false; }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── timeout sweep / external cancel ────────────────────────────────
|
|
241
|
+
async function expireQuestion(row, { status = 'timeout', message = 'This question timed out.' } = {}) {
|
|
242
|
+
const ids = JSON.parse(row.message_ids_json || '[]');
|
|
243
|
+
if (ids[0]) await strip(row.chat_id, ids[0], row.thread_id, message);
|
|
244
|
+
const result = status === 'cancelled' ? { cancelled: true } : { timedout: true };
|
|
245
|
+
finalize(row, result, status);
|
|
246
|
+
logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// shared: record an advanced result, post a receipt, then next-Q or resolve.
|
|
250
|
+
async function advance(ctx, row, res, fromText) {
|
|
251
|
+
const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
|
|
252
|
+
// Visible receipt on the question card — for BOTH a button tap and a typed "Other"
|
|
253
|
+
// answer. The typed-answer path used to skip this entirely, so a free-text answer (esp.
|
|
254
|
+
// the LAST question) left the card frozen on "Send your answer as a message." with no
|
|
255
|
+
// acknowledgment — "I answered and nothing happened" (prod: hire topic 2026-06-09).
|
|
256
|
+
await strip(row.chat_id, msgId, row.thread_id, `✓ ${res.receipt}`);
|
|
257
|
+
if (!fromText) await ack(ctx, 'Recorded'); // callback-query ack — button taps only
|
|
258
|
+
|
|
259
|
+
if (res.done) {
|
|
260
|
+
questions.updateState(row.id, res.state, false);
|
|
261
|
+
// Answer claude FIRST (guarded), THEN mark answered. If the write-back
|
|
262
|
+
// throws, leave the row pending → the timeout sweep recovers it.
|
|
263
|
+
if (!finalize(row, Q.assemble(res.state), 'answered')) {
|
|
264
|
+
logEvent('question-answer-writeback-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
logEvent('question-answered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// next question → new message; on send failure, don't strand claude.
|
|
271
|
+
const nextMsgId = await sendCurrent(row, res.state);
|
|
272
|
+
if (nextMsgId == null) {
|
|
273
|
+
finalize(row, { cancelled: true, error: 'failed to deliver the next question' }, 'cancelled');
|
|
274
|
+
logEvent('question-send-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id, phase: 'next', q_index: res.state.qIndex });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
questions.updateState(row.id, res.state, false);
|
|
278
|
+
questions.setMessageIds(row.id, [nextMsgId]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ack(ctx, text, alert = false) {
|
|
282
|
+
if (!ctx || typeof ctx.answerCallbackQuery !== 'function') return Promise.resolve();
|
|
283
|
+
return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { renderAsk, handleQuestionCallback, tryConsumeAsAnswer, expireQuestion, isAwaitingOtherFrom };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = { createQuestionHandlers };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* redeliverAsFreshTurn — the ONE redelivery tail (0.13 D4,
|
|
5
|
+
* docs/0.13-channels-lifecycle-design.md §3 D4+D5).
|
|
6
|
+
*
|
|
7
|
+
* Pre-0.13, "re-dispatch a message as a fresh turn" existed in five shapes
|
|
8
|
+
* (seam S10) — boot-replay, edit-redelivery, startup-auto-retry, auto-resume,
|
|
9
|
+
* compact-replay — each with different gating, one-shot, ack, and tagging
|
|
10
|
+
* semantics. This module is the shared tail every re-dispatch converges on:
|
|
11
|
+
*
|
|
12
|
+
* 1. once-only per (chatId, msgId) for the daemon lifetime — duplicates are
|
|
13
|
+
* the double-answer failure mode, hard-capped here (FIFO-bounded set);
|
|
14
|
+
* 2. `_isReplay` tag — no new editable row, excluded from boot replay,
|
|
15
|
+
* error replies suppressed (the boot-replay contract, generalized);
|
|
16
|
+
* 3. gate at tier 'redelivery' (D5) — abort/admin-shaped content is
|
|
17
|
+
* EVALUATED but never auto-re-executed (logged `input-dropped-no-redeliver`);
|
|
18
|
+
* skippable for same-process retries whose object already passed the
|
|
19
|
+
* full fresh gate this boot (startup-auto-retry);
|
|
20
|
+
* 4. optional one-shot DB pre-mark (the boot-replay 'replay-attempted'
|
|
21
|
+
* pattern: even if THIS attempt dies mid-turn, the next boot won't loop);
|
|
22
|
+
* 5. ack reaction (👀) — a re-dispatch must never be silent (rc.33 lesson);
|
|
23
|
+
* callers may suppress for deliberately-silent retries;
|
|
24
|
+
* 6. dispatchHandleMessage — the normal turn path.
|
|
25
|
+
*
|
|
26
|
+
* Callers (P2): boot-replay. Callers (P3): the InputLedger drop-redeliverer.
|
|
27
|
+
* Deliberately NOT callers: edit-redelivery (edits are legitimately repeatable
|
|
28
|
+
* per message — this module's once-only is drop/replay semantics; edits share
|
|
29
|
+
* the D5 gate upstream instead), startup-auto-retry (its error path must
|
|
30
|
+
* SURFACE the friendly reset reply, which the _isReplay tag would suppress;
|
|
31
|
+
* its msg object already passed the full fresh gate this boot), compact-replay
|
|
32
|
+
* (a system re-push of the operator's own recorded command — outside the
|
|
33
|
+
* user-message gate by design, §6.7), and auto-resume (a continuation of a
|
|
34
|
+
* live turn, not a redelivery).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const REDELIVERED_CAP = 256;
|
|
38
|
+
|
|
39
|
+
function createRedeliver({
|
|
40
|
+
gateInbound,
|
|
41
|
+
dispatchHandleMessage,
|
|
42
|
+
getSessionKey,
|
|
43
|
+
config,
|
|
44
|
+
db = null,
|
|
45
|
+
dbWrite = (fn) => fn(),
|
|
46
|
+
setInboundHandlerStatus = null, // override for tests; defaults to db.setInboundHandlerStatus
|
|
47
|
+
react = null,
|
|
48
|
+
bot,
|
|
49
|
+
logEvent = () => {},
|
|
50
|
+
logger = console,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (typeof dispatchHandleMessage !== 'function') throw new TypeError('redeliver: dispatchHandleMessage required');
|
|
53
|
+
if (typeof getSessionKey !== 'function') throw new TypeError('redeliver: getSessionKey required');
|
|
54
|
+
|
|
55
|
+
const redelivered = new Set(); // `${chatId}:${msgId}` — once-only, FIFO-bounded
|
|
56
|
+
const redeliveredOrder = [];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} opts
|
|
60
|
+
* @param {string} opts.chatId
|
|
61
|
+
* @param {object} opts.msg — grammy-shaped message (fresh, reconstructed, or synthetic)
|
|
62
|
+
* @param {string} opts.source — 'boot-replay' | 'edit' | 'drop' | 'startup-retry' (telemetry)
|
|
63
|
+
* @param {string|null} [opts.preMark] — handler_status to pre-mark (one-shot guard), e.g. 'replay-attempted'
|
|
64
|
+
* @param {boolean} [opts.gate=true] — run the D5 gate at tier 'redelivery'
|
|
65
|
+
* @param {boolean} [opts.ack=true] — 👀 on the redelivered message
|
|
66
|
+
* @returns {Promise<{ok:boolean, reason?:string}>}
|
|
67
|
+
*/
|
|
68
|
+
return async function redeliverAsFreshTurn({
|
|
69
|
+
chatId, msg, source, preMark = null, gate = true, ack = true,
|
|
70
|
+
} = {}) {
|
|
71
|
+
if (!msg || !msg.chat || msg.message_id == null) return { ok: false, reason: 'malformed message' };
|
|
72
|
+
const chatConfig = config.chats[String(chatId)];
|
|
73
|
+
if (!chatConfig) return { ok: false, reason: 'unconfigured chat' };
|
|
74
|
+
|
|
75
|
+
const key = `${chatId}:${msg.message_id}`;
|
|
76
|
+
if (redelivered.has(key)) {
|
|
77
|
+
logEvent('redeliver-suppressed-duplicate', { chat_id: chatId, msg_id: msg.message_id, source });
|
|
78
|
+
return { ok: false, reason: 'already-redelivered' };
|
|
79
|
+
}
|
|
80
|
+
redelivered.add(key);
|
|
81
|
+
redeliveredOrder.push(key);
|
|
82
|
+
while (redeliveredOrder.length > REDELIVERED_CAP) redelivered.delete(redeliveredOrder.shift());
|
|
83
|
+
|
|
84
|
+
msg._isReplay = true;
|
|
85
|
+
|
|
86
|
+
// Pre-mark BEFORE the gate: the one-shot DB guard must hold even when the
|
|
87
|
+
// gate blocks the content — otherwise a blocked row (abort/admin-shaped)
|
|
88
|
+
// would stay replay-eligible and re-block on every subsequent boot.
|
|
89
|
+
if (preMark) {
|
|
90
|
+
const mark = setInboundHandlerStatus || ((args) => db?.setInboundHandlerStatus?.(args));
|
|
91
|
+
dbWrite(
|
|
92
|
+
() => mark({ chat_id: chatId, msg_id: msg.message_id, status: preMark }),
|
|
93
|
+
`set handler_status=${preMark}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (gate) {
|
|
98
|
+
if (typeof gateInbound !== 'function') return { ok: false, reason: 'gate unavailable' };
|
|
99
|
+
const res = await gateInbound(msg, { tier: 'redelivery' });
|
|
100
|
+
if (res.action !== 'pass') {
|
|
101
|
+
logEvent('input-dropped-no-redeliver', {
|
|
102
|
+
chat_id: chatId, msg_id: msg.message_id, source, stage: res.stage ?? null,
|
|
103
|
+
});
|
|
104
|
+
return { ok: false, reason: res.stage || res.action };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (ack) {
|
|
109
|
+
try { react?.(chatId, msg.message_id); } catch { /* best-effort — never blocks */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const threadId = msg.message_thread_id?.toString();
|
|
113
|
+
const sessionKey = getSessionKey(String(chatId), threadId, chatConfig);
|
|
114
|
+
logEvent('redelivered-as-fresh-turn', {
|
|
115
|
+
chat_id: chatId, msg_id: msg.message_id, source, session_key: sessionKey,
|
|
116
|
+
});
|
|
117
|
+
dispatchHandleMessage(sessionKey, String(chatId), msg, bot);
|
|
118
|
+
return { ok: true };
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { createRedeliver };
|