switchroom 0.19.30 → 0.19.31
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/dist/cli/switchroom.js +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +1435 -551
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stale-pin-sweep-store.ts — the durable OBLIGATION ledger for the stale-pin
|
|
3
|
+
* sweep (`stale-pin-sweep.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Why an obligation and not a "did we sweep this boot?" flag
|
|
6
|
+
* ---------------------------------------------------------
|
|
7
|
+
* Draining a deep pin stack is not one API call. In a DM each pop costs a
|
|
8
|
+
* re-pin plus an unpin, both rate-gated, so a chat with 21 orphans (klanker,
|
|
9
|
+
* observed 2026-07-29) takes north of a minute of wall clock. The old
|
|
10
|
+
* once-per-boot in-memory `Set` (`dm-pin-sweep.ts`) forgot everything the
|
|
11
|
+
* instant the process died: a gateway restarted mid-drain simply started over
|
|
12
|
+
* — or, once the per-boot pop budget was in play, never finished at all.
|
|
13
|
+
*
|
|
14
|
+
* So the sweep is modelled as a persisted OBLIGATION per `(chatId, threadId)`
|
|
15
|
+
* target: seeded from the pin stores at boot, advanced as pops land, and
|
|
16
|
+
* discharged (`done: true`) only once the drain is VERIFIED empty. A restart
|
|
17
|
+
* mid-sweep reloads the ledger and RESUMES from `popped` rather than forgetting.
|
|
18
|
+
*
|
|
19
|
+
* Durability: the write goes through `atomicWriteFileSync` (src/util/atomic.ts)
|
|
20
|
+
* — tempfile in the same directory, `fsync` on the fd, then `rename(2)`. A
|
|
21
|
+
* power cut leaves either the previous complete ledger or the new one, and the
|
|
22
|
+
* bytes of whichever survives are on the platter. A plain
|
|
23
|
+
* `writeFileSync`+`rename` would give atomicity without durability: the rename
|
|
24
|
+
* can be ordered before the data reaches disk, which is exactly how a crashed
|
|
25
|
+
* host comes back with a zero-length ledger and a chat full of orphan pins that
|
|
26
|
+
* nothing remembers to clean.
|
|
27
|
+
*
|
|
28
|
+
* Shape choice mirrors `status-pin-store.ts`: a whole-file SNAPSHOT, because
|
|
29
|
+
* the obligation set is tiny (one row per chat/topic the agent has ever pinned
|
|
30
|
+
* in) and rewriting it is trivially cheap. PURE with respect to the injected fs
|
|
31
|
+
* seam, so the ledger semantics are unit-testable without touching a disk.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Chat classes the sweep treats differently. See `classifyChatForSweep`. */
|
|
35
|
+
export type SweepChatKind = 'dm' | 'forum-topic' | 'supergroup'
|
|
36
|
+
|
|
37
|
+
/** One persisted sweep obligation for a single `(chatId, threadId)` target. */
|
|
38
|
+
export interface SweepCursor {
|
|
39
|
+
/** Chat the orphan pins live in. */
|
|
40
|
+
chatId: string
|
|
41
|
+
/**
|
|
42
|
+
* Forum topic the target is scoped to, when known.
|
|
43
|
+
*
|
|
44
|
+
* This is the `(chat, thread)` half of the registry key. Telegram's pin stack
|
|
45
|
+
* is CHAT-WIDE — MTProto `messages.updatePinnedMessage` carries no
|
|
46
|
+
* `top_msg_id` and the Bot API's `pinChatMessage`/`unpinChatMessage` accept no
|
|
47
|
+
* `message_thread_id` — so a "topic pin" is really a chat-level pin whose
|
|
48
|
+
* message happens to live in that thread, and clients render the topic bar by
|
|
49
|
+
* filtering. A bot therefore cannot enumerate pins or target a topic with
|
|
50
|
+
* pin/unpin. The ONE topic-scoped verb it does have is
|
|
51
|
+
* `unpinAllForumTopicMessages(chat_id, message_thread_id)`, and that verb is
|
|
52
|
+
* the whole reason this field must be persisted: without it, a boot after a
|
|
53
|
+
* crash knows a forum chat has orphans but not which topic to drain.
|
|
54
|
+
*/
|
|
55
|
+
threadId?: number
|
|
56
|
+
/** Chat class, decided when the obligation was seeded. */
|
|
57
|
+
kind: SweepChatKind
|
|
58
|
+
/** Pops (or drain attempts) committed so far, ACROSS boots. */
|
|
59
|
+
popped: number
|
|
60
|
+
/** True once the drain was verified empty — the obligation is discharged. */
|
|
61
|
+
done: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Boots that have attempted this obligation. Bounded by
|
|
64
|
+
* {@link SWEEP_MAX_ATTEMPTS} so a permanently-undrainable chat (bot kicked,
|
|
65
|
+
* rights revoked, a chat that flood-waits forever) cannot re-burn the pop
|
|
66
|
+
* budget on every boot for the rest of time.
|
|
67
|
+
*/
|
|
68
|
+
attempts: number
|
|
69
|
+
/**
|
|
70
|
+
* Message ids this obligation has already issued a targeted unpin for.
|
|
71
|
+
*
|
|
72
|
+
* Load-bearing for the GROUP path, which is not a blind stack drain but a
|
|
73
|
+
* bounded pass over the ids the gateway is on record as having pinned. When a
|
|
74
|
+
* boot spends its per-minute budget half way through that list, this is what
|
|
75
|
+
* lets the next boot resume at the right place instead of re-issuing (and
|
|
76
|
+
* re-charging the flood ledger for) every unpin from the start.
|
|
77
|
+
*
|
|
78
|
+
* Unused by the DM path, which drains by observation and has no id list.
|
|
79
|
+
*/
|
|
80
|
+
doneIds?: number[]
|
|
81
|
+
/** Last terminal-ish reason, for operator forensics. Never load-bearing. */
|
|
82
|
+
lastStatus?: string
|
|
83
|
+
/** Wall-clock ms of the last write. */
|
|
84
|
+
updatedAt: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How many boots may attempt one obligation before it is forfeited. Generous
|
|
89
|
+
* because a legitimate deep stack is deliberately spread over several boots
|
|
90
|
+
* (the per-minute pin-op budget, and in a DM the 40-pop-per-chat cap): 8 boots
|
|
91
|
+
* covers far more than anything observed on the fleet.
|
|
92
|
+
*/
|
|
93
|
+
export const SWEEP_MAX_ATTEMPTS = 8
|
|
94
|
+
|
|
95
|
+
/** Injected fs seam — same shape as the status-pin store's, so the gateway can
|
|
96
|
+
* bind one durable writer for both. `writeFileSync` MUST be durable (fsync +
|
|
97
|
+
* atomic rename); the gateway binds `atomicWriteFileSync`. */
|
|
98
|
+
export interface SweepStoreFsSeam {
|
|
99
|
+
readFileSync: (path: string) => string
|
|
100
|
+
/** Durable atomic replace of `path` with `data` (fsync then rename). */
|
|
101
|
+
writeFileSync: (path: string, data: string) => void
|
|
102
|
+
existsSync: (path: string) => boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface SweepEnvelope {
|
|
106
|
+
v: 1
|
|
107
|
+
cursors: SweepCursor[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SWEEP_ENVELOPE_VERSIONS = new Set([1])
|
|
111
|
+
|
|
112
|
+
/** Stable identity of a sweep target: chat plus topic (topic-less = `-`). */
|
|
113
|
+
export function sweepTargetKey(chatId: string, threadId?: number): string {
|
|
114
|
+
return `${chatId}:${threadId ?? '-'}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isCursorRow(x: unknown): x is SweepCursor {
|
|
118
|
+
if (x == null || typeof x !== 'object') return false
|
|
119
|
+
const o = x as Record<string, unknown>
|
|
120
|
+
return (
|
|
121
|
+
typeof o.chatId === 'string' &&
|
|
122
|
+
o.chatId.length > 0 &&
|
|
123
|
+
(o.threadId === undefined || typeof o.threadId === 'number') &&
|
|
124
|
+
(o.kind === 'dm' || o.kind === 'forum-topic' || o.kind === 'supergroup') &&
|
|
125
|
+
typeof o.popped === 'number' &&
|
|
126
|
+
typeof o.done === 'boolean' &&
|
|
127
|
+
typeof o.attempts === 'number' &&
|
|
128
|
+
(o.doneIds === undefined ||
|
|
129
|
+
(Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === 'number'))) &&
|
|
130
|
+
(o.lastStatus === undefined || typeof o.lastStatus === 'string') &&
|
|
131
|
+
typeof o.updatedAt === 'number'
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Load the obligation ledger. Fails OPEN to `[]` on a missing, unreadable or
|
|
137
|
+
* malformed file: a corrupt ledger must never crash boot. Worst case the sweep
|
|
138
|
+
* re-seeds from the pin stores, which is exactly the pre-ledger behaviour.
|
|
139
|
+
*/
|
|
140
|
+
export function loadSweepCursors(path: string, fs: SweepStoreFsSeam): SweepCursor[] {
|
|
141
|
+
if (!fs.existsSync(path)) return []
|
|
142
|
+
let raw = ''
|
|
143
|
+
try {
|
|
144
|
+
raw = fs.readFileSync(path)
|
|
145
|
+
} catch {
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
let parsed: unknown
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw)
|
|
151
|
+
} catch {
|
|
152
|
+
return []
|
|
153
|
+
}
|
|
154
|
+
if (parsed == null || typeof parsed !== 'object') return []
|
|
155
|
+
const env = parsed as Record<string, unknown>
|
|
156
|
+
if (
|
|
157
|
+
typeof env.v !== 'number' ||
|
|
158
|
+
!SWEEP_ENVELOPE_VERSIONS.has(env.v) ||
|
|
159
|
+
!Array.isArray(env.cursors)
|
|
160
|
+
) {
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
return env.cursors.filter(isCursorRow)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist the ledger durably. Best-effort relative to fs availability: a write
|
|
168
|
+
* failure is logged, never thrown — a failing ledger degrades the sweep to
|
|
169
|
+
* "forgets across restarts" (the old behaviour), it must not break the gateway.
|
|
170
|
+
*/
|
|
171
|
+
export function persistSweepCursors(
|
|
172
|
+
path: string,
|
|
173
|
+
fs: SweepStoreFsSeam,
|
|
174
|
+
cursors: readonly SweepCursor[],
|
|
175
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
176
|
+
): void {
|
|
177
|
+
const env: SweepEnvelope = { v: 1, cursors: [...cursors] }
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(path, JSON.stringify(env))
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log(
|
|
182
|
+
`stale-pin-sweep-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
|
|
183
|
+
`durability degraded; a restart mid-sweep will re-seed instead of resume\n`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* READ-MODIFY-WRITE one target's row against the file on disk, so a concurrent
|
|
190
|
+
* writer's rows are never clobbered by a stale in-memory snapshot. Mirrors
|
|
191
|
+
* `applyStatusPinRow` in status-pin-store.ts.
|
|
192
|
+
*/
|
|
193
|
+
export function upsertSweepCursor(
|
|
194
|
+
path: string,
|
|
195
|
+
fs: SweepStoreFsSeam,
|
|
196
|
+
cursor: SweepCursor,
|
|
197
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
198
|
+
): void {
|
|
199
|
+
const key = sweepTargetKey(cursor.chatId, cursor.threadId)
|
|
200
|
+
const others = loadSweepCursors(path, fs).filter(
|
|
201
|
+
(c) => sweepTargetKey(c.chatId, c.threadId) !== key,
|
|
202
|
+
)
|
|
203
|
+
persistSweepCursors(path, fs, [...others, cursor], log)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The obligations still owed at boot: not discharged and not forfeited.
|
|
208
|
+
*
|
|
209
|
+
* A `done` row is retained in the ledger (rather than deleted) only until the
|
|
210
|
+
* next seed pass; `pruneSweepCursors` drops it. Keeping it for the duration of
|
|
211
|
+
* one boot is what makes a mid-sweep restart idempotent — the resumed sweep
|
|
212
|
+
* sees "already drained" instead of re-draining a chat it just cleared.
|
|
213
|
+
*/
|
|
214
|
+
export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
|
|
215
|
+
return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Drop discharged and forfeited rows — called once the ledger is reseeded. */
|
|
219
|
+
export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
|
|
220
|
+
return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
|
|
221
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stale-pin-sweep-wiring.ts — binds the pure stale-pin drain
|
|
3
|
+
* (`stale-pin-sweep.ts`) to the gateway's world, without putting any of that
|
|
4
|
+
* glue inside gateway.ts.
|
|
5
|
+
*
|
|
6
|
+
* The sweeper itself is deliberately dependency-free so its rate gates, its
|
|
7
|
+
* circuit-breaker and its "never trust an `ok:true`" invariant are provable in
|
|
8
|
+
* isolation. Everything it needs from the outside is a function. Assembling
|
|
9
|
+
* those functions is mechanical, but it is ~70 lines of policy (which pins are
|
|
10
|
+
* protected, what counts as pin rights, which env var unlocks the supergroup
|
|
11
|
+
* loop) that belongs next to the drain rather than in the gateway's already
|
|
12
|
+
* over-long module scope (switchroom#2996 anti-inflation ratchet).
|
|
13
|
+
*
|
|
14
|
+
* gateway.ts therefore supplies only the raw Bot API seam + the two live state
|
|
15
|
+
* readers; the policy lives here and is unit-testable.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createStalePinSweeper,
|
|
20
|
+
unexpiredStoreRepinIds,
|
|
21
|
+
type StalePinSweeper,
|
|
22
|
+
type SweepStoreFsSeam,
|
|
23
|
+
} from './stale-pin-sweep.js'
|
|
24
|
+
import type { PersistedStatusPin } from './status-pin-store.js'
|
|
25
|
+
import type { StatusPinClaim } from './status-pin-retarget.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The minimal Telegram surface the drain needs, expressed so the gateway hands
|
|
29
|
+
* over only its bot handle and its retry wrapper. Building the seam HERE (not
|
|
30
|
+
* in gateway.ts) keeps the four raw pin/unpin calls next to the comments that
|
|
31
|
+
* justify them.
|
|
32
|
+
*/
|
|
33
|
+
export interface StalePinSweepBotSeam {
|
|
34
|
+
/**
|
|
35
|
+
* Live grammY-shaped handle. A getter, because `lockedBot` is assigned late
|
|
36
|
+
* (inside `initGatewayBot`) and the sweep may outlive a rebind.
|
|
37
|
+
*
|
|
38
|
+
* Named `handle`, not `bot`: gateway.ts is statically scanned for
|
|
39
|
+
* module-scope identifiers named `bot`/`lockedBot`
|
|
40
|
+
* (`gateway-bot-construction-deferral.test.ts`, #2996 P0b), and a property
|
|
41
|
+
* KEY called `bot` at module scope trips that guard even though the value is
|
|
42
|
+
* a deferred arrow.
|
|
43
|
+
*/
|
|
44
|
+
handle: () => {
|
|
45
|
+
api: {
|
|
46
|
+
getChat: (chatId: string) => Promise<unknown>
|
|
47
|
+
pinChatMessage: (
|
|
48
|
+
chatId: string,
|
|
49
|
+
messageId: number,
|
|
50
|
+
opts: { disable_notification: boolean },
|
|
51
|
+
) => Promise<unknown>
|
|
52
|
+
unpinChatMessage: (chatId: string, messageId: number) => Promise<unknown>
|
|
53
|
+
unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
|
|
54
|
+
getChatMember: (chatId: string, userId: number) => Promise<unknown>
|
|
55
|
+
}
|
|
56
|
+
botInfo?: { id?: number }
|
|
57
|
+
}
|
|
58
|
+
/** The gateway's retry/telemetry envelope (`robustApiCall`). */
|
|
59
|
+
call: <T>(fn: () => Promise<T>, meta: { chat_id: string; verb: string }) => Promise<T>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface StalePinSweepWiring {
|
|
63
|
+
telegram: StalePinSweepBotSeam
|
|
64
|
+
/** Live in-memory status-pin claims (the single claim registry). */
|
|
65
|
+
claims: () => Iterable<StatusPinClaim>
|
|
66
|
+
/** Durable pin rows, read LIVE. Returns [] when persistence is off. Never
|
|
67
|
+
* throws — a read failure is logged by the caller and treated as []. */
|
|
68
|
+
loadPinRows: () => PersistedStatusPin[]
|
|
69
|
+
/** Mutex-won AND bot-constructed. False ⇒ the drain must not write. */
|
|
70
|
+
eligible: () => boolean
|
|
71
|
+
store: { path: string; fs: SweepStoreFsSeam }
|
|
72
|
+
/** Per-deployment override of `UNPIN_ALL_FORUM_TOPIC_ENABLED`; undefined =
|
|
73
|
+
* take the standing policy (the wholesale topic drain stays OFF). */
|
|
74
|
+
allowUnpinAllForumTopic?: boolean
|
|
75
|
+
log?: (line: string) => void
|
|
76
|
+
now?: () => number
|
|
77
|
+
sleep?: (ms: number) => Promise<void>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pins that must SURVIVE a drain of `chatId`: the live in-memory claims for
|
|
82
|
+
* that chat, unioned with the deliberately-retained durable rows (an unexpired
|
|
83
|
+
* `tool:` pin survives `statusPinBootCleanup` by design and must survive this
|
|
84
|
+
* too).
|
|
85
|
+
*
|
|
86
|
+
* Read from BOTH sources because neither alone is complete at both call sites:
|
|
87
|
+
* during the boot sweep the in-memory maps are still empty, and during a later
|
|
88
|
+
* first-inbound sweep the store may lag a claim taken this session.
|
|
89
|
+
*/
|
|
90
|
+
export function protectedPinIds(args: {
|
|
91
|
+
chatId: string
|
|
92
|
+
claims: Iterable<StatusPinClaim>
|
|
93
|
+
loadPinRows: () => PersistedStatusPin[]
|
|
94
|
+
now: number
|
|
95
|
+
log: (line: string) => void
|
|
96
|
+
}): number[] {
|
|
97
|
+
const ids: number[] = []
|
|
98
|
+
for (const claim of args.claims) {
|
|
99
|
+
if (claim.chatId === args.chatId) ids.push(claim.messageId)
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
ids.push(...unexpiredStoreRepinIds(args.loadPinRows(), args.chatId, args.now))
|
|
103
|
+
} catch (err) {
|
|
104
|
+
args.log(
|
|
105
|
+
`telegram gateway: stale-pin-sweep: store repin scan failed ` +
|
|
106
|
+
`(chat=${args.chatId}): ${(err as Error).message}\n`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return ids
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The message ids the gateway is ON RECORD as having pinned in one
|
|
114
|
+
* `(chat, thread)` target — the only ids the group drain is allowed to unpin.
|
|
115
|
+
*
|
|
116
|
+
* Matched on the FULL target key, not the chat alone: the pin stack is
|
|
117
|
+
* chat-wide, but the obligation is per topic, and unpinning another topic's
|
|
118
|
+
* recorded pin while sweeping this one would clear a card that is still live.
|
|
119
|
+
*/
|
|
120
|
+
export function recordedPinIdsFor(
|
|
121
|
+
rows: readonly PersistedStatusPin[],
|
|
122
|
+
chatId: string,
|
|
123
|
+
threadId?: number,
|
|
124
|
+
): number[] {
|
|
125
|
+
const out: number[] = []
|
|
126
|
+
for (const r of rows) {
|
|
127
|
+
if (r.chatId !== chatId) continue
|
|
128
|
+
if ((r.threadId ?? undefined) !== threadId) continue
|
|
129
|
+
if (typeof r.messageId === 'number' && !out.includes(r.messageId)) out.push(r.messageId)
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Assemble the gateway's stale-pin sweeper. */
|
|
135
|
+
export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSweeper {
|
|
136
|
+
const log = w.log ?? ((line: string) => process.stderr.write(line))
|
|
137
|
+
const now = w.now ?? Date.now
|
|
138
|
+
const { handle: bot, call } = w.telegram
|
|
139
|
+
return createStalePinSweeper({
|
|
140
|
+
getTopPinnedMessageId: async (chatId) => {
|
|
141
|
+
const chat = (await call(() => bot().api.getChat(chatId), {
|
|
142
|
+
chat_id: chatId,
|
|
143
|
+
verb: 'stale-pin-sweep.get-chat',
|
|
144
|
+
})) as { pinned_message?: { message_id?: number } } | undefined
|
|
145
|
+
return chat?.pinned_message?.message_id ?? null
|
|
146
|
+
},
|
|
147
|
+
// The RE-PIN half of the only sequence that actually pops a stack entry (a
|
|
148
|
+
// bare unpin on a stale entry is a silent no-op). An orphan pin has no
|
|
149
|
+
// claim to reconcile through, so the unified path cannot express it.
|
|
150
|
+
pinSilent: (chatId, messageId) =>
|
|
151
|
+
call(
|
|
152
|
+
// allow-raw-pin: orphan repin — see above. allow-raw-bot-api: already
|
|
153
|
+
// inside the gateway's robustApiCall envelope.
|
|
154
|
+
() => bot().api.pinChatMessage(chatId, messageId, { disable_notification: true }),
|
|
155
|
+
{ chat_id: chatId, verb: 'stale-pin-sweep.repin' },
|
|
156
|
+
),
|
|
157
|
+
// The UNPIN half of the pop. Its result is deliberately ignored — ok:true
|
|
158
|
+
// proves nothing; progress is read back from getChat.
|
|
159
|
+
unpin: (chatId, messageId) =>
|
|
160
|
+
call(
|
|
161
|
+
// allow-raw-pin: orphan unpin — see above. allow-raw-bot-api: already
|
|
162
|
+
// inside the gateway's robustApiCall envelope.
|
|
163
|
+
() => bot().api.unpinChatMessage(chatId, messageId),
|
|
164
|
+
{ chat_id: chatId, verb: 'stale-pin-sweep.unpin' },
|
|
165
|
+
),
|
|
166
|
+
unpinAllForumTopicMessages: (chatId, threadId) =>
|
|
167
|
+
call(() => bot().api.unpinAllForumTopicMessages(chatId, threadId), {
|
|
168
|
+
chat_id: chatId,
|
|
169
|
+
verb: 'stale-pin-sweep.unpin-all-topic',
|
|
170
|
+
}),
|
|
171
|
+
// Rights are checked before ANY write in a group. Telegram is HONEST about
|
|
172
|
+
// missing pin rights (400 "not enough rights to manage pinned messages"),
|
|
173
|
+
// but the precheck keeps a rights-less bot from emitting doomed traffic.
|
|
174
|
+
canPinInChat: async (chatId) => {
|
|
175
|
+
const self = bot().botInfo?.id
|
|
176
|
+
if (self == null) return false
|
|
177
|
+
const member = (await call(() => bot().api.getChatMember(chatId, self), {
|
|
178
|
+
chat_id: chatId,
|
|
179
|
+
verb: 'stale-pin-sweep.get-chat-member',
|
|
180
|
+
})) as { status?: string; can_pin_messages?: boolean } | undefined
|
|
181
|
+
return member?.status === 'administrator' && member.can_pin_messages === true
|
|
182
|
+
},
|
|
183
|
+
recordedPinIds: (chatId, threadId) => {
|
|
184
|
+
try {
|
|
185
|
+
return recordedPinIdsFor(w.loadPinRows(), chatId, threadId)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
log(
|
|
188
|
+
`telegram gateway: stale-pin-sweep: recorded-pin scan failed ` +
|
|
189
|
+
`(chat=${chatId}): ${(err as Error).message}\n`,
|
|
190
|
+
)
|
|
191
|
+
return []
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
protectedMessageIds: (chatId) =>
|
|
195
|
+
protectedPinIds({
|
|
196
|
+
chatId,
|
|
197
|
+
claims: w.claims(),
|
|
198
|
+
loadPinRows: w.loadPinRows,
|
|
199
|
+
now: now(),
|
|
200
|
+
log,
|
|
201
|
+
}),
|
|
202
|
+
eligible: w.eligible,
|
|
203
|
+
sleep: w.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
204
|
+
now,
|
|
205
|
+
store: w.store,
|
|
206
|
+
// Passed through UNCOERCED: undefined means "take the standing policy"
|
|
207
|
+
// (UNPIN_ALL_FORUM_TOPIC_ENABLED), not the same as an explicit false.
|
|
208
|
+
allowUnpinAllForumTopic: w.allowUnpinAllForumTopic,
|
|
209
|
+
log,
|
|
210
|
+
})
|
|
211
|
+
}
|