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.
@@ -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 file of files) {
20
- const summary = await readSessionSummary(file.path, file.updatedAt);
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 files = listSessionFiles(this.sessionsRoot).sort((left, right) => right.mtimeMs - left.mtimeMs);
37
- for (const file of files) {
38
- const summary = await readSessionSummary(file.path, file.updatedAt);
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 stateDir = getStateDir();
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
- let bootstrapCode = null;
41
- if (store.getAuthorizedUserId() == null) {
42
- let binding = store.getBindingCodeState();
43
- if (!binding || binding.mode !== "bootstrap") {
44
- binding = store.issueBindingCode({
45
- code: generateBindingCode("bootstrap"),
46
- mode: "bootstrap",
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 configOverrides = parseCodexConfigOverrides(store.getAppState("codex_config_overrides"));
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
- logger.info("received shutdown signal", { signal });
62
- codex.interruptAll();
63
- runner.stop();
64
- instanceLock?.release();
65
- instanceLock = null;
66
- logger.flush();
67
- process.exit(0);
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;
@@ -1,7 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
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
- writeJsonFile(this.appPath, {
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
- writeJsonFile(this.projectsPath, {
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
- writeJsonFile(this.sessionsPath, {
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
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
295
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
296
- renameSync(tempPath, filePath);
386
+ await writeFileAtomic(filePath, value, {
387
+ encoding: "utf8",
388
+ });
297
389
  }
298
390
  function readJsonFile(filePath) {
299
391
  try {
@@ -4,6 +4,9 @@ export class ProjectStore {
4
4
  constructor(storage) {
5
5
  this.storage = storage;
6
6
  }
7
+ flush() {
8
+ return this.storage.flush();
9
+ }
7
10
  get(chatId) {
8
11
  return this.storage.getProject(chatId);
9
12
  }
@@ -10,6 +10,9 @@ export class SessionStore {
10
10
  constructor(storage) {
11
11
  this.storage = storage;
12
12
  }
13
+ flush() {
14
+ return this.storage.flush();
15
+ }
13
16
  getAppState(key) {
14
17
  return this.storage.getAppState(key);
15
18
  }
@@ -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 sleep(waitMs + 250);
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
  }