@llblab/pi-telegram 0.54.4 → 0.55.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 +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +3 -1
- package/dist/lib/bindings.js +8 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +50 -3
- package/dist/lib/bus-follower.js +313 -146
- package/dist/lib/bus-leader.d.ts +3 -0
- package/dist/lib/bus-leader.js +99 -18
- package/dist/lib/bus.d.ts +7 -3
- package/dist/lib/bus.js +24 -16
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +48 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +7 -2
- package/dist/lib/routing.js +585 -229
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +18 -5
- package/dist/lib/threads.js +107 -30
- package/dist/lib/turns.d.ts +6 -0
- package/dist/lib/turns.js +12 -1
- package/dist/lib/updates.d.ts +13 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +14 -11
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +20 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +2 -2
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +9 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +30 -18
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +51 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +614 -257
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +120 -36
- package/lib/turns.ts +15 -1
- package/lib/updates.ts +60 -21
- package/package.json +1 -1
package/dist/lib/telegram-api.js
CHANGED
|
@@ -68,7 +68,24 @@ function parseTelegramApiTargetInteger(value) {
|
|
|
68
68
|
? parsed
|
|
69
69
|
: undefined;
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
/** Optional drain hints are not transport outcomes or request authority. */
|
|
72
|
+
function captureApiDrainObserver(capture) {
|
|
73
|
+
try {
|
|
74
|
+
const notify = capture?.();
|
|
75
|
+
return () => {
|
|
76
|
+
try {
|
|
77
|
+
const emitted = notify?.();
|
|
78
|
+
if (emitted)
|
|
79
|
+
void Promise.resolve(emitted).catch(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
catch { /* Observer failure is inert. */ }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return () => { };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function createTelegramApiTargetActivityRuntime(deps = {}) {
|
|
72
89
|
const pending = new Map();
|
|
73
90
|
const pendingChats = new Map();
|
|
74
91
|
const messageScopedMethods = new Set([
|
|
@@ -103,6 +120,7 @@ export function createTelegramApiTargetActivityRuntime() {
|
|
|
103
120
|
else {
|
|
104
121
|
pendingChats.set(chatId, (pendingChats.get(chatId) ?? 0) + 1);
|
|
105
122
|
}
|
|
123
|
+
const notifyDrained = captureApiDrainObserver(deps.captureDrain);
|
|
106
124
|
let completed = false;
|
|
107
125
|
return () => {
|
|
108
126
|
if (completed)
|
|
@@ -110,15 +128,19 @@ export function createTelegramApiTargetActivityRuntime() {
|
|
|
110
128
|
completed = true;
|
|
111
129
|
if (!key) {
|
|
112
130
|
const count = pendingChats.get(chatId);
|
|
113
|
-
if (!count || count <= 1)
|
|
131
|
+
if (!count || count <= 1) {
|
|
114
132
|
pendingChats.delete(chatId);
|
|
133
|
+
notifyDrained();
|
|
134
|
+
}
|
|
115
135
|
else
|
|
116
136
|
pendingChats.set(chatId, count - 1);
|
|
117
137
|
return;
|
|
118
138
|
}
|
|
119
139
|
const current = pending.get(key);
|
|
120
|
-
if (!current || current.count <= 1)
|
|
140
|
+
if (!current || current.count <= 1) {
|
|
121
141
|
pending.delete(key);
|
|
142
|
+
notifyDrained();
|
|
143
|
+
}
|
|
122
144
|
else
|
|
123
145
|
current.count -= 1;
|
|
124
146
|
};
|
|
@@ -953,6 +975,7 @@ export function buildTelegramAnswerGuestQueryBody(guestQueryId, text, options) {
|
|
|
953
975
|
export function createDefaultTelegramBridgeApiRuntime(deps) {
|
|
954
976
|
const client = createTelegramApiClient(deps.getBotToken, {
|
|
955
977
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
978
|
+
isThreadedMode: deps.isThreadedMode,
|
|
956
979
|
});
|
|
957
980
|
const admittedClient = deps.workspaceAdmission
|
|
958
981
|
? createTelegramApiWorkspaceAdmissionClient(client, deps.workspaceAdmission, {
|
|
@@ -1064,6 +1087,8 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1064
1087
|
request = Promise.resolve()
|
|
1065
1088
|
.then(() => deps.client.call(method, body, {
|
|
1066
1089
|
...options,
|
|
1090
|
+
// Presence is replaceable, not a queued body to replay after its session/loop owner ends.
|
|
1091
|
+
...(body.action === "typing" ? { maxAttempts: 1, retrySafety: "non-idempotent" } : {}),
|
|
1067
1092
|
retryRateLimit: false,
|
|
1068
1093
|
}))
|
|
1069
1094
|
.then((result) => {
|
|
@@ -1164,7 +1189,7 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1164
1189
|
...(options?.message_thread_id !== undefined
|
|
1165
1190
|
? { message_thread_id: options.message_thread_id }
|
|
1166
1191
|
: {}),
|
|
1167
|
-
}), "typing"),
|
|
1192
|
+
}, { assertAuthority: options?.assertAuthority }), "typing"),
|
|
1168
1193
|
sendRecordVoiceAction: createTelegramChatActionSender((chatId, action, options) => callRecorded("sendChatAction", {
|
|
1169
1194
|
chat_id: chatId,
|
|
1170
1195
|
action,
|
|
@@ -1266,9 +1291,31 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1266
1291
|
export function createTelegramApiClient(getBotToken, options = {}) {
|
|
1267
1292
|
const now = options.now ?? Date.now;
|
|
1268
1293
|
const recordRuntimeEvent = options.recordRuntimeEvent;
|
|
1294
|
+
const isThreadedMode = options.isThreadedMode;
|
|
1295
|
+
const fenceOutput = (method, body, callOptions) => {
|
|
1296
|
+
if (!isThreadedMode || !/^(send[A-Z]|copyMessages?$|forwardMessages?$)/.test(method))
|
|
1297
|
+
return callOptions;
|
|
1298
|
+
const assertAuthority = () => {
|
|
1299
|
+
const chatId = typeof body.chat_id === "number" || typeof body.chat_id === "string"
|
|
1300
|
+
? Number(body.chat_id) : undefined;
|
|
1301
|
+
const thread = body.message_thread_id;
|
|
1302
|
+
const threadId = typeof thread === "number" || (typeof thread === "string" && /^[1-9][0-9]*$/.test(thread))
|
|
1303
|
+
? Number(thread) : undefined;
|
|
1304
|
+
// Business connections and channels are distinct from the bot's private-chat All namespace.
|
|
1305
|
+
if (isThreadedMode() && chatId !== undefined && Number.isSafeInteger(chatId) && chatId > 0 &&
|
|
1306
|
+
!(typeof body.business_connection_id === "string" && body.business_connection_id.length > 0) &&
|
|
1307
|
+
(threadId === undefined || !Number.isSafeInteger(threadId) || threadId <= 0))
|
|
1308
|
+
throw new Error("Telegram All is input-only in Threaded Mode; an explicit Thread target is required.");
|
|
1309
|
+
callOptions?.assertAuthority?.();
|
|
1310
|
+
};
|
|
1311
|
+
assertAuthority();
|
|
1312
|
+
return { ...callOptions, assertAuthority };
|
|
1313
|
+
};
|
|
1269
1314
|
const draftRetryNotBeforeByTarget = new Map();
|
|
1270
1315
|
return {
|
|
1271
1316
|
call: async (method, body, options) => {
|
|
1317
|
+
body = { ...body };
|
|
1318
|
+
options = fenceOutput(method, body, options);
|
|
1272
1319
|
const token = getBotToken();
|
|
1273
1320
|
// Cooldown keys retain only the public bot-id prefix, not the credential.
|
|
1274
1321
|
const botId = token?.match(/^(\d+):/)?.[1];
|
|
@@ -1316,6 +1363,8 @@ export function createTelegramApiClient(getBotToken, options = {}) {
|
|
|
1316
1363
|
}
|
|
1317
1364
|
},
|
|
1318
1365
|
callMultipart: async (method, fields, fileField, filePath, fileName, options) => {
|
|
1366
|
+
fields = { ...fields };
|
|
1367
|
+
options = fenceOutput(method, fields, options);
|
|
1319
1368
|
return callTelegramMultipart(getBotToken(), method, fields, fileField, filePath, fileName, options);
|
|
1320
1369
|
},
|
|
1321
1370
|
downloadFile: async (fileId, suggestedName, tempDir, options) => {
|
|
@@ -22,18 +22,6 @@ export interface TelegramTextGroupMessage {
|
|
|
22
22
|
forward_from?: TelegramMessageUser;
|
|
23
23
|
forward_sender_name?: string;
|
|
24
24
|
}
|
|
25
|
-
export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
26
|
-
messages: TMessage[];
|
|
27
|
-
context?: TContext;
|
|
28
|
-
flushTimer?: ReturnType<typeof setTimeout>;
|
|
29
|
-
dispatching?: boolean;
|
|
30
|
-
suspended?: boolean;
|
|
31
|
-
reschedule?: (delayMs?: number) => void;
|
|
32
|
-
dispatchNow?: () => Promise<void>;
|
|
33
|
-
dispatchPromise?: Promise<void>;
|
|
34
|
-
dispatchLimit?: number;
|
|
35
|
-
forwardPairCandidate?: TelegramForwardCommentBatchPosition;
|
|
36
|
-
}
|
|
37
25
|
export type TelegramForwardCommentBatchPosition = "comment" | "forward";
|
|
38
26
|
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
39
27
|
prepareUpdateBatch: (updates: readonly {
|
|
@@ -65,20 +53,6 @@ export interface TelegramGroupedInputClearerDeps {
|
|
|
65
53
|
clearMediaGroups: () => void;
|
|
66
54
|
clearTextGroups: () => void;
|
|
67
55
|
}
|
|
68
|
-
export declare function queueTelegramTextGroupMessage<TMessage extends TelegramTextGroupMessage, TContext = unknown>(options: {
|
|
69
|
-
message: TMessage;
|
|
70
|
-
context: TContext;
|
|
71
|
-
groups: Map<string, TelegramTextGroupState<TMessage, TContext>>;
|
|
72
|
-
debounceMs: number;
|
|
73
|
-
minSplitLength: number;
|
|
74
|
-
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
75
|
-
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
76
|
-
dispatchMessages: (messages: TMessage[], ctx: TContext) => unknown | Promise<unknown>;
|
|
77
|
-
forceStart?: boolean;
|
|
78
|
-
dispatchImmediately?: boolean;
|
|
79
|
-
forwardPairCandidate?: TelegramForwardCommentBatchPosition;
|
|
80
|
-
delayMs?: number;
|
|
81
|
-
}): boolean;
|
|
82
56
|
export declare function createTelegramTextGroupController<TMessage extends TelegramTextGroupMessage, TContext = unknown>(options?: TelegramTextGroupControllerOptions): TelegramTextGroupController<TMessage, TContext>;
|
|
83
57
|
export declare function createTelegramTextGroupDispatchRuntime<TMessage extends TelegramTextGroupMessage, TContext>(deps: {
|
|
84
58
|
textGroups: TelegramTextGroupController<TMessage, TContext>;
|
package/dist/lib/text-groups.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
|
|
5
5
|
*/
|
|
6
6
|
import { setTimeout as waitForTimeout } from "node:timers/promises";
|
|
7
|
-
import {
|
|
7
|
+
import { createTelegramPendingGroups, extractTelegramMessageText, } from "./media.js";
|
|
8
8
|
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
|
|
9
9
|
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
|
|
10
10
|
const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
|
|
@@ -47,9 +47,9 @@ function canStartTelegramTextGroup(message, minSplitLength) {
|
|
|
47
47
|
const text = extractTelegramTextGroupText(message);
|
|
48
48
|
return text.length >= minSplitLength && !isTelegramTextGroupCommand(text);
|
|
49
49
|
}
|
|
50
|
-
function canAppendTelegramTextGroupMessage(
|
|
50
|
+
function canAppendTelegramTextGroupMessage(group, message) {
|
|
51
51
|
const text = extractTelegramTextGroupText(message);
|
|
52
|
-
const previous =
|
|
52
|
+
const previous = group.messages.at(-1);
|
|
53
53
|
return (!!previous &&
|
|
54
54
|
message.message_id > previous.message_id &&
|
|
55
55
|
message.message_id <=
|
|
@@ -57,83 +57,7 @@ function canAppendTelegramTextGroupMessage(state, message) {
|
|
|
57
57
|
(text.length > 0 || isTelegramForwardedMessage(message)) &&
|
|
58
58
|
!isTelegramTextGroupCommand(text));
|
|
59
59
|
}
|
|
60
|
-
export function queueTelegramTextGroupMessage(options) {
|
|
61
|
-
const key = getTelegramTextGroupKey(options.message);
|
|
62
|
-
if (!key)
|
|
63
|
-
return false;
|
|
64
|
-
const existing = options.groups.get(key);
|
|
65
|
-
const duplicateIndex = existing?.messages.findIndex((message) => message.message_id === options.message.message_id);
|
|
66
|
-
if (existing && duplicateIndex !== undefined && duplicateIndex >= 0) {
|
|
67
|
-
existing.messages[duplicateIndex] = options.message;
|
|
68
|
-
existing.context = options.context;
|
|
69
|
-
existing.forwardPairCandidate = options.forwardPairCandidate;
|
|
70
|
-
if (!existing.suspended && !existing.dispatching && !existing.flushTimer) {
|
|
71
|
-
existing.reschedule?.();
|
|
72
|
-
}
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
if (!existing &&
|
|
76
|
-
!options.forceStart &&
|
|
77
|
-
!canStartTelegramTextGroup(options.message, options.minSplitLength))
|
|
78
|
-
return false;
|
|
79
|
-
if (existing && !canAppendTelegramTextGroupMessage(existing, options.message))
|
|
80
|
-
return false;
|
|
81
|
-
const state = existing ?? { messages: [] };
|
|
82
|
-
state.messages.push(options.message);
|
|
83
|
-
state.context = options.context;
|
|
84
|
-
state.forwardPairCandidate = options.forwardPairCandidate;
|
|
85
|
-
const dispatchQueued = () => {
|
|
86
|
-
state.flushTimer = undefined;
|
|
87
|
-
const queued = options.groups.get(key);
|
|
88
|
-
if (!queued || queued.context === undefined)
|
|
89
|
-
return Promise.resolve();
|
|
90
|
-
if (queued.dispatching)
|
|
91
|
-
return queued.dispatchPromise ?? Promise.resolve();
|
|
92
|
-
const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
|
|
93
|
-
queued.dispatchLimit = undefined;
|
|
94
|
-
const dispatchedMessages = queued.messages.slice(0, dispatchCount);
|
|
95
|
-
const dispatchedIds = new Set(dispatchedMessages.map((message) => message.message_id));
|
|
96
|
-
queued.dispatching = true;
|
|
97
|
-
const operation = Promise.resolve(options.dispatchMessages(dispatchedMessages, queued.context)).then(() => {
|
|
98
|
-
if (options.groups.get(key) !== queued)
|
|
99
|
-
return;
|
|
100
|
-
queued.messages = queued.messages.filter((message) => !dispatchedIds.has(message.message_id));
|
|
101
|
-
queued.dispatching = false;
|
|
102
|
-
queued.dispatchPromise = undefined;
|
|
103
|
-
if (queued.messages.length === 0)
|
|
104
|
-
options.groups.delete(key);
|
|
105
|
-
else if (!queued.flushTimer)
|
|
106
|
-
scheduleDispatch();
|
|
107
|
-
}, (error) => {
|
|
108
|
-
if (options.groups.get(key) === queued) {
|
|
109
|
-
queued.dispatching = false;
|
|
110
|
-
queued.dispatchPromise = undefined;
|
|
111
|
-
if (!queued.flushTimer)
|
|
112
|
-
scheduleDispatch();
|
|
113
|
-
}
|
|
114
|
-
throw error;
|
|
115
|
-
});
|
|
116
|
-
queued.dispatchPromise = operation;
|
|
117
|
-
return operation;
|
|
118
|
-
};
|
|
119
|
-
const scheduleDispatch = (delayMs = options.debounceMs) => {
|
|
120
|
-
if (state.suspended)
|
|
121
|
-
return;
|
|
122
|
-
state.flushTimer = options.setTimer(() => {
|
|
123
|
-
void dispatchQueued().catch(() => undefined);
|
|
124
|
-
}, delayMs);
|
|
125
|
-
state.flushTimer.unref?.();
|
|
126
|
-
};
|
|
127
|
-
state.reschedule = scheduleDispatch;
|
|
128
|
-
state.dispatchNow = dispatchQueued;
|
|
129
|
-
if (state.flushTimer)
|
|
130
|
-
options.clearTimer(state.flushTimer);
|
|
131
|
-
scheduleDispatch(options.dispatchImmediately ? 0 : (options.delayMs ?? options.debounceMs));
|
|
132
|
-
options.groups.set(key, state);
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
60
|
export function createTelegramTextGroupController(options = {}) {
|
|
136
|
-
const groups = new Map();
|
|
137
61
|
const plannedForwardCommentStarts = new Set();
|
|
138
62
|
const plannedForwardCommentEnds = new Set();
|
|
139
63
|
const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
|
|
@@ -155,7 +79,40 @@ export function createTelegramTextGroupController(options = {}) {
|
|
|
155
79
|
: (timer) => {
|
|
156
80
|
timer.abort();
|
|
157
81
|
});
|
|
158
|
-
const
|
|
82
|
+
const groups = createTelegramPendingGroups({ debounceMs, requireContext: true, setTimer, clearTimer });
|
|
83
|
+
/** Admission policy for one message; the shared group machine owns timers, batches and dispatch. */
|
|
84
|
+
const queueTextGroupMessage = (options) => {
|
|
85
|
+
const existing = groups.get(options.key);
|
|
86
|
+
const dispatchMessages = (messages, ctx) => options.dispatchMessages(messages, ctx);
|
|
87
|
+
const queued = {
|
|
88
|
+
key: options.key,
|
|
89
|
+
message: options.message,
|
|
90
|
+
context: options.context,
|
|
91
|
+
...(options.forwardPairCandidate === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { meta: options.forwardPairCandidate }),
|
|
94
|
+
dispatchMessages,
|
|
95
|
+
};
|
|
96
|
+
if (existing?.messages.some((message) => message.message_id === options.message.message_id)) {
|
|
97
|
+
groups.queue({ ...queued, delayMs: debounceMs, rearm: "ifIdle" });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (!existing &&
|
|
101
|
+
!options.forceStart &&
|
|
102
|
+
!canStartTelegramTextGroup(options.message, minSplitLength))
|
|
103
|
+
return false;
|
|
104
|
+
if (existing &&
|
|
105
|
+
!canAppendTelegramTextGroupMessage(existing, options.message))
|
|
106
|
+
return false;
|
|
107
|
+
groups.queue({
|
|
108
|
+
...queued,
|
|
109
|
+
delayMs: options.dispatchImmediately
|
|
110
|
+
? 0
|
|
111
|
+
: (options.delayMs ?? debounceMs),
|
|
112
|
+
rearm: "always",
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
159
116
|
return {
|
|
160
117
|
prepareUpdateBatch(updates) {
|
|
161
118
|
for (let index = 0; index + 1 < updates.length; index += 1) {
|
|
@@ -203,22 +160,18 @@ export function createTelegramTextGroupController(options = {}) {
|
|
|
203
160
|
const forwarded = isTelegramForwardedMessage(message);
|
|
204
161
|
const existing = key ? groups.get(key) : undefined;
|
|
205
162
|
const text = extractTelegramTextGroupText(message);
|
|
206
|
-
if (existing && isTelegramTextGroupClearingCommand(text))
|
|
207
|
-
|
|
208
|
-
clearTimer(existing.flushTimer);
|
|
209
|
-
groups.delete(key);
|
|
210
|
-
}
|
|
163
|
+
if (existing && isTelegramTextGroupClearingCommand(text))
|
|
164
|
+
groups.clearKey(key);
|
|
211
165
|
const candidatePosition = forwarded
|
|
212
166
|
? "forward"
|
|
213
167
|
: "comment";
|
|
214
|
-
const existingCandidate = existing?.
|
|
168
|
+
const existingCandidate = existing?.meta;
|
|
215
169
|
const pairCompleted = existingCandidate !== undefined &&
|
|
216
170
|
existingCandidate !== candidatePosition;
|
|
217
171
|
const separateFromCandidate = existingCandidate === candidatePosition &&
|
|
218
172
|
!isTelegramTextGroupCommand(text);
|
|
219
|
-
if (separateFromCandidate && existing)
|
|
220
|
-
|
|
221
|
-
}
|
|
173
|
+
if (separateFromCandidate && existing)
|
|
174
|
+
groups.closeBatch(key);
|
|
222
175
|
const forceStart = plannedStart ||
|
|
223
176
|
(forwardCommentWaitMs !== false &&
|
|
224
177
|
!!key &&
|
|
@@ -228,15 +181,13 @@ export function createTelegramTextGroupController(options = {}) {
|
|
|
228
181
|
const dispatchImmediately = pairCompleted ||
|
|
229
182
|
separateFromCandidate ||
|
|
230
183
|
plannedForwardCommentEnds.delete(identity) ||
|
|
231
|
-
(forwarded && !!key && groups.
|
|
232
|
-
|
|
184
|
+
(forwarded && !!key && groups.get(key) !== undefined);
|
|
185
|
+
if (!key)
|
|
186
|
+
return false;
|
|
187
|
+
return queueTextGroupMessage({
|
|
188
|
+
key,
|
|
233
189
|
message,
|
|
234
190
|
context,
|
|
235
|
-
groups,
|
|
236
|
-
debounceMs,
|
|
237
|
-
minSplitLength,
|
|
238
|
-
setTimer,
|
|
239
|
-
clearTimer,
|
|
240
191
|
dispatchMessages,
|
|
241
192
|
forceStart,
|
|
242
193
|
dispatchImmediately,
|
|
@@ -250,9 +201,11 @@ export function createTelegramTextGroupController(options = {}) {
|
|
|
250
201
|
: undefined,
|
|
251
202
|
});
|
|
252
203
|
},
|
|
253
|
-
|
|
204
|
+
flushMessage: groups.flushMessage,
|
|
205
|
+
suspend: groups.suspend,
|
|
206
|
+
resume: groups.resume,
|
|
254
207
|
clear: () => {
|
|
255
|
-
|
|
208
|
+
groups.clear();
|
|
256
209
|
plannedForwardCommentStarts.clear();
|
|
257
210
|
plannedForwardCommentEnds.clear();
|
|
258
211
|
},
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Owns mode-specific labels and serialized, authority-fenced title application.
|
|
5
5
|
* Excludes routing, name allocation, profile mutation, and live-owner discovery.
|
|
6
6
|
*/
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { isDeepStrictEqual } from "node:util";
|
|
9
|
+
import { normalizeTelegramWorkspacePath } from "./workspace-identity.js";
|
|
8
10
|
function labelText(value) {
|
|
9
11
|
return value.replace(/\s+/gu, " ").trim();
|
|
10
12
|
}
|
|
@@ -20,14 +22,16 @@ function boundedLabel(base, suffix = "") {
|
|
|
20
22
|
function directorySegments(cwd) {
|
|
21
23
|
return cwd.split("/").filter(Boolean);
|
|
22
24
|
}
|
|
23
|
-
function distinguishingDirectorySegments(cwd, directories) {
|
|
24
|
-
|
|
25
|
+
function distinguishingDirectorySegments(cwd, directories, homeDirectory) {
|
|
26
|
+
// Substitute only exact home identity; qualification must still distinguish ordinary folders named home.
|
|
27
|
+
const segments = (directory) => directory === homeDirectory ? ["home"] : directorySegments(directory);
|
|
28
|
+
const parts = segments(cwd);
|
|
25
29
|
if (!parts.length)
|
|
26
30
|
return [];
|
|
27
31
|
for (let depth = 1; depth <= parts.length; depth++) {
|
|
28
32
|
const candidate = labelText(parts.slice(-depth).join("/"));
|
|
29
33
|
const collides = directories.some((other) => other !== cwd &&
|
|
30
|
-
labelText(
|
|
34
|
+
labelText(segments(other).slice(-depth).join("/")).toLowerCase() === candidate.toLowerCase());
|
|
31
35
|
if (!collides)
|
|
32
36
|
return parts.slice(-depth);
|
|
33
37
|
}
|
|
@@ -45,8 +49,8 @@ export function tokenizeTelegramDirectorySegment(segment) {
|
|
|
45
49
|
.split(/[^\p{L}\p{N}]+|\u0000/gu)
|
|
46
50
|
.filter(Boolean);
|
|
47
51
|
}
|
|
48
|
-
function formatDirectoryLabel(cwd, directories, mode) {
|
|
49
|
-
const segments = distinguishingDirectorySegments(cwd, directories);
|
|
52
|
+
function formatDirectoryLabel(cwd, directories, mode, homeDirectory) {
|
|
53
|
+
const segments = distinguishingDirectorySegments(cwd, directories, homeDirectory);
|
|
50
54
|
if (!segments.length)
|
|
51
55
|
return "/";
|
|
52
56
|
const formatted = segments
|
|
@@ -99,6 +103,10 @@ function countTelegramDisplayLabels(bindings, labels, mode, liveBindingKeys) {
|
|
|
99
103
|
}
|
|
100
104
|
export function resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBindingKeys = new Set()) {
|
|
101
105
|
const labels = new Map();
|
|
106
|
+
const homeDirectory = (mode === "directory-snake" || mode === "directory-title") &&
|
|
107
|
+
(process.platform === "linux" || process.platform === "darwin")
|
|
108
|
+
? normalizeTelegramWorkspacePath(homedir())
|
|
109
|
+
: undefined;
|
|
102
110
|
const directories = Array.from(new Set(bindings.map((binding) => binding.cwd)));
|
|
103
111
|
const liveDirectoryCounts = new Map();
|
|
104
112
|
for (const binding of bindings) {
|
|
@@ -127,7 +135,7 @@ export function resolveTelegramWorkspaceDisplayNames(bindings, mode, liveBinding
|
|
|
127
135
|
else {
|
|
128
136
|
const base = mode === "directories"
|
|
129
137
|
? directoryLabel(binding.cwd, directories)
|
|
130
|
-
: formatDirectoryLabel(binding.cwd, directories, mode);
|
|
138
|
+
: formatDirectoryLabel(binding.cwd, directories, mode, homeDirectory);
|
|
131
139
|
bases.set(binding.bindingKey, base);
|
|
132
140
|
const showSuffix = mode === "directories"
|
|
133
141
|
? binding.showSlotSuffix ||
|
package/dist/lib/threads.d.ts
CHANGED
|
@@ -248,6 +248,10 @@ export interface TelegramWorkspaceRestoreIntent {
|
|
|
248
248
|
/** Live same-session metadata, never a legacy scoped ACK or permission to dispatch/delete. */
|
|
249
249
|
export type TelegramWorkspaceLiveRebindIntent = Pick<TelegramWorkspaceRestoreIntent, "request" | "operatorUserId" | "executor" | "revision" | "createdAtMs" | "updatedAtMs"> & {
|
|
250
250
|
kind: "live-rebind";
|
|
251
|
+
/** Versioned native input gate: terminal cleanup does not itself authorize or settle input. */
|
|
252
|
+
inputRelease?: "held" | "issued" | "accepted";
|
|
253
|
+
/** A session-replacing producer must not drop earlier old-target protection or carry its delete grant forward. */
|
|
254
|
+
oldTargetProtected?: true;
|
|
251
255
|
recipient: TelegramWorkspaceRestoreRecipient;
|
|
252
256
|
} & ({
|
|
253
257
|
phase: "rebound";
|
|
@@ -324,9 +328,11 @@ export interface TelegramWorkspaceRestore {
|
|
|
324
328
|
list(): TelegramWorkspaceRestoreIntent[];
|
|
325
329
|
listLiveRebindings(): TelegramWorkspaceLiveRebindIntent[];
|
|
326
330
|
/** Caller has saved and held the selected input; this publishes the binding, not dispatch readiness. */
|
|
327
|
-
commitLiveRebind(request: TelegramWorkspaceRestoreRequest, recipient: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority
|
|
331
|
+
commitLiveRebind(request: TelegramWorkspaceRestoreRequest, recipient: TelegramWorkspaceRestoreRecipient, authority: TelegramWorkspaceRestoreAuthority, options?: {
|
|
332
|
+
holdInput: true;
|
|
333
|
+
}): Promise<TelegramWorkspaceLiveRebindIntent | undefined>;
|
|
328
334
|
/** Caller proves local apply for release or current live clearance for cleanup; unknown issuance never repeats. */
|
|
329
|
-
advanceLiveRebind(expected: TelegramWorkspaceLiveRebindIntent, step: "release" | "issue-cleanup" | Extract<TelegramWorkspaceLiveRebindIntent, {
|
|
335
|
+
advanceLiveRebind(expected: TelegramWorkspaceLiveRebindIntent, step: "release" | "issue-cleanup" | "issue-input" | "accept-input" | "protect-old-target" | Extract<TelegramWorkspaceLiveRebindIntent, {
|
|
330
336
|
phase: "finished";
|
|
331
337
|
}>["cleanup"], authority: TelegramWorkspaceRestoreAuthority): TelegramWorkspaceLiveRebindIntent | undefined;
|
|
332
338
|
commit(request: TelegramWorkspaceRestoreRequest, authority: TelegramWorkspaceRestoreAuthority): Promise<TelegramWorkspaceRestoreIntent | undefined>;
|
|
@@ -359,8 +365,11 @@ export interface TelegramWorkspaceRestore {
|
|
|
359
365
|
inspectTemporaryThreadTarget(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadTargetObservation | undefined;
|
|
360
366
|
/** Read-only veto for an already-granted in-flight attempt; never another grant, disposable classification or deletion ACK. */
|
|
361
367
|
isTemporaryThreadCleanupCurrent(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): boolean;
|
|
362
|
-
/**
|
|
363
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Publishes `creating` before the caller's single creation request; an existing source entry is returned, never
|
|
370
|
+
* recreated. `group` records an album as the tab's one creating input; it must start at the source update.
|
|
371
|
+
*/
|
|
372
|
+
reserveTemporaryThread(source: TelegramTemporaryThreadEntry["source"], token: string, authority: TelegramWorkspaceRestoreAuthority, group?: TelegramTemporaryThreadInput): {
|
|
364
373
|
reserved: boolean;
|
|
365
374
|
entry: TelegramTemporaryThreadEntry;
|
|
366
375
|
} | undefined;
|
|
@@ -467,6 +476,8 @@ export interface TelegramTopicTargetStore {
|
|
|
467
476
|
/** Exact synchronous live-operation snapshot, including a current publication frame; no mutation, lock or authority grant. */
|
|
468
477
|
withWorkspaceLiveRebindSnapshot: (expected: TelegramWorkspaceLiveRebindIntent, observe: (snapshot: Readonly<Pick<TelegramTopicTargetFile, "threads" | "workspaceBindings">>) => undefined) => void;
|
|
469
478
|
/** Current live-origin ownership veto; effect callers still require Workspace admission. False is not work/recipient/deletion authority. */
|
|
479
|
+
hasPreDispatchRebindEvidence: () => boolean;
|
|
480
|
+
listProtectedLiveRebindTargets: () => TelegramTarget[];
|
|
470
481
|
isWorkspaceLiveRebindCleanupTargetProtected: (expected: TelegramWorkspaceLiveRebindIntent) => boolean;
|
|
471
482
|
/** Read-only captured identity with an optional exact manual-name result fence; no transport/session/admission grant or lock. */
|
|
472
483
|
captureWorkspaceThreadRenameObservation: (binding: TelegramWorkspaceThreadBinding, owner: TelegramTopicTargetRecord) => (expectedManualThreadName?: string) => boolean;
|
|
@@ -572,7 +583,7 @@ export interface TelegramTopicTargetStore {
|
|
|
572
583
|
/** Claim the first reusable inactive thread for an instance, linking it to instanceId. */
|
|
573
584
|
claimReusableTarget: (instanceId: string, threadName?: string) => TelegramTopicTargetRecord | undefined;
|
|
574
585
|
}
|
|
575
|
-
export declare function createTelegramCleanupTargetProtection(store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "listTemporaryThreadTargets">>, departingRecord?: TelegramTopicTargetRecord): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]>;
|
|
586
|
+
export declare function createTelegramCleanupTargetProtection(store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups" | "listTemporaryThreadTargets" | "listProtectedLiveRebindTargets">>, departingRecord?: TelegramTopicTargetRecord): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]>;
|
|
576
587
|
export interface TelegramTopicTargetStoreOptions {
|
|
577
588
|
path: string | (() => string);
|
|
578
589
|
telegramProfile?: string | (() => string | undefined);
|
|
@@ -661,6 +672,8 @@ export declare function getTelegramWorkspaceRestoreSourceCompletionSha256(operat
|
|
|
661
672
|
export declare function getTelegramTemporaryThreadInputs(entry: TelegramTemporaryThreadEntry): TelegramTemporaryThreadInput[];
|
|
662
673
|
/** Every known group is durably cancelled or Forward-completed; still not deletion authority by itself. */
|
|
663
674
|
export declare function isTelegramTemporaryThreadFullyResolved(entry: TelegramTemporaryThreadEntry): boolean;
|
|
675
|
+
/** Canonical issuance is distinct from cleanup completion, source disposition and peer dispatch readiness. */
|
|
676
|
+
export declare function isTelegramLiveRebindInputAuthorized(value: TelegramWorkspaceLiveRebindIntent): boolean;
|
|
664
677
|
/** The unified section accepts only lossless current-format Workspace evidence, never tolerant legacy repair. */
|
|
665
678
|
export declare function parseTelegramWorkspaceStateSection(value: unknown, profile: string): TelegramTopicTargetFile | undefined;
|
|
666
679
|
export declare function resolveTelegramWorkspaceProvisionRecoveryPath(statePath: string, profileName?: string, layout?: "consolidated"): string;
|