@llblab/pi-telegram 0.51.5 → 0.52.0
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 +10 -8
- package/BACKLOG.md +1 -8
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +16 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +9 -2
- package/dist/lib/bindings.js +22 -13
- package/dist/lib/bus-api.js +4 -2
- package/dist/lib/bus-follower.d.ts +55 -57
- package/dist/lib/bus-follower.js +213 -140
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +12 -26
- package/dist/lib/commands.js +152 -98
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +281 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +177 -16
- package/dist/lib/journal.js +1115 -249
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.d.ts +57 -2
- package/dist/lib/lifecycle.js +110 -5
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/model.js +2 -4
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +3 -15
- package/dist/lib/queue.js +69 -8
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +73 -8
- package/dist/lib/routing.js +2206 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +46 -9
- package/dist/lib/status.js +179 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +9 -8
- package/dist/lib/telegram-api.d.ts +11 -3
- package/dist/lib/telegram-api.js +34 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +292 -59
- package/dist/lib/threads.js +1664 -373
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +184 -28
- package/dist/lib/updates.js +1032 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +45 -10
- package/dist/lib/workspace-retirement.js +196 -64
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +158 -39
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +127 -19
- package/docs/public-api.md +5 -3
- package/docs/ui-style.md +26 -8
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +23 -10
- package/lib/bus-api.ts +5 -2
- package/lib/bus-follower.ts +251 -226
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +150 -112
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +282 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1182 -326
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +141 -5
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/model.ts +2 -4
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +67 -9
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +1955 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +185 -36
- package/lib/sync.ts +8 -8
- package/lib/telegram-api.ts +48 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1718 -491
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1072 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +234 -102
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/dist/lib/telegram-api.js
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { createWriteStream, openAsBlob } from "node:fs";
|
|
10
|
-
import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { mkdir, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import { request as requestHttps } from "node:https";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { resolveTelegramAttachmentsDir } from "./paths.js";
|
|
14
14
|
import { Readable, Transform } from "node:stream";
|
|
15
15
|
import { pipeline } from "node:stream/promises";
|
|
16
|
-
|
|
16
|
+
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
17
17
|
export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
18
18
|
export function getTelegramInboundFileByteLimitFromEnv(env, names, defaultValue = TELEGRAM_FILE_MAX_BYTES) {
|
|
19
19
|
for (const name of names) {
|
|
@@ -27,11 +27,10 @@ export function getTelegramInboundFileByteLimitFromEnv(env, names, defaultValue
|
|
|
27
27
|
return defaultValue;
|
|
28
28
|
}
|
|
29
29
|
function getTelegramApiTempDir() {
|
|
30
|
-
return
|
|
30
|
+
return resolveTelegramAttachmentsDir();
|
|
31
31
|
}
|
|
32
32
|
const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
33
33
|
const activeTelegramApiWorkspaceAdmissionOperationIds = new Set();
|
|
34
|
-
const TELEGRAM_TEMP_SCRATCH_FILE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u;
|
|
35
34
|
const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(process.env, ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"], TELEGRAM_FILE_MAX_BYTES);
|
|
36
35
|
const TELEGRAM_NETWORK_FAMILY_ENV = "PI_TELEGRAM_NETWORK_FAMILY";
|
|
37
36
|
const TELEGRAM_NETWORK_FAMILY_VALUES = new Set([
|
|
@@ -208,6 +207,23 @@ export function createTelegramApiWorkspaceAdmissionClient(client, admission, opt
|
|
|
208
207
|
function sanitizeFileName(name) {
|
|
209
208
|
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
210
209
|
}
|
|
210
|
+
/** Names follow `<kind>-<scope>-<messageId>[-<index>][-<name>|.<ext>]`. */
|
|
211
|
+
/**
|
|
212
|
+
* `scope` is the bot `@username` (id fallback) in a private chat and the public chat `@username` (id fallback) elsewhere.
|
|
213
|
+
* A negative chat id loses its sign, so the name never contains two adjacent dashes.
|
|
214
|
+
*/
|
|
215
|
+
export function createTelegramAttachmentFileName(source, generatedName, botScope) {
|
|
216
|
+
const chat = source.chat;
|
|
217
|
+
const clean = (value) => sanitizeFileName(value.replace(/^@/u, "")).replace(/^[-.]+/u, "");
|
|
218
|
+
const scope = source.scope ? clean(source.scope) : chat?.type === "private" || !chat
|
|
219
|
+
? botScope ? clean(botScope) : chat ? String(Math.abs(chat.id)) : ""
|
|
220
|
+
: chat.username ? clean(chat.username) : String(Math.abs(chat.id));
|
|
221
|
+
const dot = generatedName.lastIndexOf(".");
|
|
222
|
+
const extension = dot > 0 ? sanitizeFileName(generatedName.slice(dot)) : "";
|
|
223
|
+
const tail = source.userFileName ? `-${clean(source.userFileName)}` : extension;
|
|
224
|
+
return [sanitizeFileName(source.kind), scope, String(source.messageId)].filter(Boolean).join("-") +
|
|
225
|
+
(source.index !== undefined ? `-${source.index}` : "") + tail;
|
|
226
|
+
}
|
|
211
227
|
class TelegramApiMalformedSuccessError extends Error {
|
|
212
228
|
constructor(method, detail) {
|
|
213
229
|
super(`Telegram API ${method} ${detail}`);
|
|
@@ -672,9 +688,9 @@ export async function cleanupTelegramTempFiles(tempDir, maxAgeMs, now = Date.now
|
|
|
672
688
|
return 0;
|
|
673
689
|
}
|
|
674
690
|
for (const entry of entries) {
|
|
675
|
-
|
|
691
|
+
// The directory holds only attachment scratch files, so age alone decides.
|
|
692
|
+
if (!entry.isFile())
|
|
676
693
|
continue;
|
|
677
|
-
}
|
|
678
694
|
const path = join(tempDir, entry.name);
|
|
679
695
|
try {
|
|
680
696
|
const stats = await stat(path);
|
|
@@ -760,7 +776,9 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
|
|
|
760
776
|
const file = await callTelegram(configuredBotToken, "getFile", { file_id: fileId }, { signal: options?.signal });
|
|
761
777
|
assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
|
|
762
778
|
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
763
|
-
|
|
779
|
+
// Names carry scope and message id, so the same message maps to the same path; publish by rename.
|
|
780
|
+
const targetPath = join(tempDir, sanitizeFileName(suggestedName));
|
|
781
|
+
const partPath = `${targetPath}.${randomUUID()}.part`;
|
|
764
782
|
const response = await callTelegramTransportRequest((family) => telegramFetch(`${TELEGRAM_API_BASE}/file/bot${configuredBotToken}/${file.file_path}`, { signal: options?.signal }, family));
|
|
765
783
|
if (!response.ok) {
|
|
766
784
|
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
|
@@ -768,10 +786,11 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
|
|
|
768
786
|
const contentLength = response.headers?.get("content-length");
|
|
769
787
|
assertTelegramFileSizeWithinLimit(contentLength ? Number.parseInt(contentLength, 10) : undefined, options?.maxFileSizeBytes);
|
|
770
788
|
try {
|
|
771
|
-
await writeTelegramDownloadResponse(response,
|
|
789
|
+
await writeTelegramDownloadResponse(response, partPath, options?.maxFileSizeBytes);
|
|
790
|
+
await rename(partPath, targetPath);
|
|
772
791
|
}
|
|
773
792
|
catch (error) {
|
|
774
|
-
await removeTelegramPartialDownload(
|
|
793
|
+
await removeTelegramPartialDownload(partPath);
|
|
775
794
|
throw error;
|
|
776
795
|
}
|
|
777
796
|
return targetPath;
|
|
@@ -788,17 +807,6 @@ export async function answerTelegramCallbackQuery(botToken, callbackQueryId, tex
|
|
|
788
807
|
}));
|
|
789
808
|
}
|
|
790
809
|
}
|
|
791
|
-
export async function deleteTelegramMessage(botToken, chatId, messageId) {
|
|
792
|
-
try {
|
|
793
|
-
await callTelegram(botToken, "deleteMessage", {
|
|
794
|
-
chat_id: chatId,
|
|
795
|
-
message_id: messageId,
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
catch {
|
|
799
|
-
// ignore
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
810
|
export function createTelegramChatActionSender(sendChatAction, action) {
|
|
803
811
|
return (chatId, options) => sendChatAction(chatId, action, options);
|
|
804
812
|
}
|
|
@@ -871,6 +879,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps) {
|
|
|
871
879
|
tempDir: getTelegramApiTempDir(),
|
|
872
880
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
|
873
881
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
|
882
|
+
...(deps.getBotScope ? { getBotScope: deps.getBotScope } : {}),
|
|
874
883
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
875
884
|
captureRequestErrorHandler: deps.captureRequestErrorHandler,
|
|
876
885
|
});
|
|
@@ -1029,9 +1038,11 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1029
1038
|
* Downloads a file from the Telegram servers into the local temp directory.
|
|
1030
1039
|
* Used for inbound voice messages, photos, documents, etc.
|
|
1031
1040
|
*/
|
|
1032
|
-
downloadFile: async (fileId, suggestedName) => {
|
|
1041
|
+
downloadFile: async (fileId, suggestedName, source) => {
|
|
1033
1042
|
try {
|
|
1034
|
-
return await deps.client.downloadFile(fileId,
|
|
1043
|
+
return await deps.client.downloadFile(fileId, source
|
|
1044
|
+
? createTelegramAttachmentFileName(source, suggestedName, deps.getBotScope?.())
|
|
1045
|
+
: suggestedName, deps.tempDir, {
|
|
1035
1046
|
maxFileSizeBytes: deps.maxFileSizeBytes,
|
|
1036
1047
|
});
|
|
1037
1048
|
}
|
|
@@ -7,6 +7,8 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openS
|
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.js";
|
|
10
|
+
import { getTelegramJournalPublicationPaths } from "./paths.js";
|
|
11
|
+
import { isWireRecord as isObject, hasOnlyWireKeys as onlyKeys, isNonNegativeWireInteger as safeTime, } from "./wire.js";
|
|
10
12
|
function targetKey(target) {
|
|
11
13
|
return `${target.chatId}:${target.threadId}`;
|
|
12
14
|
}
|
|
@@ -134,16 +136,6 @@ export function createTelegramInactiveThreadCleanupReviewRuntime(deps) {
|
|
|
134
136
|
},
|
|
135
137
|
};
|
|
136
138
|
}
|
|
137
|
-
function isObject(value) {
|
|
138
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
139
|
-
}
|
|
140
|
-
function onlyKeys(value, keys) {
|
|
141
|
-
const allowed = new Set(keys);
|
|
142
|
-
return Object.keys(value).every(key => allowed.has(key));
|
|
143
|
-
}
|
|
144
|
-
function safeTime(value) {
|
|
145
|
-
return Number.isSafeInteger(value) && value >= 0;
|
|
146
|
-
}
|
|
147
139
|
function validateWorkSet(value, profileName) {
|
|
148
140
|
if (!isObject(value) || !onlyKeys(value, ["operationId", "createdAtMs", "entries"]) ||
|
|
149
141
|
typeof value.operationId !== "string" || !value.operationId || !safeTime(value.createdAtMs) ||
|
|
@@ -199,7 +191,18 @@ function validateWorkSet(value, profileName) {
|
|
|
199
191
|
function sameCandidates(left, right) {
|
|
200
192
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
201
193
|
}
|
|
194
|
+
function parseTelegramThreadCleanupWorkFile(value, profileName, tokenSha256) {
|
|
195
|
+
if (!isObject(value) || !onlyKeys(value, ["version", "profileName", "tokenSha256", "workSets"]) ||
|
|
196
|
+
value.version !== 1 || value.profileName !== profileName || value.tokenSha256 !== tokenSha256 || !Array.isArray(value.workSets))
|
|
197
|
+
throw new Error("Telegram Thread cleanup store identity or schema does not match.");
|
|
198
|
+
const workSets = value.workSets.map(workSet => validateWorkSet(workSet, profileName));
|
|
199
|
+
if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
|
|
200
|
+
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
201
|
+
return { version: 1, profileName, tokenSha256, workSets };
|
|
202
|
+
}
|
|
202
203
|
export function createTelegramThreadCleanupWorkStore(options) {
|
|
204
|
+
options = { ...options };
|
|
205
|
+
const publicationPaths = getTelegramJournalPublicationPaths(options.path, options.runtimeDir);
|
|
203
206
|
const maxWorkSets = options.maxWorkSets ?? 32;
|
|
204
207
|
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
205
208
|
const now = options.getNowMs ?? Date.now;
|
|
@@ -236,14 +239,7 @@ export function createTelegramThreadCleanupWorkStore(options) {
|
|
|
236
239
|
finally {
|
|
237
240
|
closeSync(fd);
|
|
238
241
|
}
|
|
239
|
-
|
|
240
|
-
value.version !== 1 || value.profileName !== options.profileName ||
|
|
241
|
-
value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.workSets))
|
|
242
|
-
throw new Error("Telegram Thread cleanup store identity or schema does not match.");
|
|
243
|
-
const workSets = value.workSets.map(workSet => validateWorkSet(workSet, options.profileName));
|
|
244
|
-
if (new Set(workSets.map(workSet => workSet.operationId)).size !== workSets.length)
|
|
245
|
-
throw new Error("Telegram Thread cleanup operation identity is ambiguous.");
|
|
246
|
-
return { version: 1, profileName: options.profileName, tokenSha256: options.tokenSha256, workSets };
|
|
242
|
+
return parseTelegramThreadCleanupWorkFile(value, options.profileName, options.tokenSha256);
|
|
247
243
|
};
|
|
248
244
|
const publish = (file) => {
|
|
249
245
|
if (file.workSets.length > maxWorkSets)
|
|
@@ -251,8 +247,9 @@ export function createTelegramThreadCleanupWorkStore(options) {
|
|
|
251
247
|
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
252
248
|
if (Buffer.byteLength(serialized) > maxBytes)
|
|
253
249
|
throw new Error("Telegram Thread cleanup byte capacity reached.");
|
|
254
|
-
const temporaryPath = `${
|
|
250
|
+
const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
255
251
|
mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
|
|
252
|
+
mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
|
|
256
253
|
try {
|
|
257
254
|
writeFileSync(temporaryPath, serialized, { mode: 0o600 });
|
|
258
255
|
chmodSync(temporaryPath, 0o600);
|
|
@@ -269,7 +266,7 @@ export function createTelegramThreadCleanupWorkStore(options) {
|
|
|
269
266
|
catch { /* rename consumed it */ }
|
|
270
267
|
}
|
|
271
268
|
};
|
|
272
|
-
const mutate = (operation) => withTelegramFileTransaction(
|
|
269
|
+
const mutate = (operation) => withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
|
|
273
270
|
return {
|
|
274
271
|
prepare(operationId, candidates) {
|
|
275
272
|
if (!operationId || candidates.length === 0 || candidates.some(candidate => candidate.profileName !== options.profileName))
|
|
@@ -343,7 +340,7 @@ export function createTelegramThreadCleanupWorkStore(options) {
|
|
|
343
340
|
});
|
|
344
341
|
},
|
|
345
342
|
list() {
|
|
346
|
-
return structuredClone(withTelegramFileTransaction(
|
|
343
|
+
return structuredClone(withTelegramFileTransaction(publicationPaths.transactionPath, () => read().workSets));
|
|
347
344
|
},
|
|
348
345
|
};
|
|
349
346
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram Thread manual-name
|
|
3
|
-
* Zones:
|
|
4
|
-
* Owns one expiring exact-target
|
|
5
|
-
* Excludes
|
|
2
|
+
* Telegram Thread naming rules and manual-name interaction
|
|
3
|
+
* Zones: thread identity names, palettes, title formatting, runtime controls
|
|
4
|
+
* Owns name/title value policy and one expiring exact-target dialog per session scope.
|
|
5
|
+
* Excludes occupancy/slot allocation, display-mode projection, transport and API/store effects.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { type TelegramTarget } from "./target.ts";
|
|
8
8
|
export declare const TELEGRAM_THREAD_NAME_DIALOG_TTL_MS: number;
|
|
9
9
|
export type TelegramThreadNameDialogAction = "reset" | "cancel";
|
|
10
10
|
export interface TelegramThreadNameDialogCandidate {
|
|
@@ -44,3 +44,30 @@ export declare function createTelegramThreadNameDialogRuntime(options?: {
|
|
|
44
44
|
clearScope(scope: string): void;
|
|
45
45
|
inspect(target: TelegramTarget): TelegramThreadNameDialogCandidate | undefined;
|
|
46
46
|
};
|
|
47
|
+
export interface TelegramThreadTitleInput {
|
|
48
|
+
instanceId: string;
|
|
49
|
+
profileKey: string;
|
|
50
|
+
threadName?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface TelegramThreadNameInput {
|
|
53
|
+
seed: string;
|
|
54
|
+
cwd?: string;
|
|
55
|
+
role?: "leader" | "follower";
|
|
56
|
+
peers?: readonly string[];
|
|
57
|
+
slot?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function createTelegramThreadName(input: TelegramThreadNameInput): string;
|
|
60
|
+
export declare function normalizeTelegramTopicTargetThreadName(threadName: string): string;
|
|
61
|
+
export declare function getTelegramTopicIdentityName(threadName: string): string;
|
|
62
|
+
export declare function chooseTelegramThreadName(input: {
|
|
63
|
+
slot: string | undefined;
|
|
64
|
+
entropy?: number | string;
|
|
65
|
+
getRandom?: () => number;
|
|
66
|
+
occupied?: readonly string[];
|
|
67
|
+
}): string | undefined;
|
|
68
|
+
export declare function getTelegramThreadNameLeadingSlot(threadName: string | undefined): string | undefined;
|
|
69
|
+
export declare function getTelegramTopicThreadNameValidationError(threadName: string, _slot: string | undefined): string | undefined;
|
|
70
|
+
export declare function getTelegramManualThreadDisplayNameValidationError(threadName: string): string | undefined;
|
|
71
|
+
export declare function isTelegramTopicThreadNameValidForSlot(threadName: string, slot: string | undefined): boolean;
|
|
72
|
+
export declare function getTelegramTopicName(request: TelegramThreadTitleInput, template?: string, slot?: string): string;
|
|
73
|
+
export declare function getTelegramTopicTitleForThreadName(threadName: string, slot: string, template?: string): string;
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram Thread manual-name
|
|
3
|
-
* Zones:
|
|
4
|
-
* Owns one expiring exact-target
|
|
5
|
-
* Excludes
|
|
2
|
+
* Telegram Thread naming rules and manual-name interaction
|
|
3
|
+
* Zones: thread identity names, palettes, title formatting, runtime controls
|
|
4
|
+
* Owns name/title value policy and one expiring exact-target dialog per session scope.
|
|
5
|
+
* Excludes occupancy/slot allocation, display-mode projection, transport and API/store effects.
|
|
6
6
|
*/
|
|
7
|
+
import { areTelegramTargetsEqual as sameTarget } from "./target.js";
|
|
7
8
|
export const TELEGRAM_THREAD_NAME_DIALOG_TTL_MS = 5 * 60_000;
|
|
8
9
|
function targetKey(target) {
|
|
9
10
|
return `${target.chatId}:${target.threadId ?? "chat"}`;
|
|
10
11
|
}
|
|
11
|
-
function sameTarget(left, right) {
|
|
12
|
-
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
13
|
-
}
|
|
14
12
|
function cloneCandidate(candidate) {
|
|
15
13
|
return { ...candidate, target: { ...candidate.target } };
|
|
16
14
|
}
|
|
@@ -76,3 +74,189 @@ export function createTelegramThreadNameDialogRuntime(options) {
|
|
|
76
74
|
},
|
|
77
75
|
};
|
|
78
76
|
}
|
|
77
|
+
function hashString(value) {
|
|
78
|
+
let hash = 2166136261;
|
|
79
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
80
|
+
hash ^= value.charCodeAt(index);
|
|
81
|
+
hash = Math.imul(hash, 16777619);
|
|
82
|
+
}
|
|
83
|
+
return hash >>> 0;
|
|
84
|
+
}
|
|
85
|
+
function getWorkspaceHint(cwd) {
|
|
86
|
+
if (!cwd)
|
|
87
|
+
return undefined;
|
|
88
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
89
|
+
const last = parts.at(-1)?.trim();
|
|
90
|
+
if (!last)
|
|
91
|
+
return undefined;
|
|
92
|
+
return (last
|
|
93
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
94
|
+
.trim()
|
|
95
|
+
.slice(0, 32) || undefined);
|
|
96
|
+
}
|
|
97
|
+
export function createTelegramThreadName(input) {
|
|
98
|
+
const workspace = getWorkspaceHint(input.cwd);
|
|
99
|
+
const roleMark = input.role === "leader"
|
|
100
|
+
? "Leader"
|
|
101
|
+
: input.role === "follower"
|
|
102
|
+
? "Follower"
|
|
103
|
+
: undefined;
|
|
104
|
+
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
105
|
+
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
106
|
+
const fallback = `Instance ${hashString(`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`)
|
|
107
|
+
.toString(36)
|
|
108
|
+
.slice(0, 4)}`;
|
|
109
|
+
return ([slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
110
|
+
fallback);
|
|
111
|
+
}
|
|
112
|
+
export function normalizeTelegramTopicTargetThreadName(threadName) {
|
|
113
|
+
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
114
|
+
}
|
|
115
|
+
function getGraphemeSegments(value) {
|
|
116
|
+
const segmenter = Intl.Segmenter;
|
|
117
|
+
if (!segmenter)
|
|
118
|
+
return Array.from(value);
|
|
119
|
+
return Array.from(new segmenter(undefined, { granularity: "grapheme" }).segment(value), (part) => part.segment);
|
|
120
|
+
}
|
|
121
|
+
export function getTelegramTopicIdentityName(threadName) {
|
|
122
|
+
return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
|
|
123
|
+
.join("")
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
126
|
+
const TELEGRAM_THREAD_NAME_PALETTE = {
|
|
127
|
+
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
128
|
+
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
129
|
+
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
130
|
+
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
131
|
+
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
132
|
+
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
133
|
+
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
134
|
+
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
135
|
+
I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
|
|
136
|
+
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
137
|
+
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
138
|
+
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
139
|
+
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
140
|
+
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
141
|
+
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
142
|
+
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
143
|
+
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
144
|
+
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
145
|
+
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
146
|
+
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
147
|
+
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
148
|
+
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
149
|
+
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
150
|
+
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
151
|
+
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
152
|
+
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
153
|
+
};
|
|
154
|
+
export function chooseTelegramThreadName(input) {
|
|
155
|
+
if (!input.slot || !/^[A-Z]$/.test(input.slot))
|
|
156
|
+
return undefined;
|
|
157
|
+
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
158
|
+
if (!names || names.length === 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
const occupied = new Set((input.occupied ?? []).map((name) => getTelegramTopicIdentityName(name)));
|
|
161
|
+
const start = input.getRandom
|
|
162
|
+
? Math.max(0, Math.min(names.length - 1, Math.floor(input.getRandom() * names.length)))
|
|
163
|
+
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
164
|
+
for (let offset = 0; offset < names.length; offset += 1) {
|
|
165
|
+
const name = names[(start + offset) % names.length];
|
|
166
|
+
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
167
|
+
return name;
|
|
168
|
+
}
|
|
169
|
+
for (const paletteSlot of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
|
170
|
+
for (const name of TELEGRAM_THREAD_NAME_PALETTE[paletteSlot] ?? []) {
|
|
171
|
+
if (!occupied.has(getTelegramTopicIdentityName(name)))
|
|
172
|
+
return name;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
export function getTelegramThreadNameLeadingSlot(threadName) {
|
|
178
|
+
if (!threadName)
|
|
179
|
+
return undefined;
|
|
180
|
+
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
181
|
+
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
182
|
+
}
|
|
183
|
+
function getTelegramThreadNameEntropyIndex(entropy, length) {
|
|
184
|
+
if (length <= 1)
|
|
185
|
+
return 0;
|
|
186
|
+
if (typeof entropy === "number" && entropy < 1_000_000_000_000)
|
|
187
|
+
return 0;
|
|
188
|
+
const value = entropy === undefined ? "0" : String(entropy);
|
|
189
|
+
let hash = 2166136261;
|
|
190
|
+
for (const char of value) {
|
|
191
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
192
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
193
|
+
}
|
|
194
|
+
return hash % length;
|
|
195
|
+
}
|
|
196
|
+
export function getTelegramTopicThreadNameValidationError(threadName, _slot) {
|
|
197
|
+
const identity = getTelegramTopicIdentityName(threadName);
|
|
198
|
+
const reasons = [];
|
|
199
|
+
if (!identity)
|
|
200
|
+
reasons.push("it is empty after trimming");
|
|
201
|
+
if (/\s/.test(identity))
|
|
202
|
+
reasons.push("it contains spaces");
|
|
203
|
+
if (/[^A-Za-z]/.test(identity)) {
|
|
204
|
+
reasons.push("it contains characters outside Latin A-Z letters");
|
|
205
|
+
}
|
|
206
|
+
if (!/^[A-Z]/.test(identity)) {
|
|
207
|
+
reasons.push("it does not start with an uppercase Latin letter");
|
|
208
|
+
}
|
|
209
|
+
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
210
|
+
if (genericLabels.has(identity.toLowerCase())) {
|
|
211
|
+
reasons.push("it is a generic role label");
|
|
212
|
+
}
|
|
213
|
+
if (/^[A-Z]$/.test(identity))
|
|
214
|
+
reasons.push("it is only a bare slot letter");
|
|
215
|
+
if (reasons.length === 0)
|
|
216
|
+
return undefined;
|
|
217
|
+
return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
|
|
218
|
+
}
|
|
219
|
+
export function getTelegramManualThreadDisplayNameValidationError(threadName) {
|
|
220
|
+
const trimmed = threadName.trim();
|
|
221
|
+
const normalized = trimmed.replace(/\s+/g, " ");
|
|
222
|
+
const reasons = [];
|
|
223
|
+
if (!trimmed)
|
|
224
|
+
reasons.push("it is empty after trimming");
|
|
225
|
+
if (trimmed && /[^\x20-\x7E]/.test(trimmed)) {
|
|
226
|
+
reasons.push("it contains characters outside printable ASCII");
|
|
227
|
+
}
|
|
228
|
+
if (normalized.length > 96)
|
|
229
|
+
reasons.push("it is longer than 96 characters");
|
|
230
|
+
if (/^[A-Z]$/.test(normalized)) {
|
|
231
|
+
reasons.push("a bare slot letter is reserved for reset to automatic");
|
|
232
|
+
}
|
|
233
|
+
if (reasons.length === 0)
|
|
234
|
+
return undefined;
|
|
235
|
+
return `Invalid Telegram Thread display name: ${reasons.join("; ")}. Use 1–96 printable ASCII characters.`;
|
|
236
|
+
}
|
|
237
|
+
export function isTelegramTopicThreadNameValidForSlot(threadName, slot) {
|
|
238
|
+
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
239
|
+
}
|
|
240
|
+
function applyTopicNameTemplate(template, request, slot) {
|
|
241
|
+
const threadName = request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
242
|
+
let result = template
|
|
243
|
+
.replaceAll("{threadName}", threadName)
|
|
244
|
+
.replaceAll("{profileKey}", request.profileKey)
|
|
245
|
+
.replaceAll("{instanceId}", request.instanceId);
|
|
246
|
+
if (slot)
|
|
247
|
+
result = result.replaceAll("{slot}", slot);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
export function getTelegramTopicName(request, template = "{slot}", slot) {
|
|
251
|
+
const name = applyTopicNameTemplate(template, request, slot)
|
|
252
|
+
.replace(/\s+/g, " ")
|
|
253
|
+
.trim();
|
|
254
|
+
return (name || slot || "Pi").slice(0, 128);
|
|
255
|
+
}
|
|
256
|
+
export function getTelegramTopicTitleForThreadName(threadName, slot, template = "{threadName}") {
|
|
257
|
+
return getTelegramTopicName({
|
|
258
|
+
instanceId: "",
|
|
259
|
+
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
260
|
+
threadName,
|
|
261
|
+
}, template, slot);
|
|
262
|
+
}
|
|
@@ -192,6 +192,8 @@ export interface ThreadReconciliationApplyResult {
|
|
|
192
192
|
incompleteActions?: ThreadReconciliationAction[];
|
|
193
193
|
}
|
|
194
194
|
export interface ThreadReconciliationApplyPorts {
|
|
195
|
+
/** Private-chat temporary tabs support deletion, not forum closing. */
|
|
196
|
+
skipCloseBeforeDelete?: boolean;
|
|
195
197
|
isCleanupTargetProtected?: (target: ThreadTarget, action: ThreadReconciliationAction) => boolean;
|
|
196
198
|
callApi?: <TResponse>(method: string, body: Record<string, unknown>) => Promise<TResponse>;
|
|
197
199
|
markActiveByTarget?: (target: ThreadTarget) => boolean;
|
|
@@ -295,7 +295,7 @@ export async function applyThreadReconciliationPlan(plan, ports) {
|
|
|
295
295
|
}
|
|
296
296
|
let deleteConfirmed = false;
|
|
297
297
|
let superseded = false;
|
|
298
|
-
for (const method of ["closeForumTopic", "deleteForumTopic"]) {
|
|
298
|
+
for (const method of ports.skipCloseBeforeDelete ? ["deleteForumTopic"] : ["closeForumTopic", "deleteForumTopic"]) {
|
|
299
299
|
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
300
300
|
superseded = true;
|
|
301
301
|
break;
|