telecodex 0.1.0 → 0.1.2

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.
@@ -1,22 +1,23 @@
1
- import { DEFAULT_SESSION_PROFILE, isSessionApprovalPolicy, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, } from "../config.js";
1
+ import { DEFAULT_SESSION_PROFILE, } from "../config.js";
2
+ export const BINDING_CODE_TTL_MS = 15 * 60 * 1000;
3
+ export const BINDING_CODE_MAX_ATTEMPTS = 5;
2
4
  export class SessionStore {
3
- db;
4
- constructor(db) {
5
- this.db = db;
5
+ storage;
6
+ runtimeStateBySession = new Map();
7
+ outputMessageBySession = new Map();
8
+ queueBySession = new Map();
9
+ nextQueuedInputId = 1;
10
+ constructor(storage) {
11
+ this.storage = storage;
6
12
  }
7
13
  getAppState(key) {
8
- const row = this.db.prepare("SELECT value FROM app_state WHERE key = ?").get(key);
9
- return row?.value ?? null;
14
+ return this.storage.getAppState(key);
10
15
  }
11
16
  setAppState(key, value) {
12
- this.db
13
- .prepare(`INSERT INTO app_state (key, value, updated_at)
14
- VALUES (?, ?, ?)
15
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
16
- .run(key, value, new Date().toISOString());
17
+ this.storage.setAppState(key, value);
17
18
  }
18
19
  deleteAppState(key) {
19
- this.db.prepare("DELETE FROM app_state WHERE key = ?").run(key);
20
+ this.storage.deleteAppState(key);
20
21
  }
21
22
  getAuthorizedUserId() {
22
23
  const value = this.getAppState("authorized_user_id");
@@ -26,296 +27,331 @@ export class SessionStore {
26
27
  return Number.isSafeInteger(userId) ? userId : null;
27
28
  }
28
29
  getBootstrapCode() {
29
- return this.getAppState("bootstrap_code");
30
+ const binding = this.getBindingCodeState();
31
+ return binding?.mode === "bootstrap" ? binding.code : null;
30
32
  }
31
33
  setBootstrapCode(code) {
32
- this.setAppState("bootstrap_code", code);
34
+ this.issueBindingCode({
35
+ code,
36
+ mode: "bootstrap",
37
+ });
33
38
  }
34
39
  clearBootstrapCode() {
40
+ this.clearBindingCode();
41
+ }
42
+ getBindingCodeState(now = new Date()) {
43
+ const code = this.getAppState("bootstrap_code");
44
+ const createdAt = this.getAppState("binding_code_created_at");
45
+ const expiresAt = this.getAppState("binding_code_expires_at");
46
+ if (!code || !createdAt || !expiresAt)
47
+ return null;
48
+ const expiresAtMs = Date.parse(expiresAt);
49
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now.getTime()) {
50
+ this.clearBindingCode();
51
+ return null;
52
+ }
53
+ const attempts = normalizeNonNegativeInteger(this.getAppState("binding_code_attempts"));
54
+ if (attempts >= BINDING_CODE_MAX_ATTEMPTS) {
55
+ this.clearBindingCode();
56
+ return null;
57
+ }
58
+ return {
59
+ code,
60
+ mode: normalizeBindingCodeMode(this.getAppState("binding_code_mode")),
61
+ createdAt,
62
+ expiresAt,
63
+ attempts,
64
+ maxAttempts: BINDING_CODE_MAX_ATTEMPTS,
65
+ issuedByUserId: normalizeOptionalUserId(this.getAppState("binding_code_issued_by_user_id")),
66
+ };
67
+ }
68
+ issueBindingCode(input) {
69
+ const now = input.now ?? new Date();
70
+ const createdAt = now.toISOString();
71
+ const expiresAt = new Date(now.getTime() + (input.ttlMs ?? BINDING_CODE_TTL_MS)).toISOString();
72
+ this.setAppState("bootstrap_code", input.code);
73
+ this.setAppState("binding_code_mode", input.mode);
74
+ this.setAppState("binding_code_created_at", createdAt);
75
+ this.setAppState("binding_code_expires_at", expiresAt);
76
+ this.setAppState("binding_code_attempts", "0");
77
+ if (input.issuedByUserId == null) {
78
+ this.deleteAppState("binding_code_issued_by_user_id");
79
+ }
80
+ else {
81
+ this.setAppState("binding_code_issued_by_user_id", String(input.issuedByUserId));
82
+ }
83
+ return {
84
+ code: input.code,
85
+ mode: input.mode,
86
+ createdAt,
87
+ expiresAt,
88
+ attempts: 0,
89
+ maxAttempts: BINDING_CODE_MAX_ATTEMPTS,
90
+ issuedByUserId: input.issuedByUserId ?? null,
91
+ };
92
+ }
93
+ recordBindingCodeFailure(now = new Date()) {
94
+ const state = this.getBindingCodeState(now);
95
+ if (!state)
96
+ return null;
97
+ const attempts = state.attempts + 1;
98
+ if (attempts >= state.maxAttempts) {
99
+ this.clearBindingCode();
100
+ return {
101
+ attempts,
102
+ remaining: 0,
103
+ exhausted: true,
104
+ };
105
+ }
106
+ this.setAppState("binding_code_attempts", String(attempts));
107
+ return {
108
+ attempts,
109
+ remaining: state.maxAttempts - attempts,
110
+ exhausted: false,
111
+ };
112
+ }
113
+ clearBindingCode() {
35
114
  this.deleteAppState("bootstrap_code");
115
+ this.deleteAppState("binding_code_mode");
116
+ this.deleteAppState("binding_code_created_at");
117
+ this.deleteAppState("binding_code_expires_at");
118
+ this.deleteAppState("binding_code_attempts");
119
+ this.deleteAppState("binding_code_issued_by_user_id");
36
120
  }
37
121
  claimAuthorizedUserId(userId) {
38
122
  const existing = this.getAuthorizedUserId();
39
123
  if (existing != null)
40
124
  return existing;
41
- this.db.prepare("INSERT OR IGNORE INTO app_state (key, value, updated_at) VALUES ('authorized_user_id', ?, ?)").run(String(userId), new Date().toISOString());
125
+ this.setAppState("authorized_user_id", String(userId));
126
+ this.clearBindingCode();
42
127
  const current = this.getAuthorizedUserId();
43
128
  if (current == null)
44
129
  throw new Error("Failed to persist authorized Telegram user id");
45
- this.clearBootstrapCode();
46
130
  return current;
47
131
  }
132
+ rebindAuthorizedUserId(userId) {
133
+ this.setAppState("authorized_user_id", String(userId));
134
+ this.clearBindingCode();
135
+ }
48
136
  clearAuthorizedUserId() {
49
137
  this.deleteAppState("authorized_user_id");
138
+ this.clearBindingCode();
50
139
  }
51
140
  getOrCreate(input) {
52
141
  const existing = this.get(input.sessionKey);
53
142
  if (existing)
54
143
  return existing;
55
144
  const now = new Date().toISOString();
56
- this.db
57
- .prepare(`INSERT INTO sessions (
58
- session_key, chat_id, message_thread_id, telegram_topic_name, cwd, model, sandbox_mode, approval_policy, created_at, updated_at
59
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
60
- .run(input.sessionKey, input.chatId, input.messageThreadId, input.telegramTopicName ?? null, input.defaultCwd, input.defaultModel, DEFAULT_SESSION_PROFILE.sandboxMode, DEFAULT_SESSION_PROFILE.approvalPolicy, now, now);
145
+ this.storage.putSession({
146
+ sessionKey: input.sessionKey,
147
+ chatId: input.chatId,
148
+ messageThreadId: input.messageThreadId,
149
+ telegramTopicName: input.telegramTopicName ?? null,
150
+ codexThreadId: null,
151
+ cwd: input.defaultCwd,
152
+ model: input.defaultModel,
153
+ sandboxMode: DEFAULT_SESSION_PROFILE.sandboxMode,
154
+ approvalPolicy: DEFAULT_SESSION_PROFILE.approvalPolicy,
155
+ reasoningEffort: null,
156
+ webSearchMode: null,
157
+ networkAccessEnabled: true,
158
+ skipGitRepoCheck: true,
159
+ additionalDirectories: [],
160
+ outputSchema: null,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ });
61
164
  const created = this.get(input.sessionKey);
62
165
  if (!created)
63
166
  throw new Error("Session insert failed");
64
167
  return created;
65
168
  }
66
169
  get(sessionKey) {
67
- const row = this.db
68
- .prepare("SELECT * FROM sessions WHERE session_key = ?")
69
- .get(sessionKey);
70
- return row ? mapSessionRow(row) : null;
170
+ const stored = this.storage.getSession(sessionKey);
171
+ return mapStoredSession(stored, this.runtimeStateBySession.get(sessionKey), this.outputMessageBySession.get(sessionKey));
71
172
  }
72
173
  getByThreadId(threadId) {
73
- const row = this.db
74
- .prepare("SELECT * FROM sessions WHERE codex_thread_id = ? LIMIT 1")
75
- .get(threadId);
76
- return row ? mapSessionRow(row) : null;
174
+ const stored = this.storage.getSessionByThreadId(threadId);
175
+ return stored ? mapStoredSession(stored, this.runtimeStateBySession.get(stored.sessionKey), this.outputMessageBySession.get(stored.sessionKey)) : null;
77
176
  }
78
177
  listTopicSessions() {
79
- const rows = this.db
80
- .prepare("SELECT * FROM sessions WHERE message_thread_id IS NOT NULL ORDER BY session_key ASC")
81
- .all();
82
- return rows.map(mapSessionRow);
178
+ return this.storage
179
+ .listSessions()
180
+ .filter((session) => session.messageThreadId != null)
181
+ .map((session) => mapStoredSession(session, this.runtimeStateBySession.get(session.sessionKey), this.outputMessageBySession.get(session.sessionKey)))
182
+ .filter((session) => session != null);
83
183
  }
84
184
  remove(sessionKey) {
85
- this.db.prepare("DELETE FROM queued_inputs WHERE session_key = ?").run(sessionKey);
86
- this.db.prepare("DELETE FROM sessions WHERE session_key = ?").run(sessionKey);
185
+ this.queueBySession.delete(sessionKey);
186
+ this.runtimeStateBySession.delete(sessionKey);
187
+ this.outputMessageBySession.delete(sessionKey);
188
+ this.storage.removeSession(sessionKey);
87
189
  }
88
190
  enqueueInput(sessionKey, input) {
89
191
  const now = new Date().toISOString();
90
- const text = formatCodexInputPreview(input);
91
- const result = this.db
92
- .prepare(`INSERT INTO queued_inputs (session_key, text, input_json, created_at, updated_at)
93
- VALUES (?, ?, ?, ?, ?)`)
94
- .run(sessionKey, text, JSON.stringify(input), now, now);
95
- const id = Number(result.lastInsertRowid);
96
- const queued = this.getQueuedInput(id);
97
- if (!queued)
98
- throw new Error("Queued input insert failed");
99
- return queued;
192
+ const queued = {
193
+ id: this.nextQueuedInputId,
194
+ sessionKey,
195
+ text: formatCodexInputPreview(input),
196
+ input: cloneStoredCodexInput(input),
197
+ createdAt: now,
198
+ updatedAt: now,
199
+ };
200
+ this.nextQueuedInputId += 1;
201
+ const queue = this.queueBySession.get(sessionKey) ?? [];
202
+ queue.push(queued);
203
+ this.queueBySession.set(sessionKey, queue);
204
+ return cloneQueuedInput(queued);
100
205
  }
101
206
  getQueuedInput(id) {
102
- const row = this.db.prepare("SELECT * FROM queued_inputs WHERE id = ?").get(id);
103
- return row ? mapQueuedInputRow(row) : null;
207
+ for (const queue of this.queueBySession.values()) {
208
+ const match = queue.find((item) => item.id === id);
209
+ if (match)
210
+ return cloneQueuedInput(match);
211
+ }
212
+ return null;
104
213
  }
105
214
  getQueuedInputCount(sessionKey) {
106
- const row = this.db
107
- .prepare("SELECT COUNT(*) AS count FROM queued_inputs WHERE session_key = ?")
108
- .get(sessionKey);
109
- return row?.count ?? 0;
215
+ return this.queueBySession.get(sessionKey)?.length ?? 0;
110
216
  }
111
217
  peekNextQueuedInput(sessionKey) {
112
- const row = this.db
113
- .prepare("SELECT * FROM queued_inputs WHERE session_key = ? ORDER BY id ASC LIMIT 1")
114
- .get(sessionKey);
115
- return row ? mapQueuedInputRow(row) : null;
218
+ const queue = this.queueBySession.get(sessionKey);
219
+ const [next] = queue ?? [];
220
+ return next ? cloneQueuedInput(next) : null;
116
221
  }
117
222
  listQueuedInputs(sessionKey, limit = 5) {
118
- const rows = this.db
119
- .prepare("SELECT * FROM queued_inputs WHERE session_key = ? ORDER BY id ASC LIMIT ?")
120
- .all(sessionKey, limit);
121
- return rows.map(mapQueuedInputRow);
223
+ const queue = this.queueBySession.get(sessionKey) ?? [];
224
+ return queue.slice(0, limit).map(cloneQueuedInput);
122
225
  }
123
226
  removeQueuedInput(id) {
124
- this.db.prepare("DELETE FROM queued_inputs WHERE id = ?").run(id);
227
+ for (const [sessionKey, queue] of this.queueBySession.entries()) {
228
+ const index = queue.findIndex((item) => item.id === id);
229
+ if (index < 0)
230
+ continue;
231
+ queue.splice(index, 1);
232
+ if (queue.length === 0) {
233
+ this.queueBySession.delete(sessionKey);
234
+ }
235
+ return;
236
+ }
125
237
  }
126
238
  removeQueuedInputForSession(sessionKey, id) {
127
- const result = this.db
128
- .prepare("DELETE FROM queued_inputs WHERE session_key = ? AND id = ?")
129
- .run(sessionKey, id);
130
- return (result.changes ?? 0) > 0;
239
+ const queue = this.queueBySession.get(sessionKey);
240
+ if (!queue)
241
+ return false;
242
+ const index = queue.findIndex((item) => item.id === id);
243
+ if (index < 0)
244
+ return false;
245
+ queue.splice(index, 1);
246
+ if (queue.length === 0) {
247
+ this.queueBySession.delete(sessionKey);
248
+ }
249
+ return true;
131
250
  }
132
251
  clearQueuedInputs(sessionKey) {
133
- const result = this.db.prepare("DELETE FROM queued_inputs WHERE session_key = ?").run(sessionKey);
134
- return result.changes ?? 0;
252
+ const queue = this.queueBySession.get(sessionKey) ?? [];
253
+ this.queueBySession.delete(sessionKey);
254
+ return queue.length;
135
255
  }
136
256
  bindThread(sessionKey, threadId) {
137
- this.patch(sessionKey, {
138
- codex_thread_id: threadId,
257
+ this.patchDurableSession(sessionKey, {
258
+ codexThreadId: threadId,
139
259
  });
140
260
  }
141
261
  setTelegramTopicName(sessionKey, topicName) {
142
- this.patch(sessionKey, { telegram_topic_name: topicName });
262
+ this.patchDurableSession(sessionKey, {
263
+ telegramTopicName: topicName,
264
+ });
143
265
  }
144
266
  setRuntimeState(sessionKey, state) {
145
- this.patch(sessionKey, {
146
- runtime_status: state.status,
147
- runtime_status_detail: state.detail,
148
- runtime_status_updated_at: state.updatedAt,
149
- active_turn_id: state.activeTurnId,
150
- });
267
+ this.runtimeStateBySession.set(sessionKey, state);
151
268
  }
152
269
  setOutputMessage(sessionKey, messageId) {
153
- this.patch(sessionKey, { output_message_id: messageId });
270
+ if (messageId == null) {
271
+ this.outputMessageBySession.delete(sessionKey);
272
+ return;
273
+ }
274
+ this.outputMessageBySession.set(sessionKey, messageId);
154
275
  }
155
276
  setCwd(sessionKey, cwd) {
156
- this.patch(sessionKey, { cwd });
277
+ this.patchDurableSession(sessionKey, { cwd });
157
278
  }
158
279
  setModel(sessionKey, model) {
159
- this.patch(sessionKey, { model });
280
+ this.patchDurableSession(sessionKey, { model });
160
281
  }
161
282
  setSandboxMode(sessionKey, sandboxMode) {
162
- this.patch(sessionKey, {
163
- sandbox_mode: sandboxMode,
164
- });
283
+ this.patchDurableSession(sessionKey, { sandboxMode });
165
284
  }
166
285
  setApprovalPolicy(sessionKey, approvalPolicy) {
167
- this.patch(sessionKey, { approval_policy: approvalPolicy });
286
+ this.patchDurableSession(sessionKey, { approvalPolicy });
168
287
  }
169
288
  setReasoningEffort(sessionKey, reasoningEffort) {
170
- this.patch(sessionKey, { reasoning_effort: reasoningEffort });
289
+ this.patchDurableSession(sessionKey, { reasoningEffort });
171
290
  }
172
291
  setWebSearchMode(sessionKey, webSearchMode) {
173
- this.patch(sessionKey, { web_search_mode: webSearchMode });
292
+ this.patchDurableSession(sessionKey, { webSearchMode });
174
293
  }
175
294
  setNetworkAccessEnabled(sessionKey, enabled) {
176
- this.patch(sessionKey, { network_access_enabled: enabled ? 1 : 0 });
295
+ this.patchDurableSession(sessionKey, { networkAccessEnabled: enabled });
177
296
  }
178
297
  setSkipGitRepoCheck(sessionKey, skip) {
179
- this.patch(sessionKey, { skip_git_repo_check: skip ? 1 : 0 });
298
+ this.patchDurableSession(sessionKey, { skipGitRepoCheck: skip });
180
299
  }
181
300
  setAdditionalDirectories(sessionKey, directories) {
182
- this.patch(sessionKey, { additional_directories: JSON.stringify(directories) });
301
+ this.patchDurableSession(sessionKey, { additionalDirectories: [...directories] });
183
302
  }
184
303
  setOutputSchema(sessionKey, outputSchema) {
185
- this.patch(sessionKey, { output_schema: outputSchema });
304
+ this.patchDurableSession(sessionKey, { outputSchema });
186
305
  }
187
- patch(sessionKey, fields) {
188
- const entries = Object.entries(fields);
189
- if (entries.length === 0)
190
- return;
191
- const setSql = entries.map(([key]) => `${key} = ?`).join(", ");
192
- const values = entries.map(([, value]) => value);
193
- values.push(new Date().toISOString(), sessionKey);
194
- this.db.prepare(`UPDATE sessions SET ${setSql}, updated_at = ? WHERE session_key = ?`).run(...values);
306
+ patchDurableSession(sessionKey, patch) {
307
+ this.storage.patchSession(sessionKey, patch);
195
308
  }
196
309
  }
197
310
  export function makeSessionKey(chatId, messageThreadId) {
198
311
  return messageThreadId == null ? String(chatId) : `${chatId}:${messageThreadId}`;
199
312
  }
200
- function mapSessionRow(row) {
313
+ function mapStoredSession(stored, runtimeState, outputMessageId) {
314
+ if (!stored)
315
+ return null;
316
+ const runtime = runtimeState ?? {
317
+ status: "idle",
318
+ detail: null,
319
+ updatedAt: stored.updatedAt,
320
+ activeTurnId: null,
321
+ };
201
322
  return {
202
- sessionKey: row.session_key,
203
- chatId: row.chat_id,
204
- messageThreadId: row.message_thread_id,
205
- telegramTopicName: row.telegram_topic_name ?? null,
206
- codexThreadId: row.codex_thread_id,
207
- cwd: row.cwd,
208
- model: row.model,
209
- sandboxMode: normalizeSandboxMode(row.sandbox_mode),
210
- approvalPolicy: normalizeApprovalPolicy(row.approval_policy),
211
- reasoningEffort: normalizeReasoningEffort(row.reasoning_effort),
212
- webSearchMode: normalizeWebSearchMode(row.web_search_mode),
213
- networkAccessEnabled: normalizeBoolean(row.network_access_enabled, true),
214
- skipGitRepoCheck: normalizeBoolean(row.skip_git_repo_check, true),
215
- additionalDirectories: normalizeStringArray(row.additional_directories),
216
- outputSchema: normalizeOutputSchema(row.output_schema),
217
- runtimeStatus: normalizeRuntimeStatus(row.runtime_status, row.active_turn_id),
218
- runtimeStatusDetail: row.runtime_status_detail ?? null,
219
- runtimeStatusUpdatedAt: row.runtime_status_updated_at ?? row.updated_at,
220
- activeTurnId: row.active_turn_id,
221
- outputMessageId: row.output_message_id,
222
- createdAt: row.created_at,
223
- updatedAt: row.updated_at,
323
+ ...stored,
324
+ additionalDirectories: [...stored.additionalDirectories],
325
+ runtimeStatus: runtime.status,
326
+ runtimeStatusDetail: runtime.detail,
327
+ runtimeStatusUpdatedAt: runtime.updatedAt,
328
+ activeTurnId: runtime.activeTurnId,
329
+ outputMessageId: outputMessageId ?? null,
224
330
  };
225
331
  }
226
- function mapQueuedInputRow(row) {
332
+ function cloneQueuedInput(input) {
227
333
  return {
228
- id: row.id,
229
- sessionKey: row.session_key,
230
- text: row.text,
231
- input: parseStoredCodexInput(row.input_json, row.text),
232
- createdAt: row.created_at,
233
- updatedAt: row.updated_at,
334
+ ...input,
335
+ input: cloneStoredCodexInput(input.input),
234
336
  };
235
337
  }
236
- function normalizeSandboxMode(value) {
237
- return value && isSessionSandboxMode(value) ? value : DEFAULT_SESSION_PROFILE.sandboxMode;
238
- }
239
- function normalizeApprovalPolicy(value) {
240
- return value && isSessionApprovalPolicy(value) ? value : DEFAULT_SESSION_PROFILE.approvalPolicy;
338
+ function cloneStoredCodexInput(input) {
339
+ return typeof input === "string" ? input : input.map((item) => ({ ...item }));
241
340
  }
242
- function normalizeReasoningEffort(value) {
243
- return value && isSessionReasoningEffort(value) ? value : null;
341
+ function normalizeBindingCodeMode(value) {
342
+ return value === "rebind" ? "rebind" : "bootstrap";
244
343
  }
245
- function normalizeWebSearchMode(value) {
246
- return value && isSessionWebSearchMode(value) ? value : null;
247
- }
248
- function normalizeBoolean(value, fallback) {
249
- if (value == null)
250
- return fallback;
251
- return Number(value) !== 0;
252
- }
253
- function normalizeStringArray(value) {
344
+ function normalizeNonNegativeInteger(value) {
254
345
  if (!value)
255
- return [];
256
- try {
257
- const parsed = JSON.parse(value);
258
- if (!Array.isArray(parsed))
259
- return [];
260
- return parsed.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
261
- }
262
- catch {
263
- return [];
264
- }
265
- }
266
- function normalizeOutputSchema(value) {
267
- if (!value?.trim())
268
- return null;
269
- try {
270
- const parsed = JSON.parse(value);
271
- return isPlainObject(parsed) ? JSON.stringify(parsed) : null;
272
- }
273
- catch {
274
- return null;
275
- }
276
- }
277
- function normalizeRuntimeStatus(value, activeTurnId) {
278
- switch (value) {
279
- case "idle":
280
- case "preparing":
281
- case "running":
282
- case "failed":
283
- return value;
284
- default:
285
- return activeTurnId ? "running" : "idle";
286
- }
287
- }
288
- function parseStoredCodexInput(inputJson, fallbackText) {
289
- if (!inputJson)
290
- return fallbackText;
291
- try {
292
- const parsed = JSON.parse(inputJson);
293
- return normalizeStoredCodexInput(parsed) ?? fallbackText;
294
- }
295
- catch {
296
- return fallbackText;
297
- }
346
+ return 0;
347
+ const parsed = Number(value);
348
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
298
349
  }
299
- function normalizeStoredCodexInput(value) {
300
- if (typeof value === "string")
301
- return value;
302
- if (!Array.isArray(value))
350
+ function normalizeOptionalUserId(value) {
351
+ if (value == null)
303
352
  return null;
304
- const items = [];
305
- for (const item of value) {
306
- if (!isPlainObject(item))
307
- return null;
308
- if (item.type === "text" && typeof item.text === "string") {
309
- items.push({ type: "text", text: item.text });
310
- }
311
- else if (item.type === "local_image" && typeof item.path === "string") {
312
- items.push({ type: "local_image", path: item.path });
313
- }
314
- else {
315
- return null;
316
- }
317
- }
318
- return items;
353
+ const parsed = Number(value);
354
+ return Number.isSafeInteger(parsed) ? parsed : null;
319
355
  }
320
356
  export function formatCodexInputPreview(input) {
321
357
  if (typeof input === "string")
@@ -323,6 +359,3 @@ export function formatCodexInputPreview(input) {
323
359
  const parts = input.map((item) => (item.type === "text" ? item.text : `[image: ${item.path}]`));
324
360
  return parts.join(" ").trim() || "[image]";
325
361
  }
326
- function isPlainObject(value) {
327
- return typeof value === "object" && value !== null && !Array.isArray(value);
328
- }
@@ -11,6 +11,11 @@ export function escapeHtml(value) {
11
11
  .replaceAll("<", "&lt;")
12
12
  .replaceAll(">", "&gt;");
13
13
  }
14
+ function escapeHtmlAttribute(value) {
15
+ return escapeHtml(value)
16
+ .replaceAll('"', "&quot;")
17
+ .replaceAll("'", "&#39;");
18
+ }
14
19
  export function renderMarkdownForTelegram(markdown) {
15
20
  const tokens = md.parse(markdown, {});
16
21
  const html = renderTokens(tokens).replace(/\n{3,}/g, "\n\n").trim();
@@ -95,6 +100,7 @@ function renderTokens(tokens) {
95
100
  }
96
101
  function renderInline(tokens) {
97
102
  let out = "";
103
+ const linkStack = [];
98
104
  for (const token of tokens) {
99
105
  switch (token.type) {
100
106
  case "text":
@@ -122,12 +128,18 @@ function renderInline(tokens) {
122
128
  out += "</s>";
123
129
  break;
124
130
  case "link_open": {
125
- const href = token.attrGet("href");
126
- out += href ? `<a href="${escapeHtml(href)}">` : "";
131
+ const href = sanitizeTelegramHref(token.attrGet("href"));
132
+ const opened = Boolean(href);
133
+ linkStack.push(opened);
134
+ if (href) {
135
+ out += `<a href="${escapeHtmlAttribute(href)}">`;
136
+ }
127
137
  break;
128
138
  }
129
139
  case "link_close":
130
- out += "</a>";
140
+ if (linkStack.pop()) {
141
+ out += "</a>";
142
+ }
131
143
  break;
132
144
  case "softbreak":
133
145
  case "hardbreak":
@@ -144,3 +156,28 @@ function renderInline(tokens) {
144
156
  }
145
157
  return out;
146
158
  }
159
+ function sanitizeTelegramHref(value) {
160
+ if (!value)
161
+ return null;
162
+ const href = value.trim();
163
+ if (!href)
164
+ return null;
165
+ try {
166
+ const url = new URL(href);
167
+ return isAllowedTelegramProtocol(url.protocol) ? url.toString() : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ function isAllowedTelegramProtocol(protocol) {
174
+ switch (protocol) {
175
+ case "http:":
176
+ case "https:":
177
+ case "mailto:":
178
+ case "tg:":
179
+ return true;
180
+ default:
181
+ return false;
182
+ }
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telecodex",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Telegram bridge for local Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "scripts": {
43
43
  "dev": "NODE_OPTIONS=--disable-warning=ExperimentalWarning tsx src/cli.ts",
44
- "build": "tsc -p tsconfig.json",
44
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
45
45
  "prepack": "npm run build",
46
46
  "start": "node --disable-warning=ExperimentalWarning dist/cli.js",
47
47
  "check": "tsc -p tsconfig.json --noEmit",