polygram 0.12.0-rc.35 → 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.
- package/lib/feedback/session-feedback.js +91 -0
- package/lib/handlers/autosteer.js +4 -0
- package/lib/handlers/drop-redeliver.js +69 -0
- package/lib/handlers/edit-correction.js +2 -0
- package/lib/handlers/edit-redelivery.js +80 -65
- package/lib/handlers/gate-inbound.js +188 -0
- package/lib/handlers/redeliver.js +122 -0
- package/lib/process/channels-bridge.mjs +10 -0
- package/lib/process/cli-process.js +585 -65
- package/lib/process/process.js +9 -0
- package/lib/process-manager.js +18 -25
- package/lib/sdk/callbacks.js +54 -184
- package/lib/telegram/typing.js +17 -2
- package/package.json +1 -1
- package/polygram.js +129 -108
|
@@ -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`);
|
|
@@ -40,82 +40,97 @@ function createEditRedelivery({
|
|
|
40
40
|
// botUsername / mentionRe arrive at CALL time — see @returns. Constructing with them threw
|
|
41
41
|
// `ReferenceError: mentionRe is not defined` at boot (rc.34): the factory runs in main() where
|
|
42
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;
|
|
51
|
+
|
|
43
52
|
return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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;
|
|
55
64
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
const threadId = editedMsg.message_thread_id?.toString() || null;
|
|
72
|
+
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
83
97
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 */ }
|
|
87
101
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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: {
|
|
94
114
|
message_id: editedMsg.message_id,
|
|
95
115
|
from: editedMsg.from,
|
|
96
|
-
text:
|
|
116
|
+
text: oldText || '',
|
|
97
117
|
date: editedMsg.date,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Never throw out of the edited_message handler.
|
|
115
|
-
logger.error?.(`[edit-redelivery] ${e.message}`);
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
};
|
|
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
|
+
};
|
|
119
134
|
}
|
|
120
135
|
|
|
121
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 };
|