telecodex 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 telecodex contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # telecodex
2
+
3
+ Telegram bridge for local Codex built on the official TypeScript SDK.
4
+
5
+ ## Model
6
+
7
+ ```text
8
+ Telegram private chat
9
+ -> one-time admin bootstrap
10
+
11
+ Telegram forum supergroup
12
+ -> one project per supergroup
13
+ -> one topic per Codex thread
14
+
15
+ Telegram
16
+ -> grammY bot + runner
17
+ -> CodexSdkRuntime
18
+ -> @openai/codex-sdk
19
+ -> local Codex login
20
+ ```
21
+
22
+ The bot talks to local Codex through `@openai/codex-sdk`, which wraps the local
23
+ `codex` CLI. It does not depend on `codex app-server`.
24
+
25
+ ## Runtime contract
26
+
27
+ - Telegram is treated as a remote task interface, not a clone of Codex Desktop.
28
+ - One topic maps to one Codex SDK thread.
29
+ - Each topic has at most one active SDK run.
30
+ - Follow-up messages during an active run are queued and processed in order.
31
+ - Text and image messages are mapped to Codex SDK input.
32
+ - A run immediately creates a normal Telegram status message; progress edits that message.
33
+ - telecodex does not use pinned messages for live state.
34
+ - While a run is pending, the bot sends Telegram `typing` activity so the chat does not look dead during long SDK gaps.
35
+ - `/status` is the source of truth for runtime state, active thread id, last SDK event, and queue depth.
36
+
37
+ ## Requirements
38
+
39
+ - Node.js 24 or newer.
40
+ - A local `codex` CLI installation available on `PATH`.
41
+ - A valid local Codex login:
42
+
43
+ ```bash
44
+ codex login status
45
+ ```
46
+
47
+ - A Telegram bot token.
48
+
49
+ ## Install from npm
50
+
51
+ ```bash
52
+ npm install -g telecodex
53
+ telecodex
54
+ ```
55
+
56
+ `telecodex` uses the local `codex` CLI at runtime, so installing this package
57
+ does not replace the separate Codex CLI installation.
58
+
59
+ ## Local development
60
+
61
+ 1. Install dependencies:
62
+
63
+ ```bash
64
+ npm install
65
+ ```
66
+
67
+ 2. Start it:
68
+
69
+ ```bash
70
+ npm run dev
71
+ ```
72
+
73
+ For a production-style local install during development, `npm link` exposes the
74
+ same `telecodex` command globally from the current checkout.
75
+
76
+ ## First launch
77
+
78
+ On first launch, `telecodex`:
79
+
80
+ 1. Finds or asks for the local `codex` binary path.
81
+ 2. Verifies Codex login.
82
+ 3. Asks for a Telegram bot token if none is stored yet.
83
+ 4. Generates a one-time bootstrap code if no Telegram admin is bound yet.
84
+ 5. Waits for that code in a private chat. The first successful sender becomes the permanent admin for this bot instance.
85
+
86
+ There are no required environment variables in the normal startup path.
87
+
88
+ Optional security override:
89
+
90
+ - `TELECODEX_ALLOW_PLAINTEXT_TOKEN_FALLBACK=1` allows storing the Telegram bot token unencrypted in local state when the system keychain is unavailable. This is disabled by default.
91
+
92
+ ## Working model
93
+
94
+ - Private chat is only for bootstrap and lightweight management.
95
+ - One forum supergroup represents one project.
96
+ - The project root is bound once with `/project bind <absolute-path>`.
97
+ - Each topic in that supergroup is one Codex thread.
98
+ - Work happens by sending normal messages inside the topic.
99
+ - `/thread new <topic-name>` automatically creates a new topic; the first normal message inside it starts a fresh Codex thread.
100
+ - `/thread resume <threadId>` automatically creates a new topic and binds it to an existing thread id.
101
+
102
+ ## Stored state
103
+
104
+ - Telegram bot token: stored in the system keychain when available. Plaintext local fallback is disabled by default and must be opted into explicitly.
105
+ - Admin binding, project bindings, and topic/session state: stored in a local SQLite database under `~/.telecodex/`.
106
+ - Runtime logs: written by `pino` to `~/.telecodex/logs/telecodex.log`.
107
+ - Working directory: defaults to the directory where you ran `telecodex`.
108
+
109
+ ## Logs
110
+
111
+ - Startup prints the active log file path.
112
+ - Telegram middleware errors and message edit failures are appended to the log file.
113
+ - When you need to debug a running instance later, inspect `~/.telecodex/logs/telecodex.log` first.
114
+
115
+ ## Commands
116
+
117
+ - `/start` or `/help` - show the current usage model.
118
+ - `/status` - in private chat shows global state; in a project topic shows project/thread runtime state.
119
+ - `/project` - show the current supergroup's project binding.
120
+ - `/project bind <absolute-path>` - bind the current supergroup to a project root.
121
+ - `/project unbind` - remove the current supergroup's project binding.
122
+ - `/thread` - in a topic, show the current attached thread id.
123
+ - `/thread new <topic-name>` - create a new topic for a fresh Codex thread.
124
+ - `/thread resume <threadId>` - create a new topic and bind it to an existing Codex thread id.
125
+ - Normal text in a topic - send that message to the current Codex thread.
126
+ - `/stop` - interrupt the active SDK run.
127
+ - `/cwd <absolute-path>` - switch the topic working directory inside the current project root.
128
+ - `/mode read|write|danger|yolo` - switch runtime presets for the current topic.
129
+ - `/sandbox <read-only|workspace-write|danger-full-access>` - set sandbox explicitly for the current topic.
130
+ - `/approval <on-request|on-failure|never>` - set approval policy explicitly for the current topic.
131
+ - `/yolo on|off` - quick toggle for `danger-full-access + never` on the current topic.
132
+ - `/model <model-id>` - set model for the current topic.
133
+ - `/effort default|minimal|low|medium|high|xhigh` - set model reasoning effort for the current topic.
134
+ - `/web default|disabled|cached|live` - set Codex SDK web search mode.
135
+ - `/network on|off` - set workspace network access for Codex SDK runs.
136
+ - `/gitcheck skip|enforce` - control Codex SDK git repository checks.
137
+ - `/adddir list|add <absolute-path>|drop <index>|clear` - manage Codex SDK additional directories.
138
+ - `/schema show|set <JSON object>|clear` - manage Codex SDK output schema for the current topic.
139
+ - `/codexconfig show|set <JSON object>|clear` - manage global non-auth Codex SDK config overrides for future runs.
140
+ - Image messages in a topic - download the Telegram image locally and send it as SDK `local_image` input.
141
+
142
+ ## Notes
143
+
144
+ - Long polling is managed by `@grammyjs/runner`.
145
+ - Streaming updates are throttled before editing Telegram messages.
146
+ - Final answers are rendered from Markdown to Telegram-safe HTML.
147
+ - Because the SDK run is in-process, a telecodex restart cannot resume a partially streamed Telegram turn; the topic is reset and the user is asked to resend.
148
+ - Authentication and provider switching remain owned by the local Codex installation; telecodex does not manage API keys or login state.
149
+ - Interactive terminal stdin bridging and native Codex approval UI are intentionally not part of the Telegram contract. For unattended remote work, use the topic's sandbox/approval preset deliberately.
@@ -0,0 +1,64 @@
1
+ export function authMiddleware(input) {
2
+ return async (ctx, next) => {
3
+ const userId = ctx.from?.id;
4
+ if (!userId) {
5
+ input.logger?.warn("telegram update ignored because it has no from.id", {
6
+ chatId: ctx.chat?.id ?? null,
7
+ chatType: ctx.chat?.type ?? null,
8
+ messageThreadId: ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id ?? null,
9
+ senderChatId: ctx.message?.sender_chat?.id ?? ctx.callbackQuery?.message?.sender_chat?.id ?? null,
10
+ hasTextMessage: Boolean(ctx.message?.text),
11
+ });
12
+ if (ctx.message?.text && ctx.chat?.type !== "private") {
13
+ await ctx.reply("This message was sent as the group identity or as an anonymous admin. telecodex cannot verify the operator. Send it from your personal account instead.");
14
+ }
15
+ return;
16
+ }
17
+ const authorizedUserId = input.store.getAuthorizedUserId();
18
+ if (authorizedUserId != null) {
19
+ if (authorizedUserId === userId) {
20
+ await next();
21
+ return;
22
+ }
23
+ input.logger?.warn("telegram update denied because user is not authorized", {
24
+ chatId: ctx.chat?.id ?? null,
25
+ chatType: ctx.chat?.type ?? null,
26
+ messageThreadId: ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id ?? null,
27
+ fromId: userId,
28
+ authorizedUserId,
29
+ });
30
+ await deny(ctx, "Unauthorized.");
31
+ return;
32
+ }
33
+ if (!input.bootstrapCode) {
34
+ await deny(ctx, "Authentication is not configured for this bot yet.");
35
+ return;
36
+ }
37
+ if (ctx.chat?.type !== "private") {
38
+ await deny(ctx, "Send the admin bootstrap code to the bot in a private chat first.");
39
+ return;
40
+ }
41
+ const messageText = ctx.message?.text?.trim();
42
+ if (messageText === input.bootstrapCode) {
43
+ const claimedUserId = input.store.claimAuthorizedUserId(userId);
44
+ if (claimedUserId === userId) {
45
+ input.onAdminBound?.(userId);
46
+ await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
47
+ }
48
+ else {
49
+ await deny(ctx, "An admin account has already claimed this bot.");
50
+ }
51
+ return;
52
+ }
53
+ await ctx.reply("This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
54
+ };
55
+ }
56
+ async function deny(ctx, text) {
57
+ if (ctx.callbackQuery) {
58
+ await ctx.answerCallbackQuery({ text, show_alert: false });
59
+ return;
60
+ }
61
+ if (ctx.chat?.type === "private") {
62
+ await ctx.reply(text);
63
+ }
64
+ }
@@ -0,0 +1,239 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, } from "../config.js";
4
+ import { makeSessionKey } from "../store/sessions.js";
5
+ import { sendPlainChunks } from "../telegram/delivery.js";
6
+ import { numericChatId, numericMessageThreadId, sessionFromContext } from "./session.js";
7
+ import { truncateSingleLine } from "./sessionFlow.js";
8
+ export function getProjectForContext(ctx, projects) {
9
+ const chatId = ctx.chat?.id;
10
+ if (chatId == null || isPrivateChat(ctx))
11
+ return null;
12
+ return projects.get(String(chatId));
13
+ }
14
+ export function getScopedSession(ctx, store, projects, config, options) {
15
+ if (isPrivateChat(ctx)) {
16
+ void ctx.reply("Private chat is only for admin binding and project overview. Do actual work inside project supergroup topics.");
17
+ return null;
18
+ }
19
+ const project = getProjectForContext(ctx, projects);
20
+ if (!project) {
21
+ void ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
22
+ return null;
23
+ }
24
+ const requireTopic = options?.requireTopic ?? true;
25
+ if (requireTopic && !hasTopicContext(ctx)) {
26
+ void ctx.reply("Use this inside a forum topic. The root chat is only for project-level commands; work happens inside topics.");
27
+ return null;
28
+ }
29
+ const session = sessionFromContext(ctx, store, config);
30
+ if (!isPathWithinRoot(session.cwd, project.cwd)) {
31
+ store.setCwd(session.sessionKey, project.cwd);
32
+ return store.get(session.sessionKey) ?? session;
33
+ }
34
+ return session;
35
+ }
36
+ export function formatHelpText(ctx, projects) {
37
+ if (isPrivateChat(ctx)) {
38
+ return [
39
+ "telecodex is ready.",
40
+ "",
41
+ "Primary workflow:",
42
+ "1. One forum supergroup = one project",
43
+ "2. One topic = one Codex thread",
44
+ "3. Send normal messages directly inside the topic",
45
+ "",
46
+ "Run this first in the project group:",
47
+ "/project bind <absolute-path>",
48
+ "",
49
+ "Then manage threads in the group:",
50
+ "/thread new <topic-name>",
51
+ "/thread resume <threadId>",
52
+ "",
53
+ "Inside a topic, send messages directly:",
54
+ "/status",
55
+ "/queue",
56
+ "/queue drop <id>",
57
+ "/queue clear",
58
+ "/stop",
59
+ "",
60
+ formatPrivateProjectSummary(projects),
61
+ ].join("\n");
62
+ }
63
+ const project = getProjectForContext(ctx, projects);
64
+ if (!project) {
65
+ return [
66
+ "This supergroup has no project bound yet.",
67
+ "",
68
+ "Run this first:",
69
+ "/project bind <absolute-path>",
70
+ "",
71
+ "After binding, each topic acts as an independent Codex thread.",
72
+ ].join("\n");
73
+ }
74
+ return [
75
+ "telecodex is ready.",
76
+ "",
77
+ `project: ${project.name}`,
78
+ `root: ${project.cwd}`,
79
+ "",
80
+ "/project show the project binding",
81
+ "/project bind <absolute-path> update the project root",
82
+ "/thread new <topic-name> create a new topic; the first message starts a new thread",
83
+ "/thread resume <threadId> create a topic bound to an existing thread",
84
+ "send a normal message inside a topic to the current thread",
85
+ "/status show topic state, recent SDK events, and queue depth",
86
+ "/queue show queued messages for the current topic",
87
+ "/queue drop <id> remove one queued message",
88
+ "/queue clear clear the current topic queue",
89
+ "/stop interrupt the current SDK run",
90
+ "/cwd <path> switch to a working subdirectory inside the project root",
91
+ `/mode ${MODE_PRESETS.join("|")}`,
92
+ `/sandbox ${SANDBOX_MODES.join("|")}`,
93
+ `/approval ${APPROVAL_POLICIES.join("|")}`,
94
+ "/yolo on|off",
95
+ "/model <id>",
96
+ `/effort default|${REASONING_EFFORTS.join("|")}`,
97
+ "/web default|disabled|cached|live",
98
+ "/network on|off",
99
+ "/gitcheck skip|enforce",
100
+ "/adddir list|add|drop|clear",
101
+ "/schema show|set|clear",
102
+ "/codexconfig show|set|clear",
103
+ ].join("\n");
104
+ }
105
+ export function formatPrivateStatus(store, projects) {
106
+ return [
107
+ "telecodex admin",
108
+ `authorized telegram user id: ${store.getAuthorizedUserId() ?? "not bound"}`,
109
+ "",
110
+ formatPrivateProjectSummary(projects),
111
+ ].join("\n");
112
+ }
113
+ export function formatPrivateProjectSummary(projects) {
114
+ const bound = projects.list();
115
+ if (bound.length === 0) {
116
+ return "No project supergroups are currently bound.";
117
+ }
118
+ return `Bound project supergroups: ${bound.length}`;
119
+ }
120
+ export function formatPrivateProjectList(projects) {
121
+ const bound = projects.list();
122
+ if (bound.length === 0) {
123
+ return "No project supergroups are currently bound.";
124
+ }
125
+ return [
126
+ "Bound projects:",
127
+ ...bound.map((project, index) => `${index + 1}. ${project.name}\n root: ${project.cwd}\n chat: ${project.chatId}`),
128
+ ].join("\n");
129
+ }
130
+ export function formatProjectStatus(project) {
131
+ return [
132
+ "Project status",
133
+ `project: ${project.name}`,
134
+ `root: ${project.cwd}`,
135
+ "This supergroup represents one project. Use /thread new or /thread resume to create topics.",
136
+ ].join("\n");
137
+ }
138
+ export function ensureTopicSession(input) {
139
+ const sessionKey = makeSessionKey(input.chatId, input.messageThreadId);
140
+ const session = input.store.getOrCreate({
141
+ sessionKey,
142
+ chatId: String(input.chatId),
143
+ messageThreadId: String(input.messageThreadId),
144
+ telegramTopicName: input.topicName ?? null,
145
+ defaultCwd: input.project.cwd,
146
+ defaultModel: input.config.defaultModel,
147
+ });
148
+ if (input.topicName && session.telegramTopicName !== input.topicName) {
149
+ input.store.setTelegramTopicName(session.sessionKey, input.topicName);
150
+ }
151
+ if (input.threadId && session.codexThreadId !== input.threadId) {
152
+ input.store.bindThread(session.sessionKey, input.threadId);
153
+ }
154
+ if (!isPathWithinRoot(session.cwd, input.project.cwd)) {
155
+ input.store.setCwd(session.sessionKey, input.project.cwd);
156
+ return input.store.get(session.sessionKey) ?? session;
157
+ }
158
+ return input.store.get(session.sessionKey) ?? session;
159
+ }
160
+ export async function postTopicReadyMessage(bot, session, text, logger) {
161
+ await sendPlainChunks(bot, {
162
+ chatId: numericChatId(session),
163
+ messageThreadId: numericMessageThreadId(session),
164
+ text,
165
+ }, logger);
166
+ }
167
+ export function parseSubcommand(input) {
168
+ const trimmed = input.trim();
169
+ if (!trimmed)
170
+ return { command: null, args: "" };
171
+ const firstSpace = trimmed.indexOf(" ");
172
+ if (firstSpace < 0) {
173
+ return {
174
+ command: trimmed.toLowerCase(),
175
+ args: "",
176
+ };
177
+ }
178
+ return {
179
+ command: trimmed.slice(0, firstSpace).toLowerCase(),
180
+ args: trimmed.slice(firstSpace + 1).trim(),
181
+ };
182
+ }
183
+ export function formatTopicName(rawName, fallback) {
184
+ const normalized = rawName?.trim() || fallback;
185
+ return truncateSingleLine(normalized, 128);
186
+ }
187
+ export function resolveExistingDirectory(input) {
188
+ const resolved = path.resolve(input.trim());
189
+ if (!resolved) {
190
+ throw new Error("Project path cannot be empty.");
191
+ }
192
+ let stat;
193
+ try {
194
+ stat = statSync(resolved);
195
+ }
196
+ catch {
197
+ throw new Error(`Directory does not exist: ${resolved}`);
198
+ }
199
+ if (!stat.isDirectory()) {
200
+ throw new Error(`Not a directory: ${resolved}`);
201
+ }
202
+ return resolved;
203
+ }
204
+ export function assertProjectScopedPath(input, projectRoot) {
205
+ const resolved = path.resolve(input.trim());
206
+ if (!isPathWithinRoot(resolved, projectRoot)) {
207
+ throw new Error(["Path must stay within the project root.", `project root: ${projectRoot}`, `input: ${resolved}`].join("\n"));
208
+ }
209
+ return resolved;
210
+ }
211
+ export function formatProfileReply(prefix, sandboxMode, approvalPolicy) {
212
+ return [prefix, `sandbox: ${sandboxMode}`, `approval: ${approvalPolicy}`].join("\n");
213
+ }
214
+ export function formatReasoningEffort(value) {
215
+ return value ?? "codex-default";
216
+ }
217
+ export function contextLogFields(ctx) {
218
+ return {
219
+ chatId: ctx.chat?.id ?? null,
220
+ chatType: ctx.chat?.type ?? null,
221
+ messageThreadId: ctx.message?.message_thread_id ?? ctx.callbackQuery?.message?.message_thread_id ?? null,
222
+ fromId: ctx.from?.id ?? null,
223
+ updateId: ctx.update.update_id,
224
+ };
225
+ }
226
+ export function isPrivateChat(ctx) {
227
+ return ctx.chat?.type === "private";
228
+ }
229
+ export function isSupergroupChat(ctx) {
230
+ return ctx.chat?.type === "supergroup";
231
+ }
232
+ export function hasTopicContext(ctx) {
233
+ return ctx.message?.message_thread_id != null || ctx.callbackQuery?.message?.message_thread_id != null;
234
+ }
235
+ export function isPathWithinRoot(candidate, root) {
236
+ const resolvedCandidate = path.resolve(candidate);
237
+ const resolvedRoot = path.resolve(root);
238
+ return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
239
+ }
@@ -0,0 +1,51 @@
1
+ import { Bot } from "grammy";
2
+ import { MessageBuffer } from "../telegram/messageBuffer.js";
3
+ import { authMiddleware } from "./auth.js";
4
+ import { recoverActiveTopicSessions } from "./inputService.js";
5
+ import { registerHandlers } from "./registerHandlers.js";
6
+ export { handleUserText, refreshSessionIfActiveTurnIsStale } from "./inputService.js";
7
+ export function wireBot(input) {
8
+ const { bot, config, store, projects, codex, bootstrapCode, logger, onAdminBound } = input;
9
+ const buffers = new MessageBuffer(bot, config.updateIntervalMs, logger?.child("message-buffer"));
10
+ bot.use(authMiddleware({
11
+ bootstrapCode,
12
+ store,
13
+ ...(logger ? { logger: logger.child("auth") } : {}),
14
+ ...(onAdminBound ? { onAdminBound } : {}),
15
+ }));
16
+ if (logger) {
17
+ bot.catch((error) => {
18
+ logger.error("grammy bot error", {
19
+ updateId: error.ctx.update.update_id,
20
+ chatId: error.ctx.chat?.id ?? null,
21
+ chatType: error.ctx.chat?.type ?? null,
22
+ messageThreadId: error.ctx.message?.message_thread_id ?? error.ctx.callbackQuery?.message?.message_thread_id ?? null,
23
+ fromId: error.ctx.from?.id ?? null,
24
+ error: error.error,
25
+ });
26
+ });
27
+ }
28
+ registerHandlers({
29
+ bot,
30
+ config,
31
+ store,
32
+ projects,
33
+ codex,
34
+ buffers,
35
+ ...(logger ? { logger } : {}),
36
+ });
37
+ void recoverActiveTopicSessions(store, codex, buffers, bot, logger);
38
+ return {
39
+ bot,
40
+ buffers,
41
+ };
42
+ }
43
+ export function createBot(input) {
44
+ const { config } = input;
45
+ const bot = new Bot(config.telegramBotToken);
46
+ wireBot({
47
+ bot,
48
+ ...input,
49
+ });
50
+ return bot;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { contextLogFields, getScopedSession, } from "../commandSupport.js";
2
+ import { handleUserInput, handleUserText } from "../inputService.js";
3
+ import { telegramImageMessageToCodexInput } from "../../telegram/attachments.js";
4
+ export function registerMessageHandlers(deps) {
5
+ const { bot, config, store, projects, codex, buffers, logger } = deps;
6
+ bot.on("message:text", async (ctx) => {
7
+ const text = ctx.message.text;
8
+ logger?.info("received telegram text message", {
9
+ ...contextLogFields(ctx),
10
+ textLength: text.length,
11
+ isCommand: text.startsWith("/"),
12
+ });
13
+ if (text.startsWith("/"))
14
+ return;
15
+ const session = getScopedSession(ctx, store, projects, config);
16
+ if (!session) {
17
+ logger?.warn("ignored telegram text message because no scoped session was available", {
18
+ ...contextLogFields(ctx),
19
+ textLength: text.length,
20
+ });
21
+ return;
22
+ }
23
+ await handleUserText({
24
+ text,
25
+ session,
26
+ store,
27
+ codex,
28
+ buffers,
29
+ bot,
30
+ ...(logger ? { logger } : {}),
31
+ });
32
+ });
33
+ bot.on(["message:photo", "message:document"], async (ctx) => {
34
+ const session = getScopedSession(ctx, store, projects, config);
35
+ if (!session) {
36
+ logger?.warn("ignored telegram attachment because no scoped session was available", {
37
+ ...contextLogFields(ctx),
38
+ });
39
+ return;
40
+ }
41
+ try {
42
+ const prompt = await telegramImageMessageToCodexInput({
43
+ bot,
44
+ config,
45
+ chatId: ctx.chat.id,
46
+ messageThreadId: ctx.message.message_thread_id ?? null,
47
+ message: ctx.message,
48
+ });
49
+ if (!prompt) {
50
+ await ctx.reply("Only image attachments are supported.");
51
+ return;
52
+ }
53
+ await handleUserInput({
54
+ prompt,
55
+ session,
56
+ store,
57
+ codex,
58
+ buffers,
59
+ bot,
60
+ ...(logger ? { logger } : {}),
61
+ });
62
+ }
63
+ catch (error) {
64
+ logger?.warn("failed to handle telegram image attachment", {
65
+ ...contextLogFields(ctx),
66
+ error,
67
+ });
68
+ await ctx.reply(error instanceof Error ? error.message : String(error));
69
+ }
70
+ });
71
+ }