polygram 0.12.0-rc.34 → 0.12.0-rc.36

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.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session-scoped feedback controller (0.13 D3,
5
+ * docs/0.13-channels-lifecycle-design.md §3 D3).
6
+ *
7
+ * The per-turn reactor/typing pair lives in handleMessage's closure and dies
8
+ * with the turn (ROOT C). D1 extended the turn to claude's real cycle end —
9
+ * which closed the dead-air class for PRIMARY turns — but cycles with NO
10
+ * pending turn still had zero feedback surface:
11
+ *
12
+ * - autonomous/wakeup cycles (ScheduleWakeup, fireUserMessage self-checks):
13
+ * minutes of work with nothing visible until text lands;
14
+ * - an injected follow-up picked up as its OWN next cycle: its message sat
15
+ * with no indicator while claude worked it.
16
+ *
17
+ * This controller owns those: a session-level typing loop for the cycle's
18
+ * duration, plus — when the InputLedger knows which message the cycle picked
19
+ * up — a 🤔 anchored to that message, cleared at cycle end. Inputs are the
20
+ * previously-unconsumed lifecycle edges: 'turn-start' (UPS) with no pending,
21
+ * and 'idle'/'close' as the end signals (wired via lib/sdk/callbacks.js).
22
+ *
23
+ * Per-turn feedback (reactor cascade, streamer, waiting-on-user typing pause)
24
+ * stays where it is — this controller deliberately covers only the
25
+ * no-pending gap; it never touches a session that has a head pending.
26
+ */
27
+
28
+ const { startTyping } = require('../telegram/typing');
29
+
30
+ function createSessionFeedback({
31
+ bot,
32
+ tg,
33
+ getChatIdFromKey,
34
+ getThreadIdFromKey,
35
+ botName,
36
+ typingIntervalMs = undefined, // override for tests; default = typing.js default
37
+ logEvent = () => {},
38
+ logger = console,
39
+ } = {}) {
40
+ const active = new Map(); // sessionKey → { stop, anchor: {chatId, msgId}|null }
41
+
42
+ function startAutonomousCycle(sessionKey, { anchorMsgId = null } = {}) {
43
+ if (active.has(sessionKey)) return;
44
+ const chatId = getChatIdFromKey(sessionKey);
45
+ if (!chatId || !bot) return;
46
+ const threadIdRaw = getThreadIdFromKey?.(sessionKey);
47
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
48
+
49
+ const stop = startTyping({
50
+ bot, chatId,
51
+ ...(Number.isInteger(threadId) ? { threadId } : {}),
52
+ ...(typingIntervalMs ? { intervalMs: typingIntervalMs } : {}),
53
+ logger: { error: (m) => logger.error?.(`[${botName}] autonomous-typing: ${m}`) },
54
+ });
55
+
56
+ let anchor = null;
57
+ if (anchorMsgId != null) {
58
+ anchor = { chatId, msgId: Number(anchorMsgId) };
59
+ tg(bot, 'setMessageReaction', {
60
+ chat_id: chatId, message_id: anchor.msgId,
61
+ reaction: [{ type: 'emoji', emoji: '🤔' }],
62
+ }, { source: 'autonomous-cycle-anchor', botName }).catch(() => {});
63
+ }
64
+
65
+ active.set(sessionKey, { stop, anchor });
66
+ logEvent('autonomous-cycle-visuals', {
67
+ chat_id: chatId, session_key: sessionKey, state: 'start',
68
+ anchor_msg_id: anchor?.msgId ?? null,
69
+ });
70
+ }
71
+
72
+ function endCycle(sessionKey) {
73
+ const entry = active.get(sessionKey);
74
+ if (!entry) return;
75
+ active.delete(sessionKey);
76
+ try { entry.stop(); } catch { /* best-effort */ }
77
+ if (entry.anchor && bot) {
78
+ tg(bot, 'setMessageReaction', {
79
+ chat_id: entry.anchor.chatId, message_id: entry.anchor.msgId, reaction: [],
80
+ }, { source: 'autonomous-cycle-anchor-clear', botName }).catch(() => {});
81
+ }
82
+ logEvent('autonomous-cycle-visuals', {
83
+ chat_id: entry.anchor?.chatId ?? getChatIdFromKey(sessionKey),
84
+ session_key: sessionKey, state: 'end',
85
+ });
86
+ }
87
+
88
+ return { startAutonomousCycle, endCycle };
89
+ }
90
+
91
+ module.exports = { createSessionFeedback };
@@ -78,6 +78,7 @@ function createAutosteerHandlers({
78
78
  content: prompt,
79
79
  priority,
80
80
  msgId: msg.message_id,
81
+ source: 'autosteer', // 0.13 D2: ledger source — drop detection + redelivery eligibility
81
82
  });
82
83
  if (!ok) return { autosteered: false };
83
84
 
@@ -86,6 +87,9 @@ function createAutosteerHandlers({
86
87
  chat_id: chatId, msg_id: msg.message_id,
87
88
  text_len: prompt?.length ?? 0,
88
89
  priority,
90
+ // 0.13 P1: per-event backend. The 14d fold/drop investigation had to
91
+ // reconstruct the cli-vs-sdk split by joining chats — never again.
92
+ backend: typeof pm.getBackend === 'function' ? pm.getBackend(sessionKey) : null,
89
93
  });
90
94
  return { autosteered: true, priority };
91
95
  }
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Drop-redeliverer (0.13 D2 → D4 glue, docs/0.13-channels-lifecycle-design.md §3 D2).
5
+ *
6
+ * Consumes CliProcess's 'input-dropped' event — a ledgered input that was
7
+ * confirmed dropped (never seen at pickup, never acknowledged via
8
+ * consumed_turn_ids, not superseded, with the ack contract observed in the
9
+ * cycle) — and redelivers it ONCE through the unified D4 tail.
10
+ *
11
+ * Eligibility (design's redelivery constraints):
12
+ * - `primary` and `autosteer` sources only: both reconstruct from the
13
+ * inbound DB row (recordInbound persisted the raw message), so the
14
+ * redelivered turn re-formats through the NORMAL prompt path — no stored
15
+ * prompt text, no double-formatting, events stay content-free (L13).
16
+ * - `edit-fold` / `system` / `inject` park as telemetry
17
+ * (input-dropped-no-redeliver): an edit correction has its own
18
+ * re-delivery path, and system pushes are never auto-re-executed.
19
+ *
20
+ * The D4 tail then enforces: once-only, _isReplay, the D5 gate at tier
21
+ * 'redelivery' (an abort/admin-shaped drop is never auto-re-executed), the
22
+ * visible 👀 ack, and dispatch. Supersession was already decided ledger-side.
23
+ */
24
+
25
+ function createDropRedeliverer({ db, redeliver, logEvent = () => {}, logger = console } = {}) {
26
+ if (typeof redeliver !== 'function') throw new TypeError('drop-redeliver: redeliver required');
27
+
28
+ return async function onInputDropped(sessionKey, payload = {}) {
29
+ try {
30
+ const { chatId, msgId, source, turnId } = payload;
31
+ if (source !== 'primary' && source !== 'autosteer') {
32
+ logEvent('input-dropped-no-redeliver', {
33
+ chat_id: chatId ?? null, msg_id: msgId ?? null, source: source ?? null,
34
+ turn_id: turnId ?? null, reason: 'source-not-redeliverable',
35
+ });
36
+ return;
37
+ }
38
+ if (msgId == null) {
39
+ logEvent('input-dropped-no-redeliver', {
40
+ chat_id: chatId ?? null, source, turn_id: turnId ?? null, reason: 'no-msg-id',
41
+ });
42
+ return;
43
+ }
44
+ const row = db.getMessage(String(chatId), Number(msgId));
45
+ if (!row) {
46
+ logEvent('input-dropped-no-redeliver', {
47
+ chat_id: chatId, msg_id: msgId, source, turn_id: turnId ?? null, reason: 'no-db-row',
48
+ });
49
+ return;
50
+ }
51
+ // Reconstruct the boot-replay way: enough of a grammy Message for the
52
+ // normal prompt/attachment path to re-run from the persisted row.
53
+ const reconstructed = {
54
+ chat: { id: Number(chatId), type: String(chatId).startsWith('-') ? 'supergroup' : 'private' },
55
+ message_id: Number(msgId),
56
+ from: { id: row.user_id, first_name: row.user },
57
+ text: row.text || '',
58
+ date: Math.floor((row.ts || Date.now()) / 1000),
59
+ ...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
60
+ ...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
61
+ };
62
+ await redeliver({ chatId: String(chatId), msg: reconstructed, source: 'drop' });
63
+ } catch (err) {
64
+ logger.error?.(`[drop-redeliver] ${err?.message || err}`);
65
+ }
66
+ };
67
+ }
68
+
69
+ module.exports = { createDropRedeliverer };
@@ -59,6 +59,8 @@ function createEditCorrectionInjector({
59
59
  const ok = pm.injectUserMessage(sessionKey, {
60
60
  content: `[edit] I corrected my previous message — it now reads: ${newText}`,
61
61
  priority: 'next',
62
+ msgId: editedMsg.message_id,
63
+ source: 'edit-fold', // 0.13 D2: ledgered (telemetry; edits have their own redelivery path)
62
64
  });
63
65
  if (!ok) {
64
66
  logger.error?.(`[${chatConfig.name || chatId}] edit-correction inject failed`);
@@ -25,93 +25,112 @@
25
25
  * @param {Function} deps.shouldHandle (msg, chatConfig, botUsername) => boolean — the real gate
26
26
  * @param {Function} deps.dispatchHandleMessage (sessionKey, chatId, msg, bot) => void
27
27
  * @param {object} deps.bot
28
- * @param {RegExp|null} [deps.mentionRe] strips the @bot mention from the new text for the body
29
- * @param {string} deps.botUsername
30
28
  * @param {Function} [deps.react] (chatId, msgId) => void|Promise — on-edit acknowledgment
31
29
  * @param {Function} [deps.logEvent]
32
30
  * @param {object} [deps.logger]
33
- * @returns {(editedMsg: object, oldText: string|null) => boolean} true when a fresh turn was dispatched
31
+ * @returns {(editedMsg, oldText, botUsername, mentionRe?) => boolean} true when a fresh turn was
32
+ * dispatched. botUsername / mentionRe are passed at CALL time (not construction): they are
33
+ * resolved asynchronously via getMe and live in the createBot scope, so capturing them in the
34
+ * factory (built in main()) would both be out of scope and freeze the empty initial values.
34
35
  */
35
36
  function createEditRedelivery({
36
37
  pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
37
- mentionRe = null, botUsername, react, logEvent = () => {}, logger = console,
38
+ react, logEvent = () => {}, logger = console,
38
39
  } = {}) {
39
- return function maybePostTurnEdit(editedMsg, oldText) {
40
- try {
41
- if (!editedMsg?.chat) return false;
42
- const chatId = editedMsg.chat.id.toString();
43
- const chatConfig = config.chats[chatId];
44
- if (!chatConfig) return false;
40
+ // botUsername / mentionRe arrive at CALL time — see @returns. Constructing with them threw
41
+ // `ReferenceError: mentionRe is not defined` at boot (rc.34): the factory runs in main() where
42
+ // those createBot locals don't exist. The edited_message handler passes the live values.
43
+ //
44
+ // 0.13 D5 (spec §5 as written): the in-flight interlock is per-(chatId,msgId),
45
+ // not per-session. A re-edit of the SAME message while its re-dispatch runs
46
+ // folds via inject; an edit of a DIFFERENT message proceeds as its own
47
+ // redelivery (dispatchHandleMessage autosteers it naturally if a turn is in
48
+ // flight — through the formatted-prompt path, not a hand-built string).
49
+ const redeliveredAt = new Map(); // `${chatId}:${msgId}` → ts
50
+ const INTERLOCK_TTL_MS = 10 * 60 * 1000;
45
51
 
46
- // Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
47
- const optOut = chatConfig.editCorrection != null
48
- ? chatConfig.editCorrection === false
49
- : config.bot?.editCorrection === false;
50
- if (optOut) return false;
52
+ return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
53
+ try {
54
+ if (!editedMsg?.chat) return false;
55
+ const chatId = editedMsg.chat.id.toString();
56
+ const chatConfig = config.chats[chatId];
57
+ if (!chatConfig) return false;
51
58
 
52
- const newText = editedMsg.text || editedMsg.caption || '';
53
- if (!newText) return false; // blanked / media-only → nothing to act on
54
- // Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
55
- // caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
56
- if (oldText != null && oldText === newText) return false;
59
+ // Per-chat / bot-level opt-out (shared with the mid-turn injector). Default on.
60
+ const optOut = chatConfig.editCorrection != null
61
+ ? chatConfig.editCorrection === false
62
+ : config.bot?.editCorrection === false;
63
+ if (optOut) return false;
57
64
 
58
- const threadId = editedMsg.message_thread_id?.toString() || null;
59
- const sessionKey = getSessionKey(chatId, threadId, chatConfig);
65
+ const newText = editedMsg.text || editedMsg.caption || '';
66
+ if (!newText) return false; // blanked / media-only → nothing to act on
67
+ // Changed-guard: skip metadata-only edits (link-preview load fires edited_message too). The
68
+ // caller MUST have read oldText before recordInbound overwrote the row; null = unknown → proceed.
69
+ if (oldText != null && oldText === newText) return false;
60
70
 
61
- // Interlock: a turn is in flight (most likely OUR own re-run, whose _isReplay row reads
62
- // not-live so the mid-turn injector skipped it and fell through to here). Fold the re-edit
63
- // via inject instead of spawning a SECOND re-dispatch turn for the same message.
64
- const proc = pm?.get?.(sessionKey);
65
- if (proc?.inFlight) {
66
- pm.injectUserMessage?.(sessionKey, {
67
- content: `[edit] I edited my message again — it now reads: ${newText}`,
68
- priority: 'next',
69
- msgId: editedMsg.message_id,
70
- });
71
- logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
72
- return false;
73
- }
71
+ const threadId = editedMsg.message_thread_id?.toString() || null;
72
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
74
73
 
75
- // GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
76
- // NOT the synthetic a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
77
- // paired user editing an un-mentioned message in a mention-gated group.
78
- if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
74
+ // Interlock (per-message, 0.13 D5): only a re-edit of a message whose OWN
75
+ // re-dispatch is still running folds via inject pre-0.13 this was
76
+ // per-session (any in-flight turn folded any edit, and it injected
77
+ // BEFORE the gate; the gate now runs upstream in the edited_message
78
+ // handler, so every path through here is already gated).
79
+ const proc = pm?.get?.(sessionKey);
80
+ const interlockKey = `${chatId}:${editedMsg.message_id}`;
81
+ const lastRedeliveredAt = redeliveredAt.get(interlockKey) || 0;
82
+ if (proc?.inFlight && (Date.now() - lastRedeliveredAt) < INTERLOCK_TTL_MS && lastRedeliveredAt > 0) {
83
+ pm.injectUserMessage?.(sessionKey, {
84
+ content: `[edit] I edited my message again — it now reads: ${newText}`,
85
+ priority: 'next',
86
+ msgId: editedMsg.message_id,
87
+ source: 'edit-fold', // 0.13 D2
88
+ });
89
+ logEvent('edit-redelivery-folded', { chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id });
90
+ return false;
91
+ }
92
+
93
+ // GATE on the REAL edited message (its real from / new text / its own real reply_to, if any).
94
+ // NOT the synthetic — a self-reply_to would trip shouldHandle.repliesToOtherUser and drop a
95
+ // paired user editing an un-mentioned message in a mention-gated group.
96
+ if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
79
97
 
80
- // Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
81
- // claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
82
- try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
98
+ // Acknowledge immediately: a silent edit produces no new bubble, so show it registered before
99
+ // claude's reply lands (the rc.33 lesson). Best-effort; never blocks the re-dispatch.
100
+ try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
83
101
 
84
- // Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
85
- // reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
86
- // renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
87
- const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
88
- const synthetic = {
89
- chat: editedMsg.chat,
102
+ // Synthetic turn: NEW text in the body, OLD text in the reply_to so claude sees the change.
103
+ // reply_to_message carries `from` + `text` so resolveReplyTo takes the telegram branch and
104
+ // renders the OLD text — NOT db.getMessage (which now holds the overwritten new text).
105
+ const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
106
+ const synthetic = {
107
+ chat: editedMsg.chat,
108
+ message_id: editedMsg.message_id,
109
+ from: editedMsg.from,
110
+ text: cleanNew,
111
+ date: editedMsg.date,
112
+ ...(threadId && { message_thread_id: Number(threadId) }),
113
+ reply_to_message: {
90
114
  message_id: editedMsg.message_id,
91
115
  from: editedMsg.from,
92
- text: cleanNew,
116
+ text: oldText || '',
93
117
  date: editedMsg.date,
94
- ...(threadId && { message_thread_id: Number(threadId) }),
95
- reply_to_message: {
96
- message_id: editedMsg.message_id,
97
- from: editedMsg.from,
98
- text: oldText || '',
99
- date: editedMsg.date,
100
- },
101
- _isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
102
- };
103
- dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
104
- logEvent('edit-redelivered', {
105
- chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
106
- old_len: (oldText || '').length, new_len: newText.length,
107
- });
108
- return true;
109
- } catch (e) {
110
- // Never throw out of the edited_message handler.
111
- logger.error?.(`[edit-redelivery] ${e.message}`);
112
- return false;
113
- }
114
- };
118
+ },
119
+ _isReplay: true, // no new editable row, not replay-eligible, error reply suppressed
120
+ };
121
+ redeliveredAt.set(interlockKey, Date.now());
122
+ dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
123
+ logEvent('edit-redelivered', {
124
+ chat_id: chatId, session_key: sessionKey, msg_id: editedMsg.message_id,
125
+ old_len: (oldText || '').length, new_len: newText.length,
126
+ });
127
+ return true;
128
+ } catch (e) {
129
+ // Never throw out of the edited_message handler.
130
+ logger.error?.(`[edit-redelivery] ${e.message}`);
131
+ return false;
132
+ }
133
+ };
115
134
  }
116
135
 
117
136
  module.exports = { createEditRedelivery };
@@ -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 };