telecodex 0.1.2 → 0.1.4
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/README.md +104 -139
- package/dist/bot/auth.js +9 -8
- package/dist/bot/commandSupport.js +27 -14
- package/dist/bot/createBot.js +43 -0
- package/dist/bot/handlers/messageHandlers.js +6 -5
- package/dist/bot/handlers/operationalHandlers.js +84 -72
- package/dist/bot/handlers/projectHandlers.js +90 -76
- package/dist/bot/handlers/sessionConfigHandlers.js +141 -111
- package/dist/bot/inputService.js +30 -14
- package/dist/codex/configOverrides.js +50 -0
- package/dist/codex/sessionCatalog.js +66 -12
- package/dist/runtime/bootstrap.js +60 -37
- package/dist/runtime/startTelecodex.js +28 -21
- package/dist/store/fileState.js +100 -8
- package/dist/store/projects.js +3 -0
- package/dist/store/sessions.js +3 -0
- package/dist/telegram/delivery.js +41 -5
- package/dist/telegram/formatted.js +183 -0
- package/dist/telegram/messageBuffer.js +10 -0
- package/dist/telegram/splitMessage.js +1 -1
- package/package.json +5 -3
|
@@ -4,20 +4,25 @@ import path from "node:path";
|
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
export class CodexSessionCatalog {
|
|
6
6
|
sessionsRoot;
|
|
7
|
+
cacheTtlMs;
|
|
8
|
+
index = new Map();
|
|
9
|
+
sortedPaths = [];
|
|
10
|
+
pathByThreadId = new Map();
|
|
11
|
+
refreshPromise = null;
|
|
12
|
+
lastRefreshedAt = 0;
|
|
7
13
|
constructor(input) {
|
|
8
14
|
this.sessionsRoot = input?.sessionsRoot ?? defaultSessionsRoot();
|
|
9
15
|
this.logger = input?.logger;
|
|
16
|
+
this.cacheTtlMs = Math.max(0, input?.cacheTtlMs ?? 5_000);
|
|
10
17
|
}
|
|
11
18
|
logger;
|
|
12
19
|
async listProjectThreads(input) {
|
|
20
|
+
await this.ensureIndexFresh();
|
|
13
21
|
const projectRoot = canonicalizePath(input.projectRoot);
|
|
14
22
|
const limit = Math.max(1, input.limit ?? 8);
|
|
15
|
-
const files = listSessionFiles(this.sessionsRoot)
|
|
16
|
-
.filter((entry) => entry.mtimeMs > 0)
|
|
17
|
-
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
18
23
|
const matches = [];
|
|
19
|
-
for (const
|
|
20
|
-
const summary =
|
|
24
|
+
for (const filePath of this.sortedPaths) {
|
|
25
|
+
const summary = this.index.get(filePath)?.summary ?? null;
|
|
21
26
|
if (!summary)
|
|
22
27
|
continue;
|
|
23
28
|
if (!isPathWithinRoot(summary.cwd, projectRoot))
|
|
@@ -29,17 +34,14 @@ export class CodexSessionCatalog {
|
|
|
29
34
|
return matches;
|
|
30
35
|
}
|
|
31
36
|
async findProjectThreadById(input) {
|
|
37
|
+
await this.ensureIndexFresh();
|
|
32
38
|
const projectRoot = canonicalizePath(input.projectRoot);
|
|
33
39
|
const threadId = input.threadId.trim();
|
|
34
40
|
if (!threadId)
|
|
35
41
|
return null;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!summary)
|
|
40
|
-
continue;
|
|
41
|
-
if (summary.id !== threadId)
|
|
42
|
-
continue;
|
|
42
|
+
const filePath = this.pathByThreadId.get(threadId);
|
|
43
|
+
const summary = filePath ? this.index.get(filePath)?.summary ?? null : null;
|
|
44
|
+
if (summary) {
|
|
43
45
|
if (!isPathWithinRoot(summary.cwd, projectRoot))
|
|
44
46
|
return null;
|
|
45
47
|
return summary;
|
|
@@ -51,6 +53,58 @@ export class CodexSessionCatalog {
|
|
|
51
53
|
});
|
|
52
54
|
return null;
|
|
53
55
|
}
|
|
56
|
+
async ensureIndexFresh() {
|
|
57
|
+
if (this.refreshPromise) {
|
|
58
|
+
await this.refreshPromise;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now - this.lastRefreshedAt >= this.cacheTtlMs || this.sortedPaths.length === 0) {
|
|
63
|
+
this.refreshPromise = this.refreshIndex().finally(() => {
|
|
64
|
+
this.lastRefreshedAt = Date.now();
|
|
65
|
+
this.refreshPromise = null;
|
|
66
|
+
});
|
|
67
|
+
await this.refreshPromise;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async refreshIndex() {
|
|
72
|
+
const files = listSessionFiles(this.sessionsRoot).filter((entry) => entry.mtimeMs > 0);
|
|
73
|
+
const seenPaths = new Set(files.map((entry) => entry.path));
|
|
74
|
+
for (const existingPath of this.index.keys()) {
|
|
75
|
+
if (!seenPaths.has(existingPath)) {
|
|
76
|
+
this.index.delete(existingPath);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const cached = this.index.get(file.path);
|
|
81
|
+
if (cached && cached.mtimeMs === file.mtimeMs) {
|
|
82
|
+
if (cached.updatedAt !== file.updatedAt) {
|
|
83
|
+
cached.updatedAt = file.updatedAt;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
this.index.set(file.path, {
|
|
88
|
+
path: file.path,
|
|
89
|
+
mtimeMs: file.mtimeMs,
|
|
90
|
+
updatedAt: file.updatedAt,
|
|
91
|
+
summary: await readSessionSummary(file.path, file.updatedAt),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
this.rebuildDerivedIndexes();
|
|
95
|
+
}
|
|
96
|
+
rebuildDerivedIndexes() {
|
|
97
|
+
this.sortedPaths = [...this.index.values()]
|
|
98
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
99
|
+
.map((entry) => entry.path);
|
|
100
|
+
this.pathByThreadId.clear();
|
|
101
|
+
for (const filePath of this.sortedPaths) {
|
|
102
|
+
const summary = this.index.get(filePath)?.summary ?? null;
|
|
103
|
+
if (!summary || this.pathByThreadId.has(summary.id))
|
|
104
|
+
continue;
|
|
105
|
+
this.pathByThreadId.set(summary.id, filePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
54
108
|
}
|
|
55
109
|
function defaultSessionsRoot() {
|
|
56
110
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
@@ -15,17 +15,7 @@ import { PLAINTEXT_TOKEN_FALLBACK_ENV, SecretStore, } from "./secrets.js";
|
|
|
15
15
|
const MAC_CODEX_BIN = "/Applications/Codex.app/Contents/Resources/codex";
|
|
16
16
|
export async function bootstrapRuntime() {
|
|
17
17
|
intro("telecodex");
|
|
18
|
-
const
|
|
19
|
-
const storage = new FileStateStorage(stateDir);
|
|
20
|
-
migrateLegacySqliteState({
|
|
21
|
-
storage,
|
|
22
|
-
legacyDbPath: getLegacyStateDbPath(),
|
|
23
|
-
});
|
|
24
|
-
const store = new SessionStore(storage);
|
|
25
|
-
const projects = new ProjectStore(storage);
|
|
26
|
-
const secrets = new SecretStore(store, {
|
|
27
|
-
allowPlaintextFallback: process.env[PLAINTEXT_TOKEN_FALLBACK_ENV] === "1",
|
|
28
|
-
});
|
|
18
|
+
const { store, projects, secrets } = initializeRuntimePersistence();
|
|
29
19
|
const codexBin = await ensureCodexBin(store);
|
|
30
20
|
await ensureCodexLogin(codexBin);
|
|
31
21
|
const { token, botUsername, storageMode } = await ensureTelegramBotToken(secrets);
|
|
@@ -37,32 +27,14 @@ export async function bootstrapRuntime() {
|
|
|
37
27
|
defaultCwd: process.cwd(),
|
|
38
28
|
codexBin,
|
|
39
29
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
binding
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
bootstrapCode = binding.code;
|
|
50
|
-
}
|
|
51
|
-
else if (store.getBindingCodeState()?.mode === "bootstrap") {
|
|
52
|
-
store.clearBindingCode();
|
|
53
|
-
}
|
|
54
|
-
if (bootstrapCode) {
|
|
55
|
-
const binding = store.getBindingCodeState();
|
|
56
|
-
const copied = await copyBootstrapCode(bootstrapCode);
|
|
57
|
-
note([
|
|
58
|
-
`Bot: ${botUsername ? `@${botUsername}` : "unknown"}`,
|
|
59
|
-
`Workspace: ${config.defaultCwd}`,
|
|
60
|
-
copied ? "Binding code copied to the clipboard." : "Failed to copy the binding code. Copy it manually.",
|
|
61
|
-
`Binding code expires at: ${binding?.expiresAt ?? "unknown"}`,
|
|
62
|
-
`Max failed attempts: ${binding?.maxAttempts ?? BINDING_CODE_MAX_ATTEMPTS}`,
|
|
63
|
-
"",
|
|
64
|
-
bootstrapCode,
|
|
65
|
-
].join("\n"), "Admin Binding");
|
|
30
|
+
const binding = resolveBootstrapBindingState(store);
|
|
31
|
+
const bootstrapCode = binding?.code ?? null;
|
|
32
|
+
if (binding) {
|
|
33
|
+
await showBootstrapBindingNote({
|
|
34
|
+
binding,
|
|
35
|
+
botUsername,
|
|
36
|
+
workspace: config.defaultCwd,
|
|
37
|
+
});
|
|
66
38
|
}
|
|
67
39
|
return {
|
|
68
40
|
config,
|
|
@@ -72,6 +44,45 @@ export async function bootstrapRuntime() {
|
|
|
72
44
|
botUsername,
|
|
73
45
|
};
|
|
74
46
|
}
|
|
47
|
+
export function initializeRuntimePersistence(input) {
|
|
48
|
+
const stateDir = input?.stateDir ?? getStateDir();
|
|
49
|
+
const storage = new FileStateStorage(stateDir);
|
|
50
|
+
migrateLegacySqliteState({
|
|
51
|
+
storage,
|
|
52
|
+
legacyDbPath: getLegacyStateDbPath(),
|
|
53
|
+
});
|
|
54
|
+
const store = new SessionStore(storage);
|
|
55
|
+
const projects = new ProjectStore(storage);
|
|
56
|
+
const secrets = new SecretStore(store, {
|
|
57
|
+
allowPlaintextFallback: input?.allowPlaintextFallback ?? process.env[PLAINTEXT_TOKEN_FALLBACK_ENV] === "1",
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
storage,
|
|
61
|
+
store,
|
|
62
|
+
projects,
|
|
63
|
+
secrets,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function resolveBootstrapBindingState(store, generateCode = () => generateBindingCode("bootstrap")) {
|
|
67
|
+
if (store.getAuthorizedUserId() != null) {
|
|
68
|
+
if (store.getBindingCodeState()?.mode === "bootstrap") {
|
|
69
|
+
store.clearBindingCode();
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
let binding = store.getBindingCodeState();
|
|
74
|
+
if (!binding || binding.mode !== "bootstrap") {
|
|
75
|
+
binding = store.issueBindingCode({
|
|
76
|
+
code: generateCode(),
|
|
77
|
+
mode: "bootstrap",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
code: binding.code,
|
|
82
|
+
expiresAt: binding.expiresAt,
|
|
83
|
+
maxAttempts: binding.maxAttempts,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
75
86
|
async function ensureTelegramBotToken(secrets) {
|
|
76
87
|
const existing = secrets.getTelegramBotToken();
|
|
77
88
|
if (existing) {
|
|
@@ -211,6 +222,18 @@ async function copyBootstrapCode(code) {
|
|
|
211
222
|
return false;
|
|
212
223
|
}
|
|
213
224
|
}
|
|
225
|
+
async function showBootstrapBindingNote(input) {
|
|
226
|
+
const copied = await copyBootstrapCode(input.binding.code);
|
|
227
|
+
note([
|
|
228
|
+
`Bot: ${input.botUsername ? `@${input.botUsername}` : "unknown"}`,
|
|
229
|
+
`Workspace: ${input.workspace}`,
|
|
230
|
+
copied ? "Binding code copied to the clipboard." : "Failed to copy the binding code. Copy it manually.",
|
|
231
|
+
`Binding code expires at: ${input.binding.expiresAt}`,
|
|
232
|
+
`Max failed attempts: ${input.binding.maxAttempts ?? BINDING_CODE_MAX_ATTEMPTS}`,
|
|
233
|
+
"",
|
|
234
|
+
input.binding.code,
|
|
235
|
+
].join("\n"), "Admin Binding");
|
|
236
|
+
}
|
|
214
237
|
function requirePromptValue(value) {
|
|
215
238
|
if (isCancel(value))
|
|
216
239
|
exitCancelled();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { run } from "@grammyjs/runner";
|
|
2
2
|
import { createBot } from "../bot/createBot.js";
|
|
3
|
+
import { tryParseCodexConfigOverrides } from "../codex/configOverrides.js";
|
|
3
4
|
import { CodexSessionCatalog } from "../codex/sessionCatalog.js";
|
|
4
5
|
import { CodexSdkRuntime } from "../codex/sdkRuntime.js";
|
|
5
6
|
import { bootstrapRuntime } from "./bootstrap.js";
|
|
@@ -24,7 +25,14 @@ export async function startTelecodex() {
|
|
|
24
25
|
pid: process.pid,
|
|
25
26
|
});
|
|
26
27
|
const { config, store, projects, bootstrapCode, botUsername } = await bootstrapRuntime();
|
|
27
|
-
const
|
|
28
|
+
const storedConfigOverrides = store.getAppState("codex_config_overrides");
|
|
29
|
+
const { value: configOverrides, error: configOverridesError } = tryParseCodexConfigOverrides(storedConfigOverrides);
|
|
30
|
+
if (configOverridesError) {
|
|
31
|
+
store.deleteAppState("codex_config_overrides");
|
|
32
|
+
logger.warn("cleared invalid stored codex config overrides", {
|
|
33
|
+
error: configOverridesError.message,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
28
36
|
const codex = new CodexSdkRuntime({
|
|
29
37
|
codexBin: config.codexBin,
|
|
30
38
|
logger: logger.child("codex-sdk"),
|
|
@@ -57,14 +65,26 @@ export async function startTelecodex() {
|
|
|
57
65
|
console.warn("Telegram may take a few minutes to apply the change");
|
|
58
66
|
}
|
|
59
67
|
const runner = run(bot);
|
|
68
|
+
let shuttingDown = false;
|
|
60
69
|
const stopRuntime = (signal) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
if (shuttingDown)
|
|
71
|
+
return;
|
|
72
|
+
shuttingDown = true;
|
|
73
|
+
void (async () => {
|
|
74
|
+
logger.info("received shutdown signal", { signal });
|
|
75
|
+
codex.interruptAll();
|
|
76
|
+
runner.stop();
|
|
77
|
+
try {
|
|
78
|
+
await store.flush();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
logger.warn("failed to flush pending telecodex state during shutdown", { error });
|
|
82
|
+
}
|
|
83
|
+
instanceLock?.release();
|
|
84
|
+
instanceLock = null;
|
|
85
|
+
logger.flush();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
})();
|
|
68
88
|
};
|
|
69
89
|
process.once("SIGINT", () => stopRuntime("SIGINT"));
|
|
70
90
|
process.once("SIGTERM", () => stopRuntime("SIGTERM"));
|
|
@@ -94,19 +114,6 @@ export async function startTelecodex() {
|
|
|
94
114
|
throw error;
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
|
-
function parseCodexConfigOverrides(value) {
|
|
98
|
-
if (!value)
|
|
99
|
-
return undefined;
|
|
100
|
-
try {
|
|
101
|
-
const parsed = JSON.parse(value);
|
|
102
|
-
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
103
|
-
? parsed
|
|
104
|
-
: undefined;
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
117
|
function installProcessErrorHandlers(logger) {
|
|
111
118
|
if (processHandlersInstalled)
|
|
112
119
|
return;
|
package/dist/store/fileState.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { DEFAULT_SESSION_PROFILE, isSessionApprovalPolicy, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, } from "../config.js";
|
|
4
5
|
const FILE_STATE_VERSION = 1;
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const writeFileAtomic = require("write-file-atomic");
|
|
5
8
|
export class FileStateStorage {
|
|
6
9
|
rootDir;
|
|
7
10
|
appPath;
|
|
@@ -10,6 +13,7 @@ export class FileStateStorage {
|
|
|
10
13
|
appState = new Map();
|
|
11
14
|
projects = new Map();
|
|
12
15
|
sessions = new Map();
|
|
16
|
+
flushStateByPath = new Map();
|
|
13
17
|
constructor(rootDir) {
|
|
14
18
|
this.rootDir = rootDir;
|
|
15
19
|
mkdirSync(rootDir, { recursive: true });
|
|
@@ -143,23 +147,111 @@ export class FileStateStorage {
|
|
|
143
147
|
this.flushSessions();
|
|
144
148
|
}
|
|
145
149
|
flushAppState() {
|
|
146
|
-
|
|
150
|
+
this.scheduleJsonWrite(this.appPath, {
|
|
147
151
|
version: FILE_STATE_VERSION,
|
|
148
152
|
values: Object.fromEntries([...this.appState.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
149
153
|
});
|
|
150
154
|
}
|
|
151
155
|
flushProjects() {
|
|
152
|
-
|
|
156
|
+
this.scheduleJsonWrite(this.projectsPath, {
|
|
153
157
|
version: FILE_STATE_VERSION,
|
|
154
158
|
projects: [...this.projects.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
|
|
155
159
|
});
|
|
156
160
|
}
|
|
157
161
|
flushSessions() {
|
|
158
|
-
|
|
162
|
+
this.scheduleJsonWrite(this.sessionsPath, {
|
|
159
163
|
version: FILE_STATE_VERSION,
|
|
160
164
|
sessions: [...this.sessions.values()].sort((left, right) => left.sessionKey.localeCompare(right.sessionKey)),
|
|
161
165
|
});
|
|
162
166
|
}
|
|
167
|
+
async flush() {
|
|
168
|
+
for (;;) {
|
|
169
|
+
await Promise.resolve();
|
|
170
|
+
const active = [...this.flushStateByPath.values()].map((state) => state.draining).filter((entry) => entry != null);
|
|
171
|
+
if (active.length > 0) {
|
|
172
|
+
await Promise.all(active);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
this.throwPendingFlushErrors();
|
|
176
|
+
let started = false;
|
|
177
|
+
for (const [filePath, state] of this.flushStateByPath.entries()) {
|
|
178
|
+
if (state.scheduled || state.draining || state.pendingJson === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
this.startDrainWhenReady(filePath, state);
|
|
181
|
+
started = true;
|
|
182
|
+
}
|
|
183
|
+
if (!started && [...this.flushStateByPath.values()].every((state) => !state.scheduled && state.pendingJson === undefined)) {
|
|
184
|
+
this.throwPendingFlushErrors();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
scheduleJsonWrite(filePath, value) {
|
|
190
|
+
const state = this.getOrCreateFlushState(filePath);
|
|
191
|
+
state.pendingJson = `${JSON.stringify(value, null, 2)}\n`;
|
|
192
|
+
state.error = null;
|
|
193
|
+
this.startDrainWhenReady(filePath, state);
|
|
194
|
+
}
|
|
195
|
+
startDrainWhenReady(filePath, state) {
|
|
196
|
+
if (state.scheduled || state.draining)
|
|
197
|
+
return;
|
|
198
|
+
state.scheduled = true;
|
|
199
|
+
queueMicrotask(() => {
|
|
200
|
+
state.scheduled = false;
|
|
201
|
+
if (state.draining)
|
|
202
|
+
return;
|
|
203
|
+
state.draining = this.drainJsonWrites(filePath, state)
|
|
204
|
+
.catch((error) => {
|
|
205
|
+
state.error = error;
|
|
206
|
+
})
|
|
207
|
+
.finally(() => {
|
|
208
|
+
state.draining = null;
|
|
209
|
+
if (state.pendingJson !== undefined && !state.scheduled && state.error == null) {
|
|
210
|
+
this.startDrainWhenReady(filePath, state);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
getOrCreateFlushState(filePath) {
|
|
216
|
+
let state = this.flushStateByPath.get(filePath);
|
|
217
|
+
if (state)
|
|
218
|
+
return state;
|
|
219
|
+
state = {
|
|
220
|
+
pendingJson: undefined,
|
|
221
|
+
scheduled: false,
|
|
222
|
+
draining: null,
|
|
223
|
+
error: null,
|
|
224
|
+
};
|
|
225
|
+
this.flushStateByPath.set(filePath, state);
|
|
226
|
+
return state;
|
|
227
|
+
}
|
|
228
|
+
async drainJsonWrites(filePath, state) {
|
|
229
|
+
for (;;) {
|
|
230
|
+
const nextJson = state.pendingJson;
|
|
231
|
+
if (nextJson === undefined) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
state.pendingJson = undefined;
|
|
235
|
+
try {
|
|
236
|
+
await writeJsonFile(filePath, nextJson);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (state.pendingJson === undefined) {
|
|
240
|
+
state.pendingJson = nextJson;
|
|
241
|
+
}
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throwPendingFlushErrors() {
|
|
247
|
+
for (const state of this.flushStateByPath.values()) {
|
|
248
|
+
if (state.error == null)
|
|
249
|
+
continue;
|
|
250
|
+
const error = state.error;
|
|
251
|
+
state.error = null;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
163
255
|
}
|
|
164
256
|
function loadAppStateFile(filePath) {
|
|
165
257
|
try {
|
|
@@ -289,11 +381,11 @@ function normalizeStoredSessionRecord(value) {
|
|
|
289
381
|
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
|
|
290
382
|
};
|
|
291
383
|
}
|
|
292
|
-
function writeJsonFile(filePath, value) {
|
|
384
|
+
async function writeJsonFile(filePath, value) {
|
|
293
385
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
386
|
+
await writeFileAtomic(filePath, value, {
|
|
387
|
+
encoding: "utf8",
|
|
388
|
+
});
|
|
297
389
|
}
|
|
298
390
|
function readJsonFile(filePath) {
|
|
299
391
|
try {
|
package/dist/store/projects.js
CHANGED
package/dist/store/sessions.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { GrammyError } from "grammy";
|
|
2
2
|
import { renderPlainChunksForTelegram } from "./renderer.js";
|
|
3
3
|
import { splitTelegramHtml } from "./splitMessage.js";
|
|
4
|
+
const telegramCooldownByClient = new WeakMap();
|
|
4
5
|
export async function sendHtmlMessage(bot, input, logger) {
|
|
5
|
-
return retryTelegramCall(() => bot.api.sendMessage(input.chatId, input.text, {
|
|
6
|
+
return retryTelegramCall(bot.api, () => bot.api.sendMessage(input.chatId, input.text, {
|
|
6
7
|
...(input.messageThreadId == null ? {} : { message_thread_id: input.messageThreadId }),
|
|
7
8
|
parse_mode: "HTML",
|
|
8
9
|
link_preview_options: { is_disabled: true },
|
|
@@ -26,7 +27,7 @@ export async function sendPlainChunks(bot, input, logger) {
|
|
|
26
27
|
return messages;
|
|
27
28
|
}
|
|
28
29
|
export async function sendTypingAction(bot, input, logger) {
|
|
29
|
-
await retryTelegramCall(() => bot.api.sendChatAction(input.chatId, "typing", {
|
|
30
|
+
await retryTelegramCall(bot.api, () => bot.api.sendChatAction(input.chatId, "typing", {
|
|
30
31
|
...(input.messageThreadId == null ? {} : { message_thread_id: input.messageThreadId }),
|
|
31
32
|
}), logger, "telegram chat action rate limited", {
|
|
32
33
|
chatId: input.chatId,
|
|
@@ -80,7 +81,7 @@ export async function replaceOrSendHtmlChunks(bot, input, logger) {
|
|
|
80
81
|
return firstMessageId ?? null;
|
|
81
82
|
}
|
|
82
83
|
export async function editHtmlMessage(bot, input, logger) {
|
|
83
|
-
await retryTelegramCall(() => bot.api.editMessageText(input.chatId, input.messageId, input.text, {
|
|
84
|
+
await retryTelegramCall(bot.api, () => bot.api.editMessageText(input.chatId, input.messageId, input.text, {
|
|
84
85
|
parse_mode: "HTML",
|
|
85
86
|
link_preview_options: { is_disabled: true },
|
|
86
87
|
}), logger, "telegram edit rate limited", {
|
|
@@ -115,8 +116,9 @@ function retryAfterMs(error) {
|
|
|
115
116
|
function descriptionOf(error) {
|
|
116
117
|
return typeof error.description === "string" ? error.description : null;
|
|
117
118
|
}
|
|
118
|
-
export async function retryTelegramCall(operation, logger, message, context) {
|
|
119
|
+
export async function retryTelegramCall(cooldownKey, operation, logger, message, context) {
|
|
119
120
|
for (let attempt = 0;; attempt += 1) {
|
|
121
|
+
await waitForTelegramCooldown(cooldownKey);
|
|
120
122
|
try {
|
|
121
123
|
return await operation();
|
|
122
124
|
}
|
|
@@ -125,16 +127,50 @@ export async function retryTelegramCall(operation, logger, message, context) {
|
|
|
125
127
|
if (waitMs == null || attempt >= 5) {
|
|
126
128
|
throw error;
|
|
127
129
|
}
|
|
130
|
+
const cooldownMs = waitMs + 250;
|
|
128
131
|
logger?.warn(message, {
|
|
129
132
|
...context,
|
|
130
133
|
attempt: attempt + 1,
|
|
131
134
|
retryAfterMs: waitMs,
|
|
135
|
+
sharedCooldownMs: cooldownMs,
|
|
132
136
|
error,
|
|
133
137
|
});
|
|
134
|
-
await
|
|
138
|
+
await applyTelegramCooldown(cooldownKey, cooldownMs);
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
}
|
|
142
|
+
async function waitForTelegramCooldown(cooldownKey) {
|
|
143
|
+
for (;;) {
|
|
144
|
+
const cooldown = telegramCooldownByClient.get(cooldownKey)?.cooldown ?? null;
|
|
145
|
+
if (!cooldown)
|
|
146
|
+
return;
|
|
147
|
+
await cooldown;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function applyTelegramCooldown(cooldownKey, delayMs) {
|
|
151
|
+
const state = getTelegramCooldownState(cooldownKey);
|
|
152
|
+
const previous = state.cooldown;
|
|
153
|
+
const baseCooldown = previous
|
|
154
|
+
? previous.then(() => sleep(delayMs))
|
|
155
|
+
: sleep(delayMs);
|
|
156
|
+
const cooldown = baseCooldown.finally(() => {
|
|
157
|
+
if (state.cooldown === cooldown) {
|
|
158
|
+
state.cooldown = null;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
state.cooldown = cooldown;
|
|
162
|
+
await state.cooldown;
|
|
163
|
+
}
|
|
164
|
+
function getTelegramCooldownState(cooldownKey) {
|
|
165
|
+
let state = telegramCooldownByClient.get(cooldownKey);
|
|
166
|
+
if (state)
|
|
167
|
+
return state;
|
|
168
|
+
state = {
|
|
169
|
+
cooldown: null,
|
|
170
|
+
};
|
|
171
|
+
telegramCooldownByClient.set(cooldownKey, state);
|
|
172
|
+
return state;
|
|
173
|
+
}
|
|
138
174
|
function sleep(ms) {
|
|
139
175
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
176
|
}
|