telecodex 0.1.4 → 0.1.5

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.
@@ -2,9 +2,10 @@ import { contextLogFields, requireScopedSession, } from "../commandSupport.js";
2
2
  import { handleUserInput, handleUserText } from "../inputService.js";
3
3
  import { telegramImageMessageToCodexInput } from "../../telegram/attachments.js";
4
4
  import { replyError, replyNotice } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  export function registerMessageHandlers(deps) {
6
7
  const { bot, config, store, projects, codex, buffers, logger } = deps;
7
- bot.on("message:text", async (ctx) => {
8
+ bot.on("message:text", wrapUserFacingHandler("message:text", logger, async (ctx) => {
8
9
  const text = ctx.message.text;
9
10
  logger?.info("received telegram text message", {
10
11
  ...contextLogFields(ctx),
@@ -30,8 +31,8 @@ export function registerMessageHandlers(deps) {
30
31
  bot,
31
32
  ...(logger ? { logger } : {}),
32
33
  });
33
- });
34
- bot.on(["message:photo", "message:document"], async (ctx) => {
34
+ }));
35
+ bot.on(["message:photo", "message:document"], wrapUserFacingHandler("message:attachment", logger, async (ctx) => {
35
36
  const session = await requireScopedSession(ctx, store, projects, config);
36
37
  if (!session) {
37
38
  logger?.warn("ignored telegram attachment because no scoped session was available", {
@@ -68,5 +69,5 @@ export function registerMessageHandlers(deps) {
68
69
  });
69
70
  await replyError(ctx, error instanceof Error ? error.message : String(error));
70
71
  }
71
- });
72
+ }));
72
73
  }
@@ -5,12 +5,13 @@ import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
5
5
  import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, hasTopicContext, isPrivateChat, parseSubcommand, requireScopedSession, } from "../commandSupport.js";
6
6
  import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
7
7
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
8
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
8
9
  export function registerOperationalHandlers(deps) {
9
10
  const { bot, config, store, projects, codex, logger } = deps;
10
- bot.command(["start", "help"], async (ctx) => {
11
+ bot.command(["start", "help"], wrapUserFacingHandler("help", logger, async (ctx) => {
11
12
  await replyNotice(ctx, formatHelpText(ctx, projects));
12
- });
13
- bot.command("admin", async (ctx) => {
13
+ }));
14
+ bot.command("admin", wrapUserFacingHandler("admin", logger, async (ctx) => {
14
15
  if (!isPrivateChat(ctx)) {
15
16
  await replyNotice(ctx, "Use /admin in the bot private chat.");
16
17
  return;
@@ -62,8 +63,8 @@ export function registerOperationalHandlers(deps) {
62
63
  return;
63
64
  }
64
65
  await replyUsage(ctx, "/admin | /admin rebind | /admin cancel");
65
- });
66
- bot.command("status", async (ctx) => {
66
+ }));
67
+ bot.command("status", wrapUserFacingHandler("status", logger, async (ctx) => {
67
68
  if (isPrivateChat(ctx)) {
68
69
  await replyNotice(ctx, formatPrivateStatus(store, projects));
69
70
  return;
@@ -113,8 +114,8 @@ export function registerOperationalHandlers(deps) {
113
114
  textField("effort", formatReasoningEffort(latestSession.reasoningEffort)),
114
115
  ],
115
116
  });
116
- });
117
- bot.command("queue", async (ctx) => {
117
+ }));
118
+ bot.command("queue", wrapUserFacingHandler("queue", logger, async (ctx) => {
118
119
  const session = await requireScopedSession(ctx, store, projects, config);
119
120
  if (!session)
120
121
  return;
@@ -155,8 +156,8 @@ export function registerOperationalHandlers(deps) {
155
156
  return;
156
157
  }
157
158
  await replyUsage(ctx, "/queue | /queue drop <id> | /queue clear");
158
- });
159
- bot.command("stop", async (ctx) => {
159
+ }));
160
+ bot.command("stop", wrapUserFacingHandler("stop", logger, async (ctx) => {
160
161
  const session = await requireScopedSession(ctx, store, projects, config);
161
162
  if (!session)
162
163
  return;
@@ -177,7 +178,7 @@ export function registerOperationalHandlers(deps) {
177
178
  });
178
179
  await replyError(ctx, `Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
179
180
  }
180
- });
181
+ }));
181
182
  }
182
183
  function formatQueuedPreview(items) {
183
184
  if (items.length === 0)
@@ -2,10 +2,11 @@ import path from "node:path";
2
2
  import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
4
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
6
7
  export function registerProjectHandlers(deps) {
7
8
  const { bot, config, store, projects, logger } = deps;
8
- bot.command("project", async (ctx) => {
9
+ bot.command("project", wrapUserFacingHandler("project", logger, async (ctx) => {
9
10
  const { command, args } = parseSubcommand(ctx.match.trim());
10
11
  if (isPrivateChat(ctx)) {
11
12
  if (!command || command === "list" || command === "status") {
@@ -67,8 +68,8 @@ export function registerProjectHandlers(deps) {
67
68
  return;
68
69
  }
69
70
  await replyUsage(ctx, ["/project", "/project bind <absolute-path>", "/project unbind"]);
70
- });
71
- bot.command("thread", async (ctx) => {
71
+ }));
72
+ bot.command("thread", wrapUserFacingHandler("thread", logger, async (ctx) => {
72
73
  if (isPrivateChat(ctx)) {
73
74
  await replyNotice(ctx, "The thread command is only available inside project supergroups.");
74
75
  return;
@@ -112,7 +113,7 @@ export function registerProjectHandlers(deps) {
112
113
  return;
113
114
  }
114
115
  await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
115
- });
116
+ }));
116
117
  bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
117
118
  const threadId = ctx.message.message_thread_id;
118
119
  if (threadId == null)
@@ -2,6 +2,7 @@ import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, WEB_
2
2
  import { parseCodexConfigOverrides } from "../../codex/configOverrides.js";
3
3
  import { assertProjectScopedPath, formatProfileReply, formatReasoningEffort, getProjectForContext, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
4
4
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  export function registerSessionConfigHandlers(deps) {
6
7
  registerDirectoryHandlers(deps);
7
8
  registerProfileHandlers(deps);
@@ -9,8 +10,8 @@ export function registerSessionConfigHandlers(deps) {
9
10
  registerAdvancedHandlers(deps);
10
11
  }
11
12
  function registerDirectoryHandlers(deps) {
12
- const { bot, config, store, projects } = deps;
13
- bot.command("cwd", async (ctx) => {
13
+ const { bot, config, store, projects, logger } = deps;
14
+ bot.command("cwd", wrapUserFacingHandler("cwd", logger, async (ctx) => {
14
15
  const project = getProjectForContext(ctx, projects);
15
16
  const session = await requireScopedSession(ctx, store, projects, config);
16
17
  if (!project || !session)
@@ -34,11 +35,11 @@ function registerDirectoryHandlers(deps) {
34
35
  catch (error) {
35
36
  await replyError(ctx, error instanceof Error ? error.message : String(error));
36
37
  }
37
- });
38
+ }));
38
39
  }
39
40
  function registerProfileHandlers(deps) {
40
- const { bot, config, store, projects } = deps;
41
- bot.command("mode", async (ctx) => {
41
+ const { bot, config, store, projects, logger } = deps;
42
+ bot.command("mode", wrapUserFacingHandler("mode", logger, async (ctx) => {
42
43
  const session = await requireScopedSession(ctx, store, projects, config);
43
44
  if (!session)
44
45
  return;
@@ -59,8 +60,8 @@ function registerProfileHandlers(deps) {
59
60
  store.setSandboxMode(session.sessionKey, profile.sandboxMode);
60
61
  store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
61
62
  await replyNotice(ctx, formatProfileReply("Preset updated.", profile.sandboxMode, profile.approvalPolicy));
62
- });
63
- bot.command("sandbox", async (ctx) => {
63
+ }));
64
+ bot.command("sandbox", wrapUserFacingHandler("sandbox", logger, async (ctx) => {
64
65
  const session = await requireScopedSession(ctx, store, projects, config);
65
66
  if (!session)
66
67
  return;
@@ -75,8 +76,8 @@ function registerProfileHandlers(deps) {
75
76
  }
76
77
  store.setSandboxMode(session.sessionKey, sandboxMode);
77
78
  await replyNotice(ctx, formatProfileReply("Sandbox updated.", sandboxMode, session.approvalPolicy));
78
- });
79
- bot.command("approval", async (ctx) => {
79
+ }));
80
+ bot.command("approval", wrapUserFacingHandler("approval", logger, async (ctx) => {
80
81
  const session = await requireScopedSession(ctx, store, projects, config);
81
82
  if (!session)
82
83
  return;
@@ -91,8 +92,8 @@ function registerProfileHandlers(deps) {
91
92
  }
92
93
  store.setApprovalPolicy(session.sessionKey, approvalPolicy);
93
94
  await replyNotice(ctx, formatProfileReply("Approval policy updated.", session.sandboxMode, approvalPolicy));
94
- });
95
- bot.command("yolo", async (ctx) => {
95
+ }));
96
+ bot.command("yolo", wrapUserFacingHandler("yolo", logger, async (ctx) => {
96
97
  const session = await requireScopedSession(ctx, store, projects, config);
97
98
  if (!session)
98
99
  return;
@@ -110,11 +111,11 @@ function registerProfileHandlers(deps) {
110
111
  store.setSandboxMode(session.sessionKey, profile.sandboxMode);
111
112
  store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
112
113
  await replyNotice(ctx, formatProfileReply(value === "on" ? "YOLO enabled." : "YOLO disabled. Restored the write preset.", profile.sandboxMode, profile.approvalPolicy));
113
- });
114
+ }));
114
115
  }
115
116
  function registerExecutionHandlers(deps) {
116
- const { bot, config, store, projects } = deps;
117
- bot.command("model", async (ctx) => {
117
+ const { bot, config, store, projects, logger } = deps;
118
+ bot.command("model", wrapUserFacingHandler("model", logger, async (ctx) => {
118
119
  const session = await requireScopedSession(ctx, store, projects, config);
119
120
  if (!session)
120
121
  return;
@@ -125,8 +126,8 @@ function registerExecutionHandlers(deps) {
125
126
  }
126
127
  store.setModel(session.sessionKey, model);
127
128
  await replyNotice(ctx, `Set model: ${model}`);
128
- });
129
- bot.command("effort", async (ctx) => {
129
+ }));
130
+ bot.command("effort", wrapUserFacingHandler("effort", logger, async (ctx) => {
130
131
  const session = await requireScopedSession(ctx, store, projects, config);
131
132
  if (!session)
132
133
  return;
@@ -141,8 +142,8 @@ function registerExecutionHandlers(deps) {
141
142
  }
142
143
  store.setReasoningEffort(session.sessionKey, value === "default" ? null : value);
143
144
  await replyNotice(ctx, `Set reasoning effort: ${value === "default" ? "codex-default" : value}`);
144
- });
145
- bot.command("web", async (ctx) => {
145
+ }));
146
+ bot.command("web", wrapUserFacingHandler("web", logger, async (ctx) => {
146
147
  const session = await requireScopedSession(ctx, store, projects, config);
147
148
  if (!session)
148
149
  return;
@@ -157,8 +158,8 @@ function registerExecutionHandlers(deps) {
157
158
  }
158
159
  store.setWebSearchMode(session.sessionKey, value === "default" ? null : value);
159
160
  await replyNotice(ctx, `Set web search: ${value === "default" ? "codex-default" : value}`);
160
- });
161
- bot.command("network", async (ctx) => {
161
+ }));
162
+ bot.command("network", wrapUserFacingHandler("network", logger, async (ctx) => {
162
163
  const session = await requireScopedSession(ctx, store, projects, config);
163
164
  if (!session)
164
165
  return;
@@ -173,8 +174,8 @@ function registerExecutionHandlers(deps) {
173
174
  }
174
175
  store.setNetworkAccessEnabled(session.sessionKey, value === "on");
175
176
  await replyNotice(ctx, `Set network access: ${value}`);
176
- });
177
- bot.command("gitcheck", async (ctx) => {
177
+ }));
178
+ bot.command("gitcheck", wrapUserFacingHandler("gitcheck", logger, async (ctx) => {
178
179
  const session = await requireScopedSession(ctx, store, projects, config);
179
180
  if (!session)
180
181
  return;
@@ -189,11 +190,11 @@ function registerExecutionHandlers(deps) {
189
190
  }
190
191
  store.setSkipGitRepoCheck(session.sessionKey, value === "skip");
191
192
  await replyNotice(ctx, `Set git repo check: ${value}`);
192
- });
193
+ }));
193
194
  }
194
195
  function registerAdvancedHandlers(deps) {
195
- const { bot, config, store, projects, codex } = deps;
196
- bot.command("adddir", async (ctx) => {
196
+ const { bot, config, store, projects, codex, logger } = deps;
197
+ bot.command("adddir", wrapUserFacingHandler("adddir", logger, async (ctx) => {
197
198
  const project = getProjectForContext(ctx, projects);
198
199
  const session = await requireScopedSession(ctx, store, projects, config);
199
200
  if (!project || !session)
@@ -275,8 +276,8 @@ function registerAdvancedHandlers(deps) {
275
276
  return;
276
277
  }
277
278
  await replyUsage(ctx, "/adddir list | /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear");
278
- });
279
- bot.command("schema", async (ctx) => {
279
+ }));
280
+ bot.command("schema", wrapUserFacingHandler("schema", logger, async (ctx) => {
280
281
  const session = await requireScopedSession(ctx, store, projects, config);
281
282
  if (!session)
282
283
  return;
@@ -316,8 +317,8 @@ function registerAdvancedHandlers(deps) {
316
317
  catch (error) {
317
318
  await replyError(ctx, `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`);
318
319
  }
319
- });
320
- bot.command("codexconfig", async (ctx) => {
320
+ }));
321
+ bot.command("codexconfig", wrapUserFacingHandler("codexconfig", logger, async (ctx) => {
321
322
  const raw = ctx.match.trim();
322
323
  if (!raw || raw === "show") {
323
324
  const current = store.getAppState("codex_config_overrides");
@@ -353,7 +354,7 @@ function registerAdvancedHandlers(deps) {
353
354
  catch (error) {
354
355
  await replyError(ctx, error instanceof Error ? error.message : String(error));
355
356
  }
356
- });
357
+ }));
357
358
  }
358
359
  function isPlainObject(value) {
359
360
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -0,0 +1,59 @@
1
+ import { GrammyError, HttpError } from "grammy";
2
+ import { replyError } from "../telegram/formatted.js";
3
+ import { contextLogFields } from "./commandSupport.js";
4
+ export function wrapUserFacingHandler(handlerName, logger, handler) {
5
+ return async (ctx) => {
6
+ try {
7
+ await handler(ctx);
8
+ }
9
+ catch (error) {
10
+ logger?.error("telegram handler failed", {
11
+ handler: handlerName,
12
+ ...contextLogFields(ctx),
13
+ error,
14
+ });
15
+ await safeReplyError(ctx, describeUserFacingError(error), logger, handlerName, error);
16
+ }
17
+ };
18
+ }
19
+ async function safeReplyError(ctx, message, logger, handlerName, originalError) {
20
+ try {
21
+ await replyError(ctx, message);
22
+ }
23
+ catch (replyFailure) {
24
+ logger?.error("failed to send telegram handler error reply", {
25
+ handler: handlerName,
26
+ ...contextLogFields(ctx),
27
+ error: replyFailure,
28
+ originalError,
29
+ });
30
+ }
31
+ }
32
+ function describeUserFacingError(error) {
33
+ if (error instanceof GrammyError) {
34
+ return describeTelegramError(error);
35
+ }
36
+ if (error instanceof HttpError) {
37
+ return "Telegram request failed before it reached the API. Check the local network and try again.";
38
+ }
39
+ if (error instanceof Error) {
40
+ return error.message;
41
+ }
42
+ return String(error);
43
+ }
44
+ function describeTelegramError(error) {
45
+ const description = error.description || error.message;
46
+ const normalized = description.toLowerCase();
47
+ if (error.method === "createForumTopic") {
48
+ if (normalized.includes("not enough rights")) {
49
+ return "Telegram rejected topic creation because the bot lacks permission to create topics. Promote the bot to admin and grant topic management, then try again.";
50
+ }
51
+ if (normalized.includes("forum is disabled") ||
52
+ normalized.includes("chat is not a forum") ||
53
+ normalized.includes("topics are not enabled")) {
54
+ return "This supergroup does not currently allow forum topics. Enable topics for the group and try again.";
55
+ }
56
+ return `Failed to create the Telegram topic: ${description}`;
57
+ }
58
+ return description;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telecodex",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Telegram bridge for local Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",