@llblab/pi-telegram 0.17.5 → 0.18.1
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/AGENTS.md +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +42 -15
- package/README.md +63 -35
- package/docs/README.md +3 -1
- package/docs/architecture.md +55 -23
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound.md +1 -1
- package/docs/locks.md +0 -2
- package/docs/multi-instance-bus.md +410 -0
- package/docs/outbound.md +4 -3
- package/docs/public-api.md +12 -10
- package/docs/sections.md +2 -2
- package/docs/ui-style.md +76 -0
- package/index.ts +789 -32
- package/lib/bindings.ts +68 -12
- package/lib/bus-api.ts +314 -0
- package/lib/bus-follower.ts +853 -0
- package/lib/bus-leader.ts +915 -0
- package/lib/bus.ts +866 -0
- package/lib/command-templates.ts +9 -11
- package/lib/commands.ts +133 -47
- package/lib/config.ts +53 -5
- package/lib/lifecycle.ts +23 -7
- package/lib/locks.ts +230 -66
- package/lib/media.ts +30 -2
- package/lib/menu-model.ts +48 -17
- package/lib/menu-queue.ts +51 -20
- package/lib/menu-settings.ts +9 -5
- package/lib/menu-status.ts +3 -0
- package/lib/menu-thinking.ts +3 -0
- package/lib/menu.ts +67 -26
- package/lib/outbound-attachments.ts +102 -17
- package/lib/outbound-buttons.ts +6 -2
- package/lib/outbound-voice.ts +31 -11
- package/lib/outbound.ts +6 -4
- package/lib/ownership.ts +119 -0
- package/lib/pi.ts +26 -3
- package/lib/polling.ts +477 -7
- package/lib/preview.ts +141 -88
- package/lib/prompt-templates.ts +3 -3
- package/lib/prompts.ts +80 -30
- package/lib/queue.ts +193 -91
- package/lib/rendering.ts +0 -25
- package/lib/replies.ts +187 -55
- package/lib/routing.ts +1673 -9
- package/lib/runtime-log.ts +123 -0
- package/lib/runtime.ts +84 -12
- package/lib/sections.ts +28 -21
- package/lib/setup.ts +9 -2
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +409 -41
- package/lib/text-groups.ts +5 -1
- package/lib/thread-reconciler.ts +915 -0
- package/lib/threads.ts +2205 -0
- package/lib/turns.ts +48 -3
- package/lib/updates.ts +355 -32
- package/package.json +24 -2
- package/docs/telegram-bot-api-rich-messages.md +0 -890
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bus follower runtime
|
|
3
|
+
* Zones: multi-instance bus, follower lifecycle, manual registration
|
|
4
|
+
* Owns this Pi instance's follower-side bus behavior: manual registration,
|
|
5
|
+
* heartbeat, forwarded-update receiving, and follower-routed API calls.
|
|
6
|
+
* It must not spawn Pi processes or create hidden Telegram-originated instances.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
|
|
11
|
+
import * as Sync from "./sync.ts";
|
|
12
|
+
import * as Threads from "./threads.ts";
|
|
13
|
+
import type { TelegramLockState } from "./locks.ts";
|
|
14
|
+
import type { TelegramTarget } from "./target.ts";
|
|
15
|
+
import {
|
|
16
|
+
createTelegramBusLocalServer,
|
|
17
|
+
createUnauthorizedBusAck,
|
|
18
|
+
getTelegramBusSocketPath,
|
|
19
|
+
sendTelegramBusLocalEnvelope,
|
|
20
|
+
type TelegramBusEnvelope,
|
|
21
|
+
} from "./bus.ts";
|
|
22
|
+
|
|
23
|
+
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
24
|
+
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
const TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY =
|
|
27
|
+
"__piTelegramFollowerSessionHandoff";
|
|
28
|
+
|
|
29
|
+
export interface TelegramFollowerSessionHandoff {
|
|
30
|
+
pid: number;
|
|
31
|
+
instanceId: string;
|
|
32
|
+
createdAtMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getTelegramFollowerSessionHandoff():
|
|
36
|
+
| TelegramFollowerSessionHandoff
|
|
37
|
+
| undefined {
|
|
38
|
+
const value = (globalThis as Record<string, unknown>)[
|
|
39
|
+
TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY
|
|
40
|
+
];
|
|
41
|
+
if (!value || typeof value !== "object") return undefined;
|
|
42
|
+
const handoff = value as Partial<TelegramFollowerSessionHandoff>;
|
|
43
|
+
if (
|
|
44
|
+
typeof handoff.pid !== "number" ||
|
|
45
|
+
typeof handoff.instanceId !== "string" ||
|
|
46
|
+
typeof handoff.createdAtMs !== "number"
|
|
47
|
+
) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return handoff as TelegramFollowerSessionHandoff;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setTelegramFollowerSessionHandoff(
|
|
54
|
+
handoff: TelegramFollowerSessionHandoff | undefined,
|
|
55
|
+
): void {
|
|
56
|
+
const store = globalThis as Record<string, unknown>;
|
|
57
|
+
if (!handoff) delete store[TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY];
|
|
58
|
+
else store[TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY] = handoff;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isTelegramFollowerSessionHandoffFresh(
|
|
62
|
+
handoff: TelegramFollowerSessionHandoff | undefined,
|
|
63
|
+
options: { pid?: number; nowMs?: number; ttlMs?: number } = {},
|
|
64
|
+
): handoff is TelegramFollowerSessionHandoff {
|
|
65
|
+
if (!handoff) return false;
|
|
66
|
+
const pid = options.pid ?? process.pid;
|
|
67
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
68
|
+
const ttlMs = options.ttlMs ?? TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS;
|
|
69
|
+
return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
73
|
+
registerWithLeader: (
|
|
74
|
+
ctx: TContext,
|
|
75
|
+
leader: { busSocketPath?: string; busSecret?: string },
|
|
76
|
+
) => Promise<boolean>;
|
|
77
|
+
setContext: (ctx: TContext) => void;
|
|
78
|
+
stop: () => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
|
|
82
|
+
registrationState: Pick<TelegramBusFollowerRegistrationState, "isRegistered">;
|
|
83
|
+
instanceId: string;
|
|
84
|
+
suspendPolling: () => Promise<void>;
|
|
85
|
+
recordRuntimeEvent: (
|
|
86
|
+
category: string,
|
|
87
|
+
error: unknown,
|
|
88
|
+
details?: Record<string, unknown>,
|
|
89
|
+
) => void;
|
|
90
|
+
getNowMs?: () => number;
|
|
91
|
+
getPid?: () => number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TelegramBusFollowerSessionRefreshHookDeps<TContext> {
|
|
95
|
+
registrationState: Pick<TelegramBusFollowerRegistrationState, "isRegistered">;
|
|
96
|
+
registrationRuntime: Pick<
|
|
97
|
+
TelegramBusFollowerRegistrationRuntime<TContext>,
|
|
98
|
+
"registerWithLeader" | "setContext"
|
|
99
|
+
>;
|
|
100
|
+
getLeaderState: () => TelegramLockState;
|
|
101
|
+
updateStatus: (ctx: TContext) => void;
|
|
102
|
+
recordRuntimeEvent: (
|
|
103
|
+
category: string,
|
|
104
|
+
error: unknown,
|
|
105
|
+
details?: Record<string, unknown>,
|
|
106
|
+
) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface TelegramBusFollowerRegistrationState {
|
|
110
|
+
isRegistered: () => boolean;
|
|
111
|
+
getTarget: () => TelegramTarget | undefined;
|
|
112
|
+
getSlot: () => string | undefined;
|
|
113
|
+
getThreadName: () => string | undefined;
|
|
114
|
+
setRegistered: (
|
|
115
|
+
registered: boolean,
|
|
116
|
+
target?: TelegramTarget,
|
|
117
|
+
metadata?: { slot?: string; threadName?: string },
|
|
118
|
+
) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TelegramBusForwardedUpdateReceiverRuntime {
|
|
122
|
+
start: () => Promise<void>;
|
|
123
|
+
stop: () => Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TelegramBusFollowerApiCallerDeps {
|
|
127
|
+
socketPath: string;
|
|
128
|
+
instanceId: string;
|
|
129
|
+
createRequestId: () => string;
|
|
130
|
+
getAuthSecret?: () => string | undefined;
|
|
131
|
+
getNowMs?: () => number;
|
|
132
|
+
timeoutMs?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface TelegramBusFollowerRegistrationRuntimeDeps<
|
|
136
|
+
TContext extends { cwd?: string },
|
|
137
|
+
> {
|
|
138
|
+
instanceId: string;
|
|
139
|
+
createRequestId: () => string;
|
|
140
|
+
getLeaderAuthSecret?: (leader: { busSecret?: string }) => string | undefined;
|
|
141
|
+
setActiveAuthSecret?: (secret: string | undefined) => void;
|
|
142
|
+
followerBusSocketPath?: string;
|
|
143
|
+
getLeaderSocketPath?: () => string;
|
|
144
|
+
startReceiving?: () => Promise<void>;
|
|
145
|
+
stopReceiving?: () => Promise<void> | void;
|
|
146
|
+
registrationState?: TelegramBusFollowerRegistrationState;
|
|
147
|
+
getProfileKey?: (ctx: TContext) => string | undefined;
|
|
148
|
+
getThreadName?: (ctx: TContext) => string | undefined;
|
|
149
|
+
getNowMs?: () => number;
|
|
150
|
+
getPid?: () => number;
|
|
151
|
+
timeoutMs?: number;
|
|
152
|
+
registrationTimeoutMs?: number;
|
|
153
|
+
heartbeatMs?: number;
|
|
154
|
+
recordRuntimeEvent?: (
|
|
155
|
+
category: string,
|
|
156
|
+
error: unknown,
|
|
157
|
+
details?: Record<string, unknown>,
|
|
158
|
+
) => void;
|
|
159
|
+
onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
|
|
163
|
+
topicTargetStore: Pick<
|
|
164
|
+
Threads.TelegramTopicTargetStore,
|
|
165
|
+
"load" | "list" | "markStaleByTarget" | "upsert" | "persist"
|
|
166
|
+
>;
|
|
167
|
+
registrationState: Pick<
|
|
168
|
+
TelegramBusFollowerRegistrationState,
|
|
169
|
+
"getTarget" | "setRegistered"
|
|
170
|
+
>;
|
|
171
|
+
instanceId: string;
|
|
172
|
+
manualFollowerProfileKey: string;
|
|
173
|
+
manualFollowerOwnerId: string;
|
|
174
|
+
getSyncState: () => Sync.TelegramSyncState;
|
|
175
|
+
setSyncState: (state: Sync.TelegramSyncState) => void;
|
|
176
|
+
getNowMs?: () => number;
|
|
177
|
+
updateStatus: (ctx: TContext) => void;
|
|
178
|
+
recordRuntimeEvent?: (
|
|
179
|
+
category: string,
|
|
180
|
+
error: unknown,
|
|
181
|
+
details?: Record<string, unknown>,
|
|
182
|
+
) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type TelegramBusFollowerLeaderState =
|
|
186
|
+
| { kind: "inactive" }
|
|
187
|
+
| { kind: "active-here"; lock: TelegramBusFollowerLeaderLock }
|
|
188
|
+
| { kind: "active-elsewhere"; lock: TelegramBusFollowerLeaderLock }
|
|
189
|
+
| { kind: "stale"; lock: TelegramBusFollowerLeaderLock };
|
|
190
|
+
|
|
191
|
+
export interface TelegramBusFollowerLeaderLock {
|
|
192
|
+
busSocketPath?: string;
|
|
193
|
+
busSecret?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
197
|
+
registrationState: Pick<
|
|
198
|
+
TelegramBusFollowerRegistrationState,
|
|
199
|
+
"setRegistered"
|
|
200
|
+
>;
|
|
201
|
+
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
202
|
+
getLeaderState: () => TelegramBusFollowerLeaderState;
|
|
203
|
+
setLifecyclePhase: (phase: "electing" | undefined) => void;
|
|
204
|
+
updateStatus: (ctx: TContext) => void;
|
|
205
|
+
promoteToLeader: (ctx: TContext) => Promise<void> | void;
|
|
206
|
+
sleep: (ms: number) => Promise<void>;
|
|
207
|
+
promotionGraceMs: number;
|
|
208
|
+
recordRuntimeEvent: (
|
|
209
|
+
category: string,
|
|
210
|
+
error: unknown,
|
|
211
|
+
details?: Record<string, unknown>,
|
|
212
|
+
) => void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
|
|
216
|
+
TContext,
|
|
217
|
+
TReactionUpdate,
|
|
218
|
+
TCallbackQuery,
|
|
219
|
+
TMessage = unknown,
|
|
220
|
+
> {
|
|
221
|
+
socketPath: string;
|
|
222
|
+
instanceId: string;
|
|
223
|
+
getAuthSecret?: () => string | undefined;
|
|
224
|
+
getContext: () => TContext | undefined;
|
|
225
|
+
handleForwardedCallback: (
|
|
226
|
+
query: TCallbackQuery,
|
|
227
|
+
ctx: TContext,
|
|
228
|
+
) => Promise<void> | void;
|
|
229
|
+
handleForwardedReaction: (
|
|
230
|
+
reactionUpdate: TReactionUpdate,
|
|
231
|
+
ctx: TContext,
|
|
232
|
+
) => Promise<void> | void;
|
|
233
|
+
handleForwardedMessage?: (
|
|
234
|
+
message: TMessage,
|
|
235
|
+
ctx: TContext,
|
|
236
|
+
) => Promise<void> | void;
|
|
237
|
+
handleForwardedEditedMessage?: (
|
|
238
|
+
message: TMessage,
|
|
239
|
+
ctx: TContext,
|
|
240
|
+
) => Promise<void> | void;
|
|
241
|
+
handleReplaceTarget?: (
|
|
242
|
+
input: {
|
|
243
|
+
target: TelegramTarget & { threadId: number };
|
|
244
|
+
oldTarget?: TelegramTarget & { threadId: number };
|
|
245
|
+
reason: "thread-restore";
|
|
246
|
+
},
|
|
247
|
+
ctx: TContext,
|
|
248
|
+
) => Promise<void> | void;
|
|
249
|
+
recordRuntimeEvent?: (
|
|
250
|
+
category: string,
|
|
251
|
+
error: unknown,
|
|
252
|
+
details?: Record<string, unknown>,
|
|
253
|
+
) => void;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
|
|
257
|
+
deps: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>,
|
|
258
|
+
): NonNullable<
|
|
259
|
+
TelegramBusForwardedUpdateReceiverRuntimeDeps<
|
|
260
|
+
TContext,
|
|
261
|
+
unknown,
|
|
262
|
+
unknown
|
|
263
|
+
>["handleReplaceTarget"]
|
|
264
|
+
> {
|
|
265
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
266
|
+
return async (input, ctx) => {
|
|
267
|
+
await deps.topicTargetStore.load();
|
|
268
|
+
const nowMs = getNowMs();
|
|
269
|
+
const currentRecord = Threads.findCurrentTelegramInstanceThreadRecord({
|
|
270
|
+
records: deps.topicTargetStore.list(),
|
|
271
|
+
instanceId: deps.instanceId,
|
|
272
|
+
preferredTarget: input.oldTarget ?? deps.registrationState.getTarget(),
|
|
273
|
+
});
|
|
274
|
+
if (input.oldTarget) {
|
|
275
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
276
|
+
input.oldTarget,
|
|
277
|
+
"deleted",
|
|
278
|
+
"Follower thread was replaced by thread restore.",
|
|
279
|
+
);
|
|
280
|
+
} else if (currentRecord) {
|
|
281
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
282
|
+
currentRecord.target,
|
|
283
|
+
"deleted",
|
|
284
|
+
"Follower thread was replaced by thread restore.",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const profileKey =
|
|
288
|
+
currentRecord?.profileKey ?? deps.manualFollowerProfileKey;
|
|
289
|
+
deps.topicTargetStore.upsert({
|
|
290
|
+
profileKey,
|
|
291
|
+
owner: {
|
|
292
|
+
kind: "manual-follower",
|
|
293
|
+
instanceId: deps.manualFollowerOwnerId,
|
|
294
|
+
},
|
|
295
|
+
target: input.target,
|
|
296
|
+
status: "active",
|
|
297
|
+
syncStatus: "open",
|
|
298
|
+
createdAtMs: currentRecord?.createdAtMs ?? nowMs,
|
|
299
|
+
updatedAtMs: nowMs,
|
|
300
|
+
lastSyncObservedAtMs: nowMs,
|
|
301
|
+
lastReconcileAction: "follower-thread-restore",
|
|
302
|
+
instanceId: deps.instanceId,
|
|
303
|
+
slot: currentRecord?.slot,
|
|
304
|
+
threadName: currentRecord?.threadName,
|
|
305
|
+
rerouteConfirmedAtMs: nowMs,
|
|
306
|
+
});
|
|
307
|
+
deps.registrationState.setRegistered(true, input.target);
|
|
308
|
+
deps.setSyncState(
|
|
309
|
+
Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
310
|
+
nowMs,
|
|
311
|
+
action: "follower-thread-restore",
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
await deps.topicTargetStore.persist();
|
|
315
|
+
deps.updateStatus(ctx);
|
|
316
|
+
deps.recordRuntimeEvent?.(
|
|
317
|
+
"bus",
|
|
318
|
+
"Telegram follower thread target replaced",
|
|
319
|
+
{
|
|
320
|
+
phase: "follower-thread-restore",
|
|
321
|
+
chatId: input.target.chatId,
|
|
322
|
+
threadId: input.target.threadId,
|
|
323
|
+
oldThreadId:
|
|
324
|
+
input.oldTarget?.threadId ?? currentRecord?.target.threadId,
|
|
325
|
+
slot: currentRecord?.slot,
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function createTelegramBusFollowerApiCaller(
|
|
332
|
+
deps: TelegramBusFollowerApiCallerDeps,
|
|
333
|
+
): (method: string, args: unknown[]) => Promise<unknown> {
|
|
334
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
335
|
+
const timeoutMs = deps.timeoutMs ?? 30000;
|
|
336
|
+
return async (method, args) => {
|
|
337
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
338
|
+
socketPath: deps.socketPath,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
envelope: {
|
|
341
|
+
kind: "follower.callApi",
|
|
342
|
+
requestId: deps.createRequestId(),
|
|
343
|
+
auth: deps.getAuthSecret?.(),
|
|
344
|
+
instanceId: deps.instanceId,
|
|
345
|
+
method,
|
|
346
|
+
args,
|
|
347
|
+
sentAtMs: getNowMs(),
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (response?.kind === "bus.ack" && response.ok) return response.result;
|
|
351
|
+
const message =
|
|
352
|
+
response?.kind === "bus.ack"
|
|
353
|
+
? response.message
|
|
354
|
+
: "Telegram bus API call did not return an acknowledgement.";
|
|
355
|
+
throw new Error(message ?? "Telegram bus API call failed.");
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isTelegramStaleContextError(error: unknown): boolean {
|
|
360
|
+
return (
|
|
361
|
+
error instanceof Error &&
|
|
362
|
+
(error.message.includes("stale after session") ||
|
|
363
|
+
error.message.includes("stale ctx"))
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function createTelegramBusFollowerSessionReplacementSuspender(
|
|
368
|
+
deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
|
|
369
|
+
): () => Promise<void> {
|
|
370
|
+
return async () => {
|
|
371
|
+
if (deps.registrationState.isRegistered()) {
|
|
372
|
+
setTelegramFollowerSessionHandoff({
|
|
373
|
+
pid: (deps.getPid ?? (() => process.pid))(),
|
|
374
|
+
instanceId: deps.instanceId,
|
|
375
|
+
createdAtMs: (deps.getNowMs ?? Date.now)(),
|
|
376
|
+
});
|
|
377
|
+
deps.recordRuntimeEvent(
|
|
378
|
+
"bus",
|
|
379
|
+
"Telegram follower registration preserved",
|
|
380
|
+
{
|
|
381
|
+
phase: "follower-session-preserve",
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await deps.suspendPolling();
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function createTelegramBusFollowerSessionRefreshHook<TContext>(
|
|
391
|
+
deps: TelegramBusFollowerSessionRefreshHookDeps<TContext>,
|
|
392
|
+
): (_event: unknown, ctx: TContext) => Promise<void> {
|
|
393
|
+
return async (_event, ctx) => {
|
|
394
|
+
if (!deps.registrationState.isRegistered()) {
|
|
395
|
+
const handoff = getTelegramFollowerSessionHandoff();
|
|
396
|
+
const lockState = deps.getLeaderState();
|
|
397
|
+
const handoffIsFresh = isTelegramFollowerSessionHandoffFresh(handoff);
|
|
398
|
+
if (handoffIsFresh && lockState.kind === "active-elsewhere") {
|
|
399
|
+
try {
|
|
400
|
+
const restored = await deps.registrationRuntime.registerWithLeader(
|
|
401
|
+
ctx,
|
|
402
|
+
lockState.lock,
|
|
403
|
+
);
|
|
404
|
+
if (restored) {
|
|
405
|
+
setTelegramFollowerSessionHandoff(undefined);
|
|
406
|
+
deps.updateStatus(ctx);
|
|
407
|
+
deps.recordRuntimeEvent(
|
|
408
|
+
"bus",
|
|
409
|
+
"Telegram follower registration restored after session replacement",
|
|
410
|
+
{
|
|
411
|
+
phase: "follower-session-restore",
|
|
412
|
+
previousInstanceId: handoff.instanceId,
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
deps.recordRuntimeEvent("bus", error, {
|
|
418
|
+
phase: "follower-session-restore",
|
|
419
|
+
previousInstanceId: handoff?.instanceId,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
} else if (handoff) {
|
|
423
|
+
setTelegramFollowerSessionHandoff(undefined);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (!deps.registrationState.isRegistered()) return;
|
|
427
|
+
deps.registrationRuntime.setContext(ctx);
|
|
428
|
+
deps.updateStatus(ctx);
|
|
429
|
+
deps.recordRuntimeEvent(
|
|
430
|
+
"bus",
|
|
431
|
+
"Telegram follower session context refreshed",
|
|
432
|
+
{ phase: "follower-session-refresh" },
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function createTelegramBusFollowerRegistrationState(): TelegramBusFollowerRegistrationState {
|
|
438
|
+
let registered = false;
|
|
439
|
+
let target: TelegramTarget | undefined;
|
|
440
|
+
let slot: string | undefined;
|
|
441
|
+
let threadName: string | undefined;
|
|
442
|
+
return {
|
|
443
|
+
isRegistered: () => registered,
|
|
444
|
+
getTarget: () => (target ? { ...target } : undefined),
|
|
445
|
+
getSlot: () => slot,
|
|
446
|
+
getThreadName: () => threadName,
|
|
447
|
+
setRegistered: (next, nextTarget, metadata) => {
|
|
448
|
+
registered = next;
|
|
449
|
+
target = next ? (nextTarget ? { ...nextTarget } : undefined) : undefined;
|
|
450
|
+
slot = next ? metadata?.slot : undefined;
|
|
451
|
+
threadName = next ? metadata?.threadName : undefined;
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
457
|
+
deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
|
|
458
|
+
): (error: unknown, ctx: TContext) => Promise<void> {
|
|
459
|
+
let promotionPending = false;
|
|
460
|
+
const safeUpdateStatus = (ctx: TContext) => {
|
|
461
|
+
try {
|
|
462
|
+
deps.updateStatus(ctx);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (!isTelegramStaleContextError(error)) throw error;
|
|
465
|
+
deps.recordRuntimeEvent("bus", error, {
|
|
466
|
+
phase: "follower-stale-context-status",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const clearRegisteredState = (ctx: TContext) => {
|
|
471
|
+
deps.registrationState.setRegistered(false);
|
|
472
|
+
safeUpdateStatus(ctx);
|
|
473
|
+
};
|
|
474
|
+
const tryRegisterWithLeader = async (
|
|
475
|
+
ctx: TContext,
|
|
476
|
+
leader: TelegramBusFollowerLeaderLock,
|
|
477
|
+
phase: string,
|
|
478
|
+
) => {
|
|
479
|
+
try {
|
|
480
|
+
const restored = await deps
|
|
481
|
+
.getRegistrationRuntime()
|
|
482
|
+
.registerWithLeader(ctx, leader);
|
|
483
|
+
if (!restored) return false;
|
|
484
|
+
deps.setLifecyclePhase(undefined);
|
|
485
|
+
safeUpdateStatus(ctx);
|
|
486
|
+
deps.recordRuntimeEvent(
|
|
487
|
+
"bus",
|
|
488
|
+
"Telegram follower registration restored",
|
|
489
|
+
{
|
|
490
|
+
phase,
|
|
491
|
+
},
|
|
492
|
+
);
|
|
493
|
+
return true;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
clearRegisteredState(ctx);
|
|
496
|
+
deps.recordRuntimeEvent("bus", error, { phase });
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const promoteToLeader = async (reason: unknown, ctx: TContext) => {
|
|
501
|
+
deps.setLifecyclePhase("electing");
|
|
502
|
+
safeUpdateStatus(ctx);
|
|
503
|
+
deps.recordRuntimeEvent("bus", reason, {
|
|
504
|
+
phase: "follower-promotion-electing",
|
|
505
|
+
});
|
|
506
|
+
deps.getRegistrationRuntime().stop();
|
|
507
|
+
deps.setLifecyclePhase("electing");
|
|
508
|
+
safeUpdateStatus(ctx);
|
|
509
|
+
deps.recordRuntimeEvent("bus", "Telegram follower elected for promotion", {
|
|
510
|
+
phase: "follower-promotion-electing",
|
|
511
|
+
});
|
|
512
|
+
await deps.promoteToLeader(ctx);
|
|
513
|
+
deps.setLifecyclePhase(undefined);
|
|
514
|
+
safeUpdateStatus(ctx);
|
|
515
|
+
deps.recordRuntimeEvent("bus", "Telegram follower promotion completed", {
|
|
516
|
+
phase: "follower-promotion-complete",
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
return async (error, ctx) => {
|
|
520
|
+
if (promotionPending) return;
|
|
521
|
+
promotionPending = true;
|
|
522
|
+
try {
|
|
523
|
+
const state = deps.getLeaderState();
|
|
524
|
+
if (state.kind === "active-elsewhere") {
|
|
525
|
+
clearRegisteredState(ctx);
|
|
526
|
+
if (
|
|
527
|
+
await tryRegisterWithLeader(
|
|
528
|
+
ctx,
|
|
529
|
+
state.lock,
|
|
530
|
+
"follower-register-restore",
|
|
531
|
+
)
|
|
532
|
+
) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
deps.setLifecyclePhase("electing");
|
|
536
|
+
safeUpdateStatus(ctx);
|
|
537
|
+
deps.recordRuntimeEvent(
|
|
538
|
+
"bus",
|
|
539
|
+
"Telegram follower waiting for leader reload recovery",
|
|
540
|
+
{ phase: "follower-promotion-grace" },
|
|
541
|
+
);
|
|
542
|
+
await deps.sleep(deps.promotionGraceMs);
|
|
543
|
+
const graceState = deps.getLeaderState();
|
|
544
|
+
if (graceState.kind === "active-elsewhere") {
|
|
545
|
+
if (
|
|
546
|
+
await tryRegisterWithLeader(
|
|
547
|
+
ctx,
|
|
548
|
+
graceState.lock,
|
|
549
|
+
"follower-register-restore-grace",
|
|
550
|
+
)
|
|
551
|
+
) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await promoteToLeader(error, ctx);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (graceState.kind === "stale" || graceState.kind === "inactive") {
|
|
558
|
+
await promoteToLeader(error, ctx);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (state.kind === "stale" || state.kind === "inactive") {
|
|
563
|
+
await promoteToLeader(error, ctx);
|
|
564
|
+
}
|
|
565
|
+
} catch (promotionError) {
|
|
566
|
+
deps.setLifecyclePhase(undefined);
|
|
567
|
+
safeUpdateStatus(ctx);
|
|
568
|
+
if (isTelegramStaleContextError(promotionError)) {
|
|
569
|
+
deps.recordRuntimeEvent("bus", promotionError, {
|
|
570
|
+
phase: "follower-heartbeat-stale-context",
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
throw promotionError;
|
|
575
|
+
} finally {
|
|
576
|
+
promotionPending = false;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function createTelegramBusFollowerRegistrationRuntime<
|
|
582
|
+
TContext extends { cwd?: string },
|
|
583
|
+
>(
|
|
584
|
+
deps: TelegramBusFollowerRegistrationRuntimeDeps<TContext>,
|
|
585
|
+
): TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
586
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
587
|
+
const getPid = deps.getPid ?? (() => process.pid);
|
|
588
|
+
const heartbeatMs = deps.heartbeatMs ?? 1000;
|
|
589
|
+
const registrationTimeoutMs =
|
|
590
|
+
deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
|
|
591
|
+
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
|
592
|
+
let activeLeaderSocketPath: string | undefined;
|
|
593
|
+
let activeAuthSecret: string | undefined;
|
|
594
|
+
let activeContext: TContext | undefined;
|
|
595
|
+
const stopHeartbeat = () => {
|
|
596
|
+
if (!heartbeatInterval) return;
|
|
597
|
+
clearInterval(heartbeatInterval);
|
|
598
|
+
heartbeatInterval = undefined;
|
|
599
|
+
};
|
|
600
|
+
const stop = () => {
|
|
601
|
+
stopHeartbeat();
|
|
602
|
+
activeAuthSecret = undefined;
|
|
603
|
+
deps.setActiveAuthSecret?.(undefined);
|
|
604
|
+
deps.registrationState?.setRegistered(false);
|
|
605
|
+
activeContext = undefined;
|
|
606
|
+
void deps.stopReceiving?.();
|
|
607
|
+
};
|
|
608
|
+
const sendHeartbeat = async () => {
|
|
609
|
+
if (!activeLeaderSocketPath) return;
|
|
610
|
+
try {
|
|
611
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
612
|
+
socketPath: activeLeaderSocketPath,
|
|
613
|
+
timeoutMs: deps.timeoutMs,
|
|
614
|
+
envelope: {
|
|
615
|
+
kind: "follower.heartbeat",
|
|
616
|
+
requestId: deps.createRequestId(),
|
|
617
|
+
auth: activeAuthSecret,
|
|
618
|
+
instanceId: deps.instanceId,
|
|
619
|
+
sentAtMs: getNowMs(),
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
if (response?.kind === "bus.ack" && !response.ok) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
response.message ?? "Telegram bus follower heartbeat was rejected.",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
} catch (error) {
|
|
628
|
+
deps.recordRuntimeEvent?.("bus", error, { phase: "follower-heartbeat" });
|
|
629
|
+
if (activeContext) await deps.onHeartbeatFailure?.(error, activeContext);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const startHeartbeat = (socketPath: string) => {
|
|
633
|
+
stopHeartbeat();
|
|
634
|
+
activeLeaderSocketPath = socketPath;
|
|
635
|
+
heartbeatInterval = setInterval(() => {
|
|
636
|
+
void sendHeartbeat();
|
|
637
|
+
}, heartbeatMs);
|
|
638
|
+
heartbeatInterval.unref?.();
|
|
639
|
+
};
|
|
640
|
+
return {
|
|
641
|
+
registerWithLeader: async (ctx, leader) => {
|
|
642
|
+
const leaderSocketPath =
|
|
643
|
+
leader.busSocketPath ??
|
|
644
|
+
deps.getLeaderSocketPath?.() ??
|
|
645
|
+
getTelegramBusSocketPath();
|
|
646
|
+
await deps.startReceiving?.();
|
|
647
|
+
activeAuthSecret = deps.getLeaderAuthSecret?.(leader);
|
|
648
|
+
deps.setActiveAuthSecret?.(activeAuthSecret);
|
|
649
|
+
let response: TelegramBusEnvelope | undefined;
|
|
650
|
+
try {
|
|
651
|
+
response = await sendTelegramBusLocalEnvelope({
|
|
652
|
+
socketPath: leaderSocketPath,
|
|
653
|
+
timeoutMs: registrationTimeoutMs,
|
|
654
|
+
envelope: {
|
|
655
|
+
kind: "follower.register",
|
|
656
|
+
requestId: deps.createRequestId(),
|
|
657
|
+
auth: activeAuthSecret,
|
|
658
|
+
registration: {
|
|
659
|
+
instanceId: deps.instanceId,
|
|
660
|
+
profileKey:
|
|
661
|
+
deps.getProfileKey?.(ctx) ??
|
|
662
|
+
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
|
|
663
|
+
threadName:
|
|
664
|
+
deps.getThreadName?.(ctx) ??
|
|
665
|
+
(ctx.cwd ? basename(ctx.cwd) : undefined),
|
|
666
|
+
cwd: ctx.cwd,
|
|
667
|
+
pid: getPid(),
|
|
668
|
+
busSocketPath: deps.followerBusSocketPath,
|
|
669
|
+
connectedAtMs: getNowMs(),
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
stopHeartbeat();
|
|
675
|
+
activeLeaderSocketPath = undefined;
|
|
676
|
+
activeAuthSecret = undefined;
|
|
677
|
+
deps.registrationState?.setRegistered(false);
|
|
678
|
+
deps.setActiveAuthSecret?.(undefined);
|
|
679
|
+
await deps.stopReceiving?.();
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
if (response?.kind === "bus.ack" && !response.ok) {
|
|
683
|
+
stopHeartbeat();
|
|
684
|
+
activeLeaderSocketPath = undefined;
|
|
685
|
+
activeAuthSecret = undefined;
|
|
686
|
+
deps.registrationState?.setRegistered(false);
|
|
687
|
+
deps.setActiveAuthSecret?.(undefined);
|
|
688
|
+
await deps.stopReceiving?.();
|
|
689
|
+
throw new Error(
|
|
690
|
+
response.message ??
|
|
691
|
+
"Telegram bus follower registration was rejected.",
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (response?.kind === "bus.ack" && response.ok) {
|
|
695
|
+
const registrationResult = parseRegistrationResult(response.result);
|
|
696
|
+
deps.registrationState?.setRegistered(
|
|
697
|
+
true,
|
|
698
|
+
registrationResult.target,
|
|
699
|
+
registrationResult,
|
|
700
|
+
);
|
|
701
|
+
activeLeaderSocketPath = leaderSocketPath;
|
|
702
|
+
activeContext = ctx;
|
|
703
|
+
await sendHeartbeat();
|
|
704
|
+
startHeartbeat(leaderSocketPath);
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
stopHeartbeat();
|
|
708
|
+
activeLeaderSocketPath = undefined;
|
|
709
|
+
activeAuthSecret = undefined;
|
|
710
|
+
deps.registrationState?.setRegistered(false);
|
|
711
|
+
deps.setActiveAuthSecret?.(undefined);
|
|
712
|
+
await deps.stopReceiving?.();
|
|
713
|
+
return false;
|
|
714
|
+
},
|
|
715
|
+
setContext(ctx) {
|
|
716
|
+
activeContext = ctx;
|
|
717
|
+
},
|
|
718
|
+
stop,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function createTelegramBusForwardedUpdateReceiverRuntime<
|
|
723
|
+
TContext,
|
|
724
|
+
TReactionUpdate,
|
|
725
|
+
TCallbackQuery,
|
|
726
|
+
TMessage = unknown,
|
|
727
|
+
>(
|
|
728
|
+
deps: TelegramBusForwardedUpdateReceiverRuntimeDeps<
|
|
729
|
+
TContext,
|
|
730
|
+
TReactionUpdate,
|
|
731
|
+
TCallbackQuery,
|
|
732
|
+
TMessage
|
|
733
|
+
>,
|
|
734
|
+
): TelegramBusForwardedUpdateReceiverRuntime {
|
|
735
|
+
const server = createTelegramBusLocalServer({
|
|
736
|
+
socketPath: deps.socketPath,
|
|
737
|
+
async handleEnvelope(envelope) {
|
|
738
|
+
const authSecret = deps.getAuthSecret?.();
|
|
739
|
+
if (deps.getAuthSecret && (!authSecret || envelope.auth !== authSecret)) {
|
|
740
|
+
return createUnauthorizedBusAck(envelope.requestId);
|
|
741
|
+
}
|
|
742
|
+
if (
|
|
743
|
+
(envelope.kind !== "leader.forwardCallback" &&
|
|
744
|
+
envelope.kind !== "leader.forwardReaction" &&
|
|
745
|
+
envelope.kind !== "leader.forwardMessage" &&
|
|
746
|
+
envelope.kind !== "leader.forwardEditedMessage" &&
|
|
747
|
+
envelope.kind !== "leader.replaceFollowerTarget") ||
|
|
748
|
+
envelope.recipientInstanceId !== deps.instanceId
|
|
749
|
+
) {
|
|
750
|
+
return {
|
|
751
|
+
kind: "bus.ack",
|
|
752
|
+
requestId: envelope.requestId,
|
|
753
|
+
ok: false,
|
|
754
|
+
message: "Telegram bus receiver cannot handle this envelope.",
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const ctx = deps.getContext();
|
|
758
|
+
if (!ctx) {
|
|
759
|
+
return {
|
|
760
|
+
kind: "bus.ack",
|
|
761
|
+
requestId: envelope.requestId,
|
|
762
|
+
ok: false,
|
|
763
|
+
message: "Telegram bus follower has no active context.",
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
if (envelope.kind === "leader.forwardCallback") {
|
|
768
|
+
await deps.handleForwardedCallback(
|
|
769
|
+
envelope.query as TCallbackQuery,
|
|
770
|
+
ctx,
|
|
771
|
+
);
|
|
772
|
+
} else if (envelope.kind === "leader.forwardReaction") {
|
|
773
|
+
await deps.handleForwardedReaction(
|
|
774
|
+
envelope.reactionUpdate as TReactionUpdate,
|
|
775
|
+
ctx,
|
|
776
|
+
);
|
|
777
|
+
} else if (envelope.kind === "leader.forwardMessage") {
|
|
778
|
+
if (!deps.handleForwardedMessage) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
"Telegram bus receiver cannot handle this envelope.",
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
await deps.handleForwardedMessage(envelope.message as TMessage, ctx);
|
|
784
|
+
} else if (envelope.kind === "leader.forwardEditedMessage") {
|
|
785
|
+
if (!deps.handleForwardedEditedMessage) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
"Telegram bus receiver cannot handle this envelope.",
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
await deps.handleForwardedEditedMessage(
|
|
791
|
+
envelope.message as TMessage,
|
|
792
|
+
ctx,
|
|
793
|
+
);
|
|
794
|
+
} else {
|
|
795
|
+
if (!deps.handleReplaceTarget) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
"Telegram bus receiver cannot replace follower target.",
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
await deps.handleReplaceTarget(
|
|
801
|
+
{
|
|
802
|
+
target: envelope.target,
|
|
803
|
+
...(envelope.oldTarget ? { oldTarget: envelope.oldTarget } : {}),
|
|
804
|
+
reason: envelope.reason,
|
|
805
|
+
},
|
|
806
|
+
ctx,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
810
|
+
} catch (error) {
|
|
811
|
+
deps.recordRuntimeEvent?.("bus", error, { phase: "follower-forward" });
|
|
812
|
+
return {
|
|
813
|
+
kind: "bus.ack",
|
|
814
|
+
requestId: envelope.requestId,
|
|
815
|
+
ok: false,
|
|
816
|
+
message:
|
|
817
|
+
error instanceof Error
|
|
818
|
+
? error.message
|
|
819
|
+
: "Telegram bus follower dispatch failed.",
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
return server;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function parseRegistrationResult(value: unknown): {
|
|
828
|
+
target?: TelegramTarget;
|
|
829
|
+
slot?: string;
|
|
830
|
+
threadName?: string;
|
|
831
|
+
} {
|
|
832
|
+
if (!isRecord(value)) return {};
|
|
833
|
+
const target = parseTarget(isRecord(value.target) ? value.target : value);
|
|
834
|
+
return {
|
|
835
|
+
...(target ? { target } : {}),
|
|
836
|
+
...(typeof value.slot === "string" ? { slot: value.slot } : {}),
|
|
837
|
+
...(typeof value.threadName === "string"
|
|
838
|
+
? { threadName: value.threadName }
|
|
839
|
+
: {}),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function parseTarget(value: unknown): TelegramTarget | undefined {
|
|
844
|
+
if (value === undefined) return undefined;
|
|
845
|
+
if (!isRecord(value) || typeof value.chatId !== "number") return undefined;
|
|
846
|
+
const target: TelegramTarget = { chatId: value.chatId };
|
|
847
|
+
if (typeof value.threadId === "number") target.threadId = value.threadId;
|
|
848
|
+
return target;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
852
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
853
|
+
}
|