telecodex 0.1.3 → 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/dist/bot/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { replyError, replyNotice } from "../telegram/formatted.js";
1
2
  export function authMiddleware(input) {
2
3
  return async (ctx, next) => {
3
4
  const userId = ctx.from?.id;
@@ -10,7 +11,7 @@ export function authMiddleware(input) {
10
11
  hasTextMessage: Boolean(ctx.message?.text),
11
12
  });
12
13
  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
+ await replyError(ctx, "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
  }
15
16
  return;
16
17
  }
@@ -31,7 +32,7 @@ export function authMiddleware(input) {
31
32
  store: input.store,
32
33
  success: async () => {
33
34
  input.store.rebindAuthorizedUserId(userId);
34
- await ctx.reply("Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
35
+ await replyNotice(ctx, "Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
35
36
  },
36
37
  mismatchLabel: "Admin handoff code did not match.",
37
38
  exhaustedLabel: "Admin handoff code exhausted its attempt limit and was invalidated. Issue a new one from the currently authorized account.",
@@ -68,7 +69,7 @@ export function authMiddleware(input) {
68
69
  const claimedUserId = input.store.claimAuthorizedUserId(userId);
69
70
  if (claimedUserId === userId) {
70
71
  input.onAdminBound?.(userId);
71
- await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
72
+ await replyNotice(ctx, "Admin binding succeeded. Only this Telegram account can use this bot from now on.");
72
73
  return;
73
74
  }
74
75
  await deny(ctx, "An admin account has already claimed this bot.");
@@ -79,7 +80,7 @@ export function authMiddleware(input) {
79
80
  if (handled)
80
81
  return;
81
82
  }
82
- 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.");
83
+ await replyNotice(ctx, "This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
83
84
  };
84
85
  }
85
86
  async function handleBindingCodeMessage(input) {
@@ -92,14 +93,14 @@ async function handleBindingCodeMessage(input) {
92
93
  }
93
94
  const attempt = input.store.recordBindingCodeFailure();
94
95
  if (!attempt) {
95
- await input.ctx.reply("The binding code is no longer active. Issue a new one and try again.");
96
+ await replyError(input.ctx, "The binding code is no longer active. Issue a new one and try again.");
96
97
  return true;
97
98
  }
98
99
  if (attempt.exhausted) {
99
- await input.ctx.reply(input.exhaustedLabel);
100
+ await replyError(input.ctx, input.exhaustedLabel);
100
101
  return true;
101
102
  }
102
- await input.ctx.reply(`${input.mismatchLabel}\nRemaining attempts: ${attempt.remaining}`);
103
+ await replyError(input.ctx, input.mismatchLabel, `Remaining attempts: ${attempt.remaining}`);
103
104
  return true;
104
105
  }
105
106
  async function deny(ctx, text) {
@@ -108,6 +109,6 @@ async function deny(ctx, text) {
108
109
  return;
109
110
  }
110
111
  if (ctx.chat?.type === "private") {
111
- await ctx.reply(text);
112
+ await replyError(ctx, text);
112
113
  }
113
114
  }
@@ -2,7 +2,7 @@ import { realpathSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, } from "../config.js";
4
4
  import { makeSessionKey } from "../store/sessions.js";
5
- import { sendPlainChunks } from "../telegram/delivery.js";
5
+ import { replyNotice, sendReplyNotice } from "../telegram/formatted.js";
6
6
  import { numericChatId, numericMessageThreadId, sessionFromContext } from "./session.js";
7
7
  import { truncateSingleLine } from "./sessionFlow.js";
8
8
  export function getProjectForContext(ctx, projects) {
@@ -12,20 +12,12 @@ export function getProjectForContext(ctx, projects) {
12
12
  return projects.get(String(chatId));
13
13
  }
14
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
15
  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.");
16
+ if (!project || isPrivateChat(ctx))
22
17
  return null;
23
- }
24
18
  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.");
19
+ if (requireTopic && !hasTopicContext(ctx))
27
20
  return null;
28
- }
29
21
  const session = sessionFromContext(ctx, store, config);
30
22
  if (!isPathWithinRoot(session.cwd, project.cwd)) {
31
23
  store.setCwd(session.sessionKey, project.cwd);
@@ -33,6 +25,13 @@ export function getScopedSession(ctx, store, projects, config, options) {
33
25
  }
34
26
  return session;
35
27
  }
28
+ export async function requireScopedSession(ctx, store, projects, config, options) {
29
+ const session = getScopedSession(ctx, store, projects, config, options);
30
+ if (session)
31
+ return session;
32
+ await replyScopedSessionRequirement(ctx, projects, options);
33
+ return null;
34
+ }
36
35
  export function formatHelpText(ctx, projects) {
37
36
  if (isPrivateChat(ctx)) {
38
37
  return [
@@ -163,11 +162,10 @@ export function ensureTopicSession(input) {
163
162
  return input.store.get(session.sessionKey) ?? session;
164
163
  }
165
164
  export async function postTopicReadyMessage(bot, session, text, logger) {
166
- await sendPlainChunks(bot, {
165
+ await sendReplyNotice(bot, {
167
166
  chatId: numericChatId(session),
168
167
  messageThreadId: numericMessageThreadId(session),
169
- text,
170
- }, logger);
168
+ }, text, logger);
171
169
  }
172
170
  export function parseSubcommand(input) {
173
171
  const trimmed = input.trim();
@@ -252,3 +250,18 @@ function canonicalizeBoundaryPath(input) {
252
250
  return resolved;
253
251
  }
254
252
  }
253
+ async function replyScopedSessionRequirement(ctx, projects, options) {
254
+ if (isPrivateChat(ctx)) {
255
+ await replyNotice(ctx, "Private chat is only for admin binding and project overview. Do actual work inside project supergroup topics.");
256
+ return;
257
+ }
258
+ const project = getProjectForContext(ctx, projects);
259
+ if (!project) {
260
+ await replyNotice(ctx, "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
261
+ return;
262
+ }
263
+ const requireTopic = options?.requireTopic ?? true;
264
+ if (requireTopic && !hasTopicContext(ctx)) {
265
+ await replyNotice(ctx, "Use this inside a forum topic. The root chat is only for project-level commands; work happens inside topics.");
266
+ }
267
+ }
@@ -1,6 +1,7 @@
1
- import { contextLogFields, getScopedSession, } from "../commandSupport.js";
1
+ import { contextLogFields, requireScopedSession, } from "../commandSupport.js";
2
2
  import { handleUserInput, handleUserText } from "../inputService.js";
3
3
  import { telegramImageMessageToCodexInput } from "../../telegram/attachments.js";
4
+ import { replyError, replyNotice } from "../../telegram/formatted.js";
4
5
  export function registerMessageHandlers(deps) {
5
6
  const { bot, config, store, projects, codex, buffers, logger } = deps;
6
7
  bot.on("message:text", async (ctx) => {
@@ -12,7 +13,7 @@ export function registerMessageHandlers(deps) {
12
13
  });
13
14
  if (text.startsWith("/"))
14
15
  return;
15
- const session = getScopedSession(ctx, store, projects, config);
16
+ const session = await requireScopedSession(ctx, store, projects, config);
16
17
  if (!session) {
17
18
  logger?.warn("ignored telegram text message because no scoped session was available", {
18
19
  ...contextLogFields(ctx),
@@ -31,7 +32,7 @@ export function registerMessageHandlers(deps) {
31
32
  });
32
33
  });
33
34
  bot.on(["message:photo", "message:document"], async (ctx) => {
34
- const session = getScopedSession(ctx, store, projects, config);
35
+ const session = await requireScopedSession(ctx, store, projects, config);
35
36
  if (!session) {
36
37
  logger?.warn("ignored telegram attachment because no scoped session was available", {
37
38
  ...contextLogFields(ctx),
@@ -47,7 +48,7 @@ export function registerMessageHandlers(deps) {
47
48
  message: ctx.message,
48
49
  });
49
50
  if (!prompt) {
50
- await ctx.reply("Only image attachments are supported.");
51
+ await replyNotice(ctx, "Only image attachments are supported.");
51
52
  return;
52
53
  }
53
54
  await handleUserInput({
@@ -65,7 +66,7 @@ export function registerMessageHandlers(deps) {
65
66
  ...contextLogFields(ctx),
66
67
  error,
67
68
  });
68
- await ctx.reply(error instanceof Error ? error.message : String(error));
69
+ await replyError(ctx, error instanceof Error ? error.message : String(error));
69
70
  }
70
71
  });
71
72
  }
@@ -2,34 +2,37 @@ import { presetFromProfile } from "../../config.js";
2
2
  import { generateBindingCode } from "../../runtime/bindingCodes.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
4
  import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
5
- import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
5
+ import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, hasTopicContext, isPrivateChat, parseSubcommand, requireScopedSession, } from "../commandSupport.js";
6
6
  import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
7
+ import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
7
8
  export function registerOperationalHandlers(deps) {
8
9
  const { bot, config, store, projects, codex, logger } = deps;
9
10
  bot.command(["start", "help"], async (ctx) => {
10
- await ctx.reply(formatHelpText(ctx, projects));
11
+ await replyNotice(ctx, formatHelpText(ctx, projects));
11
12
  });
12
13
  bot.command("admin", async (ctx) => {
13
14
  if (!isPrivateChat(ctx)) {
14
- await ctx.reply("Use /admin in the bot private chat.");
15
+ await replyNotice(ctx, "Use /admin in the bot private chat.");
15
16
  return;
16
17
  }
17
18
  const authorizedUserId = store.getAuthorizedUserId();
18
19
  if (authorizedUserId == null) {
19
- await ctx.reply("Admin binding is not completed yet.");
20
+ await replyNotice(ctx, "Admin binding is not completed yet.");
20
21
  return;
21
22
  }
22
23
  const { command } = parseSubcommand(ctx.match.trim());
23
24
  const binding = store.getBindingCodeState();
24
25
  if (!command || command === "status") {
25
- await ctx.reply([
26
- "Admin status",
27
- `authorized telegram user id: ${authorizedUserId}`,
28
- binding?.mode === "rebind"
29
- ? `pending handoff: active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
30
- : "pending handoff: none",
31
- "Usage: /admin | /admin rebind | /admin cancel",
32
- ].join("\n"));
26
+ await replyDocument(ctx, {
27
+ title: "Admin status",
28
+ fields: [
29
+ textField("authorized telegram user id", authorizedUserId),
30
+ textField("pending handoff", binding?.mode === "rebind"
31
+ ? `active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
32
+ : "none"),
33
+ ],
34
+ footer: "Usage: /admin | /admin rebind | /admin cancel",
35
+ });
33
36
  return;
34
37
  }
35
38
  if (command === "rebind") {
@@ -38,124 +41,133 @@ export function registerOperationalHandlers(deps) {
38
41
  mode: "rebind",
39
42
  issuedByUserId: authorizedUserId,
40
43
  });
41
- await ctx.reply([
42
- "Admin handoff code created.",
43
- `expires at: ${formatIsoTimestamp(next.expiresAt)}`,
44
- `max failed attempts: ${next.maxAttempts}`,
45
- "",
46
- next.code,
47
- "",
48
- "Send this code from the target Telegram account in this bot's private chat to transfer control.",
49
- ].join("\n"));
44
+ await replyDocument(ctx, {
45
+ title: "Admin handoff code created.",
46
+ fields: [
47
+ textField("expires at", formatIsoTimestamp(next.expiresAt)),
48
+ textField("max failed attempts", next.maxAttempts),
49
+ codeField("code", next.code),
50
+ ],
51
+ footer: "Send this code from the target Telegram account in this bot's private chat to transfer control.",
52
+ });
50
53
  return;
51
54
  }
52
55
  if (command === "cancel") {
53
56
  if (binding?.mode !== "rebind") {
54
- await ctx.reply("No pending admin handoff.");
57
+ await replyNotice(ctx, "No pending admin handoff.");
55
58
  return;
56
59
  }
57
60
  store.clearBindingCode();
58
- await ctx.reply("Cancelled the pending admin handoff.");
61
+ await replyNotice(ctx, "Cancelled the pending admin handoff.");
59
62
  return;
60
63
  }
61
- await ctx.reply("Usage: /admin | /admin rebind | /admin cancel");
64
+ await replyUsage(ctx, "/admin | /admin rebind | /admin cancel");
62
65
  });
63
66
  bot.command("status", async (ctx) => {
64
67
  if (isPrivateChat(ctx)) {
65
- await ctx.reply(formatPrivateStatus(store, projects));
68
+ await replyNotice(ctx, formatPrivateStatus(store, projects));
66
69
  return;
67
70
  }
68
71
  const project = getProjectForContext(ctx, projects);
69
72
  if (!project) {
70
- await ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
73
+ await replyNotice(ctx, "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
71
74
  return;
72
75
  }
73
76
  if (!hasTopicContext(ctx)) {
74
- await ctx.reply(formatProjectStatus(project));
77
+ await replyNotice(ctx, formatProjectStatus(project));
75
78
  return;
76
79
  }
77
- const session = getScopedSession(ctx, store, projects, config);
80
+ const session = await requireScopedSession(ctx, store, projects, config);
78
81
  if (!session)
79
82
  return;
80
83
  const latestSession = await refreshSessionIfActiveTurnIsStale(session, store, codex, bot, logger);
81
84
  const queueDepth = store.getQueuedInputCount(latestSession.sessionKey);
82
85
  const queuedPreview = store.listQueuedInputs(latestSession.sessionKey, 3);
83
86
  const activeRun = codex.getActiveRun(latestSession.sessionKey);
84
- await ctx.reply([
85
- "Status",
86
- `project: ${project.name}`,
87
- `root: ${project.cwd}`,
88
- `thread: ${latestSession.codexThreadId ?? "not created"}`,
89
- `state: ${formatSessionRuntimeStatus(latestSession.runtimeStatus)}`,
90
- `state detail: ${latestSession.runtimeStatusDetail ?? "none"}`,
91
- `state updated: ${formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)}`,
92
- `active turn: ${latestSession.activeTurnId ?? "none"}`,
93
- `active run: ${activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"}`,
94
- `active run thread: ${activeRun?.threadId ?? "none"}`,
95
- `active run last event: ${activeRun?.lastEventType ?? "none"}`,
96
- `active run last update: ${activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"}`,
97
- `queue: ${queueDepth}`,
98
- `queue next: ${formatQueuedPreview(queuedPreview)}`,
99
- `cwd: ${latestSession.cwd}`,
100
- `preset: ${presetFromProfile(latestSession)}`,
101
- `sandbox: ${latestSession.sandboxMode}`,
102
- `approval: ${latestSession.approvalPolicy}`,
103
- `network: ${latestSession.networkAccessEnabled ? "on" : "off"}`,
104
- `web: ${latestSession.webSearchMode ?? "codex-default"}`,
105
- `git check: ${latestSession.skipGitRepoCheck ? "skip" : "enforce"}`,
106
- `add dirs: ${latestSession.additionalDirectories.length}`,
107
- `schema: ${latestSession.outputSchema ? "set" : "none"}`,
108
- `model: ${latestSession.model}`,
109
- `effort: ${formatReasoningEffort(latestSession.reasoningEffort)}`,
110
- ].join("\n"));
87
+ await replyDocument(ctx, {
88
+ title: "Status",
89
+ fields: [
90
+ codeField("project", project.name),
91
+ codeField("root", project.cwd),
92
+ codeField("thread", latestSession.codexThreadId ?? "not created"),
93
+ textField("state", formatSessionRuntimeStatus(latestSession.runtimeStatus)),
94
+ textField("state detail", latestSession.runtimeStatusDetail ?? "none"),
95
+ textField("state updated", formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)),
96
+ codeField("active turn", latestSession.activeTurnId ?? "none"),
97
+ textField("active run", activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"),
98
+ codeField("active run thread", activeRun?.threadId ?? "none"),
99
+ textField("active run last event", activeRun?.lastEventType ?? "none"),
100
+ textField("active run last update", activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"),
101
+ textField("queue", queueDepth),
102
+ textField("queue next", formatQueuedPreview(queuedPreview)),
103
+ codeField("cwd", latestSession.cwd),
104
+ textField("preset", presetFromProfile(latestSession)),
105
+ textField("sandbox", latestSession.sandboxMode),
106
+ textField("approval", latestSession.approvalPolicy),
107
+ textField("network", latestSession.networkAccessEnabled ? "on" : "off"),
108
+ textField("web", latestSession.webSearchMode ?? "codex-default"),
109
+ textField("git check", latestSession.skipGitRepoCheck ? "skip" : "enforce"),
110
+ textField("add dirs", latestSession.additionalDirectories.length),
111
+ textField("schema", latestSession.outputSchema ? "set" : "none"),
112
+ textField("model", latestSession.model),
113
+ textField("effort", formatReasoningEffort(latestSession.reasoningEffort)),
114
+ ],
115
+ });
111
116
  });
112
117
  bot.command("queue", async (ctx) => {
113
- const session = getScopedSession(ctx, store, projects, config);
118
+ const session = await requireScopedSession(ctx, store, projects, config);
114
119
  if (!session)
115
120
  return;
116
121
  const { command, args } = parseSubcommand(ctx.match.trim());
117
122
  if (!command) {
118
123
  const queued = store.listQueuedInputs(session.sessionKey, 5);
119
124
  const queueDepth = store.getQueuedInputCount(session.sessionKey);
120
- await ctx.reply([
121
- "Queue",
122
- `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
123
- `active turn: ${session.activeTurnId ?? "none"}`,
124
- `queue: ${queueDepth}`,
125
- queued.length > 0 ? `items:\n${formatQueuedItems(queued)}` : "items: none",
126
- "Usage: /queue | /queue drop <id> | /queue clear",
127
- ].join("\n"));
125
+ await replyDocument(ctx, {
126
+ title: "Queue",
127
+ fields: [
128
+ textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
129
+ codeField("active turn", session.activeTurnId ?? "none"),
130
+ textField("queue", queueDepth),
131
+ ],
132
+ sections: [
133
+ {
134
+ title: "Items",
135
+ lines: queued.length > 0 ? [formatQueuedItems(queued)] : ["none"],
136
+ },
137
+ ],
138
+ footer: "Usage: /queue | /queue drop <id> | /queue clear",
139
+ });
128
140
  return;
129
141
  }
130
142
  if (command === "clear") {
131
143
  const removed = store.clearQueuedInputs(session.sessionKey);
132
- await ctx.reply(`Cleared the queue and removed ${removed} pending message(s).`);
144
+ await replyNotice(ctx, `Cleared the queue and removed ${removed} pending message(s).`);
133
145
  return;
134
146
  }
135
147
  if (command === "drop") {
136
148
  const id = Number(args);
137
149
  if (!Number.isInteger(id) || id <= 0) {
138
- await ctx.reply("Usage: /queue drop <id>");
150
+ await replyUsage(ctx, "/queue drop <id>");
139
151
  return;
140
152
  }
141
153
  const removed = store.removeQueuedInputForSession(session.sessionKey, id);
142
- await ctx.reply(removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
154
+ await replyNotice(ctx, removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
143
155
  return;
144
156
  }
145
- await ctx.reply("Usage: /queue | /queue drop <id> | /queue clear");
157
+ await replyUsage(ctx, "/queue | /queue drop <id> | /queue clear");
146
158
  });
147
159
  bot.command("stop", async (ctx) => {
148
- const session = getScopedSession(ctx, store, projects, config);
160
+ const session = await requireScopedSession(ctx, store, projects, config);
149
161
  if (!session)
150
162
  return;
151
163
  const latest = store.get(session.sessionKey) ?? session;
152
164
  if (!codex.isRunning(session.sessionKey)) {
153
- await ctx.reply("There is no active Codex SDK turn right now.");
165
+ await replyNotice(ctx, "There is no active Codex SDK turn right now.");
154
166
  return;
155
167
  }
156
168
  try {
157
169
  codex.interrupt(session.sessionKey);
158
- await ctx.reply("Interrupt requested for the current run. Waiting for Codex SDK to stop.");
170
+ await replyNotice(ctx, "Interrupt requested for the current run. Waiting for Codex SDK to stop.");
159
171
  }
160
172
  catch (error) {
161
173
  logger?.warn("interrupt turn failed", {
@@ -163,7 +175,7 @@ export function registerOperationalHandlers(deps) {
163
175
  ...sessionLogFields(latest),
164
176
  error,
165
177
  });
166
- await ctx.reply(`Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
178
+ await replyError(ctx, `Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
167
179
  }
168
180
  });
169
181
  }