= async
message: `File ${append ? "appended" : "written"} successfully`,
},
};
- } catch (error) {
- if (error instanceof WorkspaceSecurityError) {
- return {
- success: false,
- error: error.message,
- };
- }
- return {
- success: false,
- error: getErrorMessage(error),
- };
- }
-};
+ });
diff --git a/src/agent/tools/wrap.ts b/src/agent/tools/wrap.ts
new file mode 100644
index 00000000..78b3bc69
--- /dev/null
+++ b/src/agent/tools/wrap.ts
@@ -0,0 +1,17 @@
+import type { ToolExecutor } from "./types.js";
+import { getErrorMessage } from "../../utils/errors.js";
+
+/**
+ * Wrap a tool executor so any thrown error becomes the standard failed ToolResult
+ * rather than escaping the ToolResult contract. Single source for the generic
+ * try/catch β { success: false, error } mapping repeated across executors.
+ */
+export function withToolErrors(fn: ToolExecutor
): ToolExecutor
{
+ return async (params, context) => {
+ try {
+ return await fn(params, context);
+ } catch (error) {
+ return { success: false, error: getErrorMessage(error) };
+ }
+ };
+}
diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts
index 77fc2406..225e21f3 100644
--- a/src/api/middleware/auth.ts
+++ b/src/api/middleware/auth.ts
@@ -1,7 +1,8 @@
-import { createHash, timingSafeEqual } from "node:crypto";
+import { timingSafeEqual } from "node:crypto";
import type { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { createProblemResponse } from "../schemas/common.js";
+import { hashApiKey } from "../../utils/crypto-tokens.js";
interface FailedAttempt {
count: number;
@@ -17,10 +18,6 @@ function normalizeIp(ip: string): string {
return ip;
}
-function hashApiKey(key: string): string {
- return createHash("sha256").update(key).digest("hex");
-}
-
export function createAuthMiddleware(config: {
keyHash: string;
allowedIps: string[];
diff --git a/src/api/routes/agent.ts b/src/api/routes/agent.ts
index 4074ceb4..383da2c2 100644
--- a/src/api/routes/agent.ts
+++ b/src/api/routes/agent.ts
@@ -1,21 +1,97 @@
import { Hono } from "hono";
+import type { Context } from "hono";
import type { AgentLifecycle } from "../../agent/lifecycle.js";
import { createProblemResponse } from "../schemas/common.js";
import { createLogger } from "../../utils/logger.js";
-const log = createLogger("ManagementAPI");
+const log = createLogger("AgentRoutes");
-export function createAgentRoutes(lifecycle: AgentLifecycle | null | undefined) {
+/**
+ * Per-server error mapper. The API emits RFC 9457 problem+json while the WebUI
+ * emits `{ error }`, so each server injects its own envelope formatter; the
+ * shared lifecycle logic (guard rules, fire-and-forget transitions) lives here.
+ */
+export type AgentRouteErrorMapper = (
+ c: Context,
+ status: number,
+ title: string,
+ detail: string
+) => Response;
+
+/** Default mapper used by the Management API (RFC 9457 problem+json). */
+export const problemErrorMapper: AgentRouteErrorMapper = (c, status, title, detail) =>
+ createProblemResponse(c, status, title, detail);
+
+/**
+ * Agent lifecycle routes: start, stop, status and restart.
+ *
+ * Shared by the WebUI (`/api/agent/*`) and Management API (`/v1/agent/*`)
+ * servers. The unavailable (503) and transient-conflict (409) responses use the
+ * injected `errorResponse` mapper so each server keeps its own error envelope;
+ * the `{ state }` conflicts (running/stopped) and success payloads are identical
+ * across servers and emitted directly.
+ */
+export function createAgentRoutes(
+ lifecycle: AgentLifecycle | null | undefined,
+ options: { errorResponse?: AgentRouteErrorMapper } = {}
+) {
+ const errorResponse = options.errorResponse ?? problemErrorMapper;
const app = new Hono();
- app.post("/restart", async (c) => {
- if (!lifecycle) {
- return createProblemResponse(c, 503, "Service Unavailable", "Agent lifecycle not available");
+ const unavailable = (c: Context) =>
+ errorResponse(c, 503, "Service Unavailable", "Agent lifecycle not available");
+
+ app.post("/start", async (c) => {
+ if (!lifecycle) return unavailable(c);
+
+ const state = lifecycle.getState();
+ if (state === "running") {
+ return c.json({ state: "running" }, 409);
+ }
+ if (state === "stopping") {
+ return errorResponse(c, 409, "Conflict", "Agent is currently stopping, please wait");
+ }
+
+ // Fire-and-forget: start is async, we return immediately
+ lifecycle.start().catch((err: Error) => {
+ log.error({ err }, "Agent start failed");
+ });
+ return c.json({ state: "starting" });
+ });
+
+ app.post("/stop", async (c) => {
+ if (!lifecycle) return unavailable(c);
+
+ const state = lifecycle.getState();
+ if (state === "stopped") {
+ return c.json({ state: "stopped" }, 409);
}
+ if (state === "starting") {
+ return errorResponse(c, 409, "Conflict", "Agent is currently starting, please wait");
+ }
+
+ // Fire-and-forget: stop is async, we return immediately
+ lifecycle.stop().catch((err: Error) => {
+ log.error({ err }, "Agent stop failed");
+ });
+ return c.json({ state: "stopping" });
+ });
+
+ app.get("/status", (c) => {
+ if (!lifecycle) return unavailable(c);
+ return c.json({
+ state: lifecycle.getState(),
+ uptime: lifecycle.getUptime(),
+ error: lifecycle.getError() ?? null,
+ });
+ });
+
+ app.post("/restart", async (c) => {
+ if (!lifecycle) return unavailable(c);
const state = lifecycle.getState();
if (state === "starting" || state === "stopping") {
- return createProblemResponse(c, 409, "Conflict", `Agent is currently ${state}, please wait`);
+ return errorResponse(c, 409, "Conflict", `Agent is currently ${state}, please wait`);
}
// Fire-and-forget restart: stop then start
@@ -25,7 +101,7 @@ export function createAgentRoutes(lifecycle: AgentLifecycle | null | undefined)
await lifecycle.stop();
}
await lifecycle.start();
- log.info("Agent restarted via Management API");
+ log.info("Agent restarted");
} catch (error) {
log.error({ err: error }, "Agent restart failed");
}
diff --git a/src/api/server.ts b/src/api/server.ts
index f7b22265..332048e1 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -1,23 +1,23 @@
import { Hono } from "hono";
-import { bodyLimit } from "hono/body-limit";
import { timeout } from "hono/timeout";
-import { streamSSE } from "hono/streaming";
-import { serve, type ServerType } from "@hono/node-server";
+import type { serve, ServerType } from "@hono/node-server";
import type { HttpBindings } from "@hono/node-server";
import { createServer as createHttpsServer } from "node:https";
-import { randomBytes, createHash } from "node:crypto";
-import type { Server as HttpServer } from "node:http";
+import { randomBytes } from "node:crypto";
import { HTTPException } from "hono/http-exception";
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { createLogger } from "../utils/logger.js";
+import { hashApiKey } from "../utils/crypto-tokens.js";
import { TELETON_ROOT } from "../workspace/paths.js";
import { ensureTlsCert, type TlsCert } from "./tls.js";
import type { ApiServerDeps } from "./deps.js";
import { createDepsAdapter } from "./deps.js";
import type { ApiConfig } from "../config/schema.js";
-import type { StateChangeEvent } from "../agent/lifecycle.js";
+import { createLifecycleSSE } from "../webui/lifecycle-sse.js";
+import { applySecurityMiddleware, sharedBodyLimit } from "../webui/http-common.js";
+import { startHonoServer, stopHonoServer } from "../utils/http-server.js";
import { createProblem } from "./schemas/common.js";
// Middleware
@@ -27,19 +27,7 @@ import { globalRateLimit, mutatingRateLimit, readRateLimit } from "./middleware/
import { auditMiddleware } from "./middleware/audit.js";
// Existing WebUI route factories
-import { createStatusRoutes } from "../webui/routes/status.js";
-import { createToolsRoutes } from "../webui/routes/tools.js";
-import { createLogsRoutes } from "../webui/routes/logs.js";
-import { createMemoryRoutes } from "../webui/routes/memory.js";
-import { createSoulRoutes } from "../webui/routes/soul.js";
-import { createPluginsRoutes } from "../webui/routes/plugins.js";
-import { createMcpRoutes } from "../webui/routes/mcp.js";
-import { createWorkspaceRoutes } from "../webui/routes/workspace.js";
-import { createTasksRoutes } from "../webui/routes/tasks.js";
-import { createConfigRoutes } from "../webui/routes/config.js";
-import { createMarketplaceRoutes } from "../webui/routes/marketplace.js";
-import { createHooksRoutes } from "../webui/routes/hooks.js";
-import { createTonProxyRoutes } from "../webui/routes/ton-proxy.js";
+import { SHARED_ROUTE_FACTORIES } from "../webui/routes/shared.js";
import { createSetupRoutes } from "../webui/routes/setup.js";
// New API routes
@@ -59,11 +47,6 @@ function generateApiKey(): string {
return KEY_PREFIX + randomBytes(32).toString("base64url");
}
-/** Hash an API key with SHA-256 */
-function hashApiKey(key: string): string {
- return createHash("sha256").update(key).digest("hex");
-}
-
/** Check setup completeness by probing key files */
function getSetupStatus(): Record {
return {
@@ -143,18 +126,11 @@ export class ApiServer {
// 2. Body limit (2MB)
this.app.use(
"*",
- bodyLimit({
- maxSize: 2 * 1024 * 1024,
- onError: (c) => {
- return c.json(
- createProblem(413, "Payload Too Large", "Request body exceeds 2MB limit"),
- 413,
- {
- "Content-Type": "application/problem+json",
- }
- );
- },
- })
+ sharedBodyLimit((c) =>
+ c.json(createProblem(413, "Payload Too Large", "Request body exceeds 2MB limit"), 413, {
+ "Content-Type": "application/problem+json",
+ })
+ )
);
// 3. Timeout (30s) β exclude SSE endpoints
@@ -165,13 +141,8 @@ export class ApiServer {
return timeout(30_000)(c, next);
});
- // 4. Security headers
- this.app.use("*", async (c, next) => {
- await next();
- c.res.headers.set("X-Content-Type-Options", "nosniff");
- c.res.headers.set("X-Frame-Options", "DENY");
- c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
- });
+ // 4. Security headers (HSTS β HTTPS-only API)
+ applySecurityMiddleware(this.app, { hsts: true });
}
private setupRoutes(): void {
@@ -225,102 +196,14 @@ export class ApiServer {
const adaptedDeps = createDepsAdapter(this.deps);
// Mount existing WebUI route factories under /v1/
- this.app.route("/v1/status", createStatusRoutes(adaptedDeps));
- this.app.route("/v1/tools", createToolsRoutes(adaptedDeps));
- this.app.route("/v1/logs", createLogsRoutes(adaptedDeps));
- this.app.route("/v1/memory", createMemoryRoutes(adaptedDeps));
- this.app.route("/v1/soul", createSoulRoutes(adaptedDeps));
- this.app.route("/v1/plugins", createPluginsRoutes(adaptedDeps));
- this.app.route("/v1/mcp", createMcpRoutes(adaptedDeps));
- this.app.route("/v1/workspace", createWorkspaceRoutes(adaptedDeps));
- this.app.route("/v1/tasks", createTasksRoutes(adaptedDeps));
- this.app.route("/v1/config", createConfigRoutes(adaptedDeps));
- this.app.route("/v1/marketplace", createMarketplaceRoutes(adaptedDeps));
- this.app.route("/v1/hooks", createHooksRoutes(adaptedDeps));
- this.app.route("/v1/ton-proxy", createTonProxyRoutes(adaptedDeps));
+ for (const [seg, make] of SHARED_ROUTE_FACTORIES) {
+ this.app.route(`/v1/${seg}`, make(adaptedDeps));
+ }
// Setup routes (no agent deps needed, keyHash for config persistence)
this.app.route("/v1/setup", createSetupRoutes({ keyHash: this.keyHash }));
- // Agent lifecycle routes (inline, same pattern as WebUI)
- this.app.post("/v1/agent/start", async (c) => {
- const lifecycle = this.deps.lifecycle;
- if (!lifecycle) {
- return c.json(
- createProblem(503, "Service Unavailable", "Agent lifecycle not available"),
- 503,
- {
- "Content-Type": "application/problem+json",
- }
- );
- }
- const state = lifecycle.getState();
- if (state === "running") {
- return c.json({ state: "running" }, 409);
- }
- if (state === "stopping") {
- return c.json(
- createProblem(409, "Conflict", "Agent is currently stopping, please wait"),
- 409,
- {
- "Content-Type": "application/problem+json",
- }
- );
- }
- lifecycle.start().catch((err: Error) => {
- log.error({ err }, "Agent start failed");
- });
- return c.json({ state: "starting" });
- });
-
- this.app.post("/v1/agent/stop", async (c) => {
- const lifecycle = this.deps.lifecycle;
- if (!lifecycle) {
- return c.json(
- createProblem(503, "Service Unavailable", "Agent lifecycle not available"),
- 503,
- {
- "Content-Type": "application/problem+json",
- }
- );
- }
- const state = lifecycle.getState();
- if (state === "stopped") {
- return c.json({ state: "stopped" }, 409);
- }
- if (state === "starting") {
- return c.json(
- createProblem(409, "Conflict", "Agent is currently starting, please wait"),
- 409,
- {
- "Content-Type": "application/problem+json",
- }
- );
- }
- lifecycle.stop().catch((err: Error) => {
- log.error({ err }, "Agent stop failed");
- });
- return c.json({ state: "stopping" });
- });
-
- this.app.get("/v1/agent/status", (c) => {
- const lifecycle = this.deps.lifecycle;
- if (!lifecycle) {
- return c.json(
- createProblem(503, "Service Unavailable", "Agent lifecycle not available"),
- 503,
- {
- "Content-Type": "application/problem+json",
- }
- );
- }
- return c.json({
- state: lifecycle.getState(),
- uptime: lifecycle.getUptime(),
- error: lifecycle.getError() ?? null,
- });
- });
-
+ // Agent lifecycle SSE stream (start/stop/status/restart live in createAgentRoutes)
this.app.get("/v1/agent/events", (c) => {
const lifecycle = this.deps.lifecycle;
if (!lifecycle) {
@@ -333,51 +216,7 @@ export class ApiServer {
);
}
- return streamSSE(c, async (stream) => {
- let aborted = false;
-
- stream.onAbort(() => {
- aborted = true;
- });
-
- const now = Date.now();
- await stream.writeSSE({
- event: "status",
- id: String(now),
- data: JSON.stringify({
- state: lifecycle.getState(),
- error: lifecycle.getError() ?? null,
- timestamp: now,
- }),
- retry: 3000,
- });
-
- const onStateChange = (event: StateChangeEvent) => {
- if (aborted) return;
- void stream.writeSSE({
- event: "status",
- id: String(event.timestamp),
- data: JSON.stringify({
- state: event.state,
- error: event.error ?? null,
- timestamp: event.timestamp,
- }),
- });
- };
-
- lifecycle.on("stateChange", onStateChange);
-
- while (!aborted) {
- await stream.sleep(30_000);
- if (aborted) break;
- await stream.writeSSE({
- event: "ping",
- data: "",
- });
- }
-
- lifecycle.off("stateChange", onStateChange);
- });
+ return createLifecycleSSE(c, lifecycle);
});
// New API-only routes under /v1/
@@ -419,50 +258,33 @@ export class ApiServer {
this.setupMiddleware();
this.setupRoutes();
- return new Promise((resolve, reject) => {
- try {
- this.server = serve(
- {
- fetch: this.app.fetch as Parameters[0]["fetch"],
- port: this.config.port,
- createServer: createHttpsServer,
- serverOptions: {
- cert: tls.cert,
- key: tls.key,
- },
- },
- (info) => {
- (this.server as HttpServer).maxConnections = 20;
- log.info(`Management API server running on https://localhost:${info.port}`);
- if (this.apiKey) {
- log.info(
- `API key: ${KEY_PREFIX}${this.apiKey.slice(KEY_PREFIX.length, KEY_PREFIX.length + 4)}...`
- );
- }
- log.info(`TLS fingerprint: ${tls.fingerprint.slice(0, 16)}...`);
- resolve();
+ try {
+ this.server = await startHonoServer({
+ fetch: this.app.fetch as Parameters[0]["fetch"],
+ port: this.config.port,
+ createServer: createHttpsServer,
+ serverOptions: { cert: tls.cert, key: tls.key },
+ maxConnections: 20,
+ onListen: (info) => {
+ log.info(`Management API server running on https://localhost:${info.port}`);
+ if (this.apiKey) {
+ log.info(
+ `API key: ${KEY_PREFIX}${this.apiKey.slice(KEY_PREFIX.length, KEY_PREFIX.length + 4)}...`
+ );
}
- );
-
- (this.server as HttpServer).on("error", (err: Error) => {
- log.error({ err }, "Management API server error");
- reject(err);
- });
- } catch (error) {
- reject(error);
- }
- });
+ log.info(`TLS fingerprint: ${tls.fingerprint.slice(0, 16)}...`);
+ },
+ });
+ } catch (err) {
+ log.error({ err }, "Management API server error");
+ throw err;
+ }
}
async stop(): Promise {
if (this.server) {
- return new Promise((resolve) => {
- (this.server as HttpServer).closeAllConnections();
- (this.server as HttpServer).close(() => {
- log.info("Management API server stopped");
- resolve();
- });
- });
+ await stopHonoServer(this.server);
+ log.info("Management API server stopped");
}
}
diff --git a/src/bot/index.ts b/src/bot/index.ts
index 98111f3b..e23bcbe6 100644
--- a/src/bot/index.ts
+++ b/src/bot/index.ts
@@ -4,8 +4,8 @@
*/
import { Bot, type MiddlewareFn, type Context } from "grammy";
-import { Api } from "telegram";
import type Database from "better-sqlite3";
+import { getGramJSErrorMessage, getGrammyErrorDescription } from "../utils/errors.js";
import type { BotConfig, DealContext } from "./types.js";
import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../constants/limits.js";
import { decodeCallback } from "./types.js";
@@ -28,11 +28,15 @@ import {
} from "./services/message-builder.js";
import {
toGrammyKeyboard,
- toTLMarkup,
hasStyledButtons,
+ stripCustomEmoji,
type StyledButtonDef,
-} from "./services/styled-keyboard.js";
-import { parseHtml, stripCustomEmoji } from "./services/html-parser.js";
+} from "../sdk/formatting.js";
+import {
+ answerInlineQueryStyled,
+ editInlineViaGramJS,
+ editInlineViaGramJSStrict,
+} from "./services/inline-transport.js";
import { GramJSBotClient } from "./gramjs-bot.js";
import { getWalletAddress } from "../ton/wallet-service.js";
import { createLogger } from "../utils/logger.js";
@@ -122,7 +126,15 @@ export class DealBot {
if (this.gramjsBot?.isConnected() && hasStyledButtons(buttons)) {
try {
- await this.answerInlineQueryStyled(queryId, dealId, deal, text, buttons);
+ await answerInlineQueryStyled({
+ gramjsBot: this.gramjsBot,
+ queryId,
+ resultId: dealId,
+ title: `π Deal #${dealId}`,
+ description: this.formatShortDescription(deal),
+ html: text,
+ buttons,
+ });
return;
} catch (error) {
log.warn({ err: error }, "[Bot] GramJS styled answer failed, falling back to Grammy");
@@ -169,10 +181,15 @@ export class DealBot {
let edited = false;
if (this.gramjsBot?.isConnected()) {
try {
- await this.editViaGramJS(inlineMessageId, text, buttons);
+ await editInlineViaGramJSStrict({
+ gramjsBot: this.gramjsBot,
+ inlineMessageId,
+ html: text,
+ buttons,
+ });
edited = true;
} catch (error: unknown) {
- const errMsg = (error as Record)?.errorMessage;
+ const errMsg = getGramJSErrorMessage(error);
log.warn(
{ err: error },
`[Bot] chosen_inline_result GramJS edit failed: ${errMsg || error}`
@@ -189,7 +206,7 @@ export class DealBot {
reply_markup: keyboard,
});
} catch (error: unknown) {
- const errDesc = (error as Record)?.description;
+ const errDesc = getGrammyErrorDescription(error);
log.error(
{ err: error },
`[Bot] chosen_inline_result Grammy fallback failed: ${errDesc || error}`
@@ -267,43 +284,6 @@ export class DealBot {
});
}
- /**
- * Answer inline query via GramJS with styled buttons.
- * Custom emojis stripped (SetInlineBotResults doesn't support them).
- */
- private async answerInlineQueryStyled(
- queryId: string,
- dealId: string,
- deal: DealContext,
- htmlText: string,
- buttons: StyledButtonDef[][]
- ): Promise {
- if (!this.gramjsBot) throw new Error("GramJS bot not available");
-
- const strippedHtml = stripCustomEmoji(htmlText);
- const { text: plainText, entities } = parseHtml(strippedHtml);
- const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined;
-
- await this.gramjsBot.answerInlineQuery({
- queryId,
- results: [
- new Api.InputBotInlineResult({
- id: dealId,
- type: "article",
- title: `π Deal #${dealId}`,
- description: this.formatShortDescription(deal),
- sendMessage: new Api.InputBotInlineMessageText({
- message: plainText,
- entities: entities.length > 0 ? entities : undefined,
- noWebpage: true,
- replyMarkup: markup,
- }),
- }),
- ],
- cacheTime: 0,
- });
- }
-
private async handleAccept(ctx: Context, deal: DealContext): Promise {
if (deal.status !== "proposed") {
await ctx.answerCallbackQuery({ text: "Already processed" });
@@ -398,14 +378,17 @@ export class DealBot {
if (this.gramjsBot?.isConnected()) {
try {
- await this.editViaGramJS(inlineMsgId, text, buttons);
+ await editInlineViaGramJS({
+ gramjsBot: this.gramjsBot,
+ inlineMessageId: inlineMsgId,
+ html: text,
+ buttons,
+ });
return;
} catch (error: unknown) {
- const errMsg = (error as Record)?.errorMessage;
- if (errMsg === "MESSAGE_NOT_MODIFIED") return;
log.warn(
{ err: error },
- `[Bot] GramJS edit failed, falling back to Grammy: ${errMsg || error}`
+ `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}`
);
}
}
@@ -418,7 +401,7 @@ export class DealBot {
reply_markup: keyboard,
});
} catch (error: unknown) {
- const desc = (error as Record)?.description;
+ const desc = getGrammyErrorDescription(error);
if (desc?.includes("message is not modified")) return;
log.error({ err: error }, "[Bot] Failed to edit inline message");
}
@@ -431,14 +414,17 @@ export class DealBot {
): Promise {
if (this.gramjsBot?.isConnected() && buttons) {
try {
- await this.editViaGramJS(inlineMessageId, text, buttons);
+ await editInlineViaGramJS({
+ gramjsBot: this.gramjsBot,
+ inlineMessageId,
+ html: text,
+ buttons,
+ });
return;
} catch (error: unknown) {
- const errMsg = (error as Record)?.errorMessage;
- if (errMsg === "MESSAGE_NOT_MODIFIED") return;
log.warn(
{ err: error },
- `[Bot] GramJS edit failed, falling back to Grammy: ${errMsg || error}`
+ `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}`
);
}
}
@@ -455,24 +441,6 @@ export class DealBot {
}
}
- private async editViaGramJS(
- inlineMessageId: string,
- htmlText: string,
- buttons: StyledButtonDef[][]
- ): Promise {
- if (!this.gramjsBot) throw new Error("GramJS bot not available");
-
- const { text: plainText, entities } = parseHtml(htmlText);
- const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined;
-
- await this.gramjsBot.editInlineMessageByStringId({
- inlineMessageId,
- text: plainText,
- entities: entities.length > 0 ? entities : undefined,
- replyMarkup: markup,
- });
- }
-
private formatShortDescription(deal: DealContext): string {
const userGives =
deal.userGivesType === "ton"
diff --git a/src/bot/inline-router.ts b/src/bot/inline-router.ts
index 689a5525..665a8244 100644
--- a/src/bot/inline-router.ts
+++ b/src/bot/inline-router.ts
@@ -8,6 +8,7 @@
import type { Context, MiddlewareFn } from "grammy";
import type { InlineQueryResult } from "@grammyjs/types";
+import { getGramJSErrorMessage } from "../utils/errors.js";
import type {
InlineQueryContext,
InlineResult,
@@ -16,9 +17,13 @@ import type {
ButtonDef,
} from "@teleton-agent/sdk";
import type { GramJSBotClient } from "./gramjs-bot.js";
+import { splitPrefix } from "./types.js";
import { createLogger } from "../utils/logger.js";
-import { toGrammyKeyboard, toTLMarkup, prefixButtons } from "./services/styled-keyboard.js";
-import { stripCustomEmoji, parseHtml } from "./services/html-parser.js";
+import { toGrammyKeyboard, prefixButtons, stripCustomEmoji } from "../sdk/formatting.js";
+import { editInlineViaGramJS } from "./services/inline-transport.js";
+
+// Re-exported for callers that import the router's glob compiler (now shared).
+export { compileGlob } from "../sdk/formatting.js";
const log = createLogger("InlineRouter");
@@ -37,15 +42,6 @@ export interface PluginBotHandlers {
onChosenResult?: (ctx: ChosenResultContext) => Promise;
}
-/**
- * Compile a glob-like pattern to a RegExp.
- * Supports `*` as wildcard matching any sequence of characters.
- */
-export function compileGlob(pattern: string): RegExp {
- const regexStr = "^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "(.*)") + "$";
- return new RegExp(regexStr);
-}
-
/**
* Match a pre-compiled glob regex against a string.
* Returns match groups (the parts matched by `*`) or null.
@@ -56,8 +52,6 @@ function globMatch(regex: RegExp, input: string): string[] | null {
return match.slice(1);
}
-// prefixButtons imported from shared styled-keyboard.ts
-
export class InlineRouter {
private plugins = new Map();
private gramjsBot: GramJSBotClient | null = null;
@@ -81,17 +75,24 @@ export class InlineRouter {
return this.plugins.has(name);
}
+ // ββ Prefix routing contract ββββββββββββββββββββββββββββββββββββββββββββββ
+ // This middleware is installed BEFORE DealBot in the Grammy chain. It peels a
+ // `prefix:rest` segment off inline queries / callback data / chosen-result ids
+ // (via splitPrefix) and dispatches to the matching registered plugin.
+ //
+ // A prefix is RESERVED for DealBot β accept Β· decline Β· sent Β· copy_addr Β·
+ // copy_memo Β· refresh (see decodeCallback in types.ts) β and plugins must not
+ // register those names. Any prefix without a registered plugin handler (or no
+ // colon at all) falls through via next() to DealBot's own handlers.
middleware(): MiddlewareFn {
return async (ctx, next) => {
// ββ Inline Query βββββββββββββββββββββββββββββββββ
if (ctx.inlineQuery) {
- const rawQuery = ctx.inlineQuery.query.trim();
- const colonIdx = rawQuery.indexOf(":");
- if (colonIdx > 0) {
- const prefix = rawQuery.slice(0, colonIdx);
- const plugin = this.plugins.get(prefix);
+ const split = splitPrefix(ctx.inlineQuery.query.trim());
+ if (split) {
+ const plugin = this.plugins.get(split.prefix);
if (plugin?.onInlineQuery) {
- await this.handleInlineQuery(ctx, prefix, rawQuery.slice(colonIdx + 1), plugin);
+ await this.handleInlineQuery(ctx, split.prefix, split.rest, plugin);
return; // handled, don't fall through
}
}
@@ -101,14 +102,11 @@ export class InlineRouter {
// ββ Callback Query βββββββββββββββββββββββββββββββ
if (ctx.callbackQuery?.data) {
- const data = ctx.callbackQuery.data;
- const colonIdx = data.indexOf(":");
- if (colonIdx > 0) {
- const prefix = data.slice(0, colonIdx);
- const plugin = this.plugins.get(prefix);
+ const split = splitPrefix(ctx.callbackQuery.data);
+ if (split) {
+ const plugin = this.plugins.get(split.prefix);
if (plugin?.onCallback) {
- const strippedData = data.slice(colonIdx + 1);
- await this.handleCallback(ctx, prefix, strippedData, plugin);
+ await this.handleCallback(ctx, split.prefix, split.rest, plugin);
return;
}
}
@@ -117,13 +115,11 @@ export class InlineRouter {
// ββ Chosen Inline Result βββββββββββββββββββββββββ
if (ctx.chosenInlineResult) {
- const resultId = ctx.chosenInlineResult.result_id;
- const colonIdx = resultId.indexOf(":");
- if (colonIdx > 0) {
- const prefix = resultId.slice(0, colonIdx);
- const plugin = this.plugins.get(prefix);
+ const split = splitPrefix(ctx.chosenInlineResult.result_id);
+ if (split) {
+ const plugin = this.plugins.get(split.prefix);
if (plugin?.onChosenResult) {
- await this.handleChosenResult(ctx, prefix, plugin);
+ await this.handleChosenResult(ctx, split.prefix, plugin);
return;
}
}
@@ -233,21 +229,17 @@ export class InlineRouter {
const inlineMsgId = ctx.callbackQuery?.inline_message_id;
if (inlineMsgId && gramjsBotRef?.isConnected() && styledButtons) {
try {
- const strippedHtml = stripCustomEmoji(text);
- const { text: plainText, entities } = parseHtml(strippedHtml);
- const markup = toTLMarkup(styledButtons);
-
- await gramjsBotRef.editInlineMessageByStringId({
+ await editInlineViaGramJS({
+ gramjsBot: gramjsBotRef,
inlineMessageId: inlineMsgId,
- text: plainText,
- entities: entities.length > 0 ? entities : undefined,
- replyMarkup: markup,
+ html: stripCustomEmoji(text),
+ buttons: styledButtons,
});
return;
} catch (error: unknown) {
- const errMsg = (error as Record)?.errorMessage;
- if (errMsg === "MESSAGE_NOT_MODIFIED") return;
- log.debug(`GramJS edit failed, falling back to Grammy: ${errMsg || error}`);
+ log.debug(
+ `GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}`
+ );
}
}
@@ -293,8 +285,7 @@ export class InlineRouter {
if (!chosenResult || !plugin.onChosenResult) return;
const resultId = chosenResult.result_id;
- const colonIdx = resultId.indexOf(":");
- const strippedResultId = colonIdx > 0 ? resultId.slice(colonIdx + 1) : resultId;
+ const strippedResultId = splitPrefix(resultId)?.rest ?? resultId;
const crCtx: ChosenResultContext = {
resultId: strippedResultId,
diff --git a/src/bot/services/deal-service.ts b/src/bot/services/deal-service.ts
index 246cb445..b77d0c28 100644
--- a/src/bot/services/deal-service.ts
+++ b/src/bot/services/deal-service.ts
@@ -6,6 +6,13 @@ import type Database from "better-sqlite3";
import type { DealContext, DealStatus } from "../types.js";
import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../../constants/limits.js";
+/** Column list selected by every deal query, mapped by rowToDeal. */
+const DEAL_COLUMNS = `id, user_telegram_id, user_username, chat_id,
+ user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton,
+ agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton,
+ profit_ton, status, created_at, expires_at,
+ inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at`;
+
interface DealRow {
id: string;
user_telegram_id: number;
@@ -61,11 +68,7 @@ export function getDeal(db: Database.Database, dealId: string): DealContext | nu
const row = db
.prepare(
`SELECT
- id, user_telegram_id, user_username, chat_id,
- user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton,
- agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton,
- profit_ton, status, created_at, expires_at,
- inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at
+ ${DEAL_COLUMNS}
FROM deals WHERE id = ?`
)
.get(dealId) as DealRow | undefined;
@@ -155,11 +158,7 @@ export function getDealsAwaitingVerification(db: Database.Database): DealContext
const rows = db
.prepare(
`SELECT
- id, user_telegram_id, user_username, chat_id,
- user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton,
- agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton,
- profit_ton, status, created_at, expires_at,
- inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at
+ ${DEAL_COLUMNS}
FROM deals
WHERE status = 'payment_claimed'
ORDER BY payment_claimed_at ASC
@@ -177,11 +176,7 @@ export function getDealsAwaitingExecution(db: Database.Database): DealContext[]
const rows = db
.prepare(
`SELECT
- id, user_telegram_id, user_username, chat_id,
- user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton,
- agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton,
- profit_ton, status, created_at, expires_at,
- inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at
+ ${DEAL_COLUMNS}
FROM deals
WHERE status = 'verified' AND agent_sent_at IS NULL
ORDER BY user_payment_verified_at ASC
diff --git a/src/bot/services/html-parser.ts b/src/bot/services/html-parser.ts
deleted file mode 100644
index 98205600..00000000
--- a/src/bot/services/html-parser.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * Simple HTML β MessageEntity parser for MTProto
- * Converts our limited HTML subset (, , , ) to
- * plain text + Telegram MessageEntity array.
- *
- * Entity offsets use UTF-16 code units (matching Telegram's spec and JS string.length).
- */
-
-import { Api } from "telegram";
-import { toLong } from "../../utils/gramjs-bigint.js";
-
-export interface ParsedMessage {
- text: string;
- entities: Api.TypeMessageEntity[];
-}
-
-/**
- * Parse HTML string to plain text + MessageEntity array
- */
-export function parseHtml(html: string): ParsedMessage {
- const entities: Api.TypeMessageEntity[] = [];
- let text = "";
- let pos = 0;
-
- // Stack for tracking open tags
- const stack: { tag: string; offset: number; url?: string; emojiId?: string }[] = [];
-
- while (pos < html.length) {
- if (html[pos] === "<") {
- const endBracket = html.indexOf(">", pos);
- if (endBracket === -1) {
- // Malformed HTML - treat '<' as literal
- text += "<";
- pos++;
- continue;
- }
-
- const tagStr = html.substring(pos + 1, endBracket);
-
- if (tagStr.startsWith("/")) {
- // Closing tag
- const tagName = tagStr.substring(1).toLowerCase().trim();
- for (let i = stack.length - 1; i >= 0; i--) {
- if (stack[i].tag === tagName) {
- const open = stack[i];
- const length = text.length - open.offset;
-
- if (length > 0) {
- switch (tagName) {
- case "b":
- case "strong":
- entities.push(new Api.MessageEntityBold({ offset: open.offset, length }));
- break;
- case "i":
- case "em":
- entities.push(new Api.MessageEntityItalic({ offset: open.offset, length }));
- break;
- case "code":
- entities.push(new Api.MessageEntityCode({ offset: open.offset, length }));
- break;
- case "a":
- if (open.url) {
- entities.push(
- new Api.MessageEntityTextUrl({
- offset: open.offset,
- length,
- url: open.url,
- })
- );
- }
- break;
- case "tg-emoji":
- if (open.emojiId) {
- entities.push(
- new Api.MessageEntityCustomEmoji({
- offset: open.offset,
- length,
- documentId: toLong(open.emojiId),
- })
- );
- }
- break;
- }
- }
-
- stack.splice(i, 1);
- break;
- }
- }
- } else {
- // Opening tag
- const spaceIdx = tagStr.indexOf(" ");
- const tagName = (spaceIdx >= 0 ? tagStr.substring(0, spaceIdx) : tagStr).toLowerCase();
- const attrs = spaceIdx >= 0 ? tagStr.substring(spaceIdx) : "";
-
- let url: string | undefined;
- let emojiId: string | undefined;
- if (tagName === "a") {
- const hrefMatch = attrs.match(/href="([^"]+)"/);
- if (hrefMatch) {
- const rawUrl = unescapeHtml(hrefMatch[1]);
- if (/^(javascript|data|vbscript|file):/i.test(rawUrl.trim())) {
- url = "#";
- } else {
- url = rawUrl;
- }
- }
- } else if (tagName === "tg-emoji") {
- const eidMatch = attrs.match(/emoji-id="([^"]+)"/);
- if (eidMatch) emojiId = eidMatch[1];
- }
-
- stack.push({ tag: tagName, offset: text.length, url, emojiId });
- }
-
- pos = endBracket + 1;
- } else if (html.substring(pos, pos + 5) === "&") {
- text += "&";
- pos += 5;
- } else if (html.substring(pos, pos + 4) === "<") {
- text += "<";
- pos += 4;
- } else if (html.substring(pos, pos + 4) === ">") {
- text += ">";
- pos += 4;
- } else if (html.substring(pos, pos + 6) === """) {
- text += '"';
- pos += 6;
- } else {
- text += html[pos];
- pos++;
- }
- }
-
- return { text, entities };
-}
-
-/**
- * Strip tags for Grammy/Bot API fallback (keeps unicode emoji inside)
- */
-export function stripCustomEmoji(html: string): string {
- return html.replace(/]*>([^<]*)<\/tg-emoji>/g, "$1");
-}
-
-function unescapeHtml(text: string): string {
- return text
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, '"');
-}
diff --git a/src/bot/services/inline-transport.ts b/src/bot/services/inline-transport.ts
new file mode 100644
index 00000000..84c86413
--- /dev/null
+++ b/src/bot/services/inline-transport.ts
@@ -0,0 +1,126 @@
+/**
+ * Inline-message MTProto transport.
+ *
+ * Encapsulates every GramJS (MTProto) operation the bot performs on inline
+ * messages β answering inline queries with styled buttons and editing inline
+ * messages β so DealBot and the plugin SDK stay free of transport plumbing.
+ *
+ * Depends only on a GramJSBotClient and the pure formatting helpers; it has no
+ * knowledge of deals or business logic. Each higher-level caller keeps its own
+ * Grammy (Bot API) fallback.
+ */
+
+import { Api } from "telegram";
+import type { GramJSBotClient } from "../gramjs-bot.js";
+import {
+ toTLMarkup,
+ hasStyledButtons,
+ parseHtml,
+ stripCustomEmoji,
+ type StyledButtonDef,
+} from "../../sdk/formatting.js";
+import { getGramJSErrorMessage } from "../../utils/errors.js";
+
+/**
+ * Edit an inline message via GramJS MTProto (raw β does NOT swallow errors).
+ *
+ * The GramJS trunk shared by every inline-edit site: parseHtml β toTLMarkup β
+ * editInlineMessageByStringId. Throws on any GramJS error, including
+ * MESSAGE_NOT_MODIFIED, so callers can decide how to handle each case.
+ */
+async function editInlineRaw(params: {
+ gramjsBot: GramJSBotClient;
+ inlineMessageId: string;
+ html: string;
+ buttons?: StyledButtonDef[][];
+}): Promise {
+ const { gramjsBot, inlineMessageId, html, buttons } = params;
+
+ const { text: plainText, entities } = parseHtml(html);
+ const markup = buttons && hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined;
+
+ await gramjsBot.editInlineMessageByStringId({
+ inlineMessageId,
+ text: plainText,
+ entities: entities.length > 0 ? entities : undefined,
+ replyMarkup: markup,
+ });
+}
+
+/**
+ * Edit an inline message via GramJS MTProto (styled buttons, custom emoji).
+ *
+ * @returns `true` if the edit succeeded (or was a no-op because the content was
+ * unchanged β MESSAGE_NOT_MODIFIED is swallowed). Throws on any other GramJS
+ * error so callers can run their own Grammy fallback.
+ */
+export async function editInlineViaGramJS(params: {
+ gramjsBot: GramJSBotClient;
+ inlineMessageId: string;
+ html: string;
+ buttons?: StyledButtonDef[][];
+}): Promise {
+ try {
+ await editInlineRaw(params);
+ return true;
+ } catch (error: unknown) {
+ if (getGramJSErrorMessage(error) === "MESSAGE_NOT_MODIFIED") return true;
+ throw error;
+ }
+}
+
+/**
+ * Edit an inline message via GramJS without swallowing MESSAGE_NOT_MODIFIED.
+ *
+ * Used by chosen_inline_result, where any failure (including a no-op edit on a
+ * freshly created message) must surface to the caller's Grammy fallback path.
+ */
+export async function editInlineViaGramJSStrict(params: {
+ gramjsBot: GramJSBotClient;
+ inlineMessageId: string;
+ html: string;
+ buttons?: StyledButtonDef[][];
+}): Promise {
+ await editInlineRaw(params);
+}
+
+/**
+ * Answer an inline query with a single styled article result via GramJS MTProto.
+ *
+ * Custom emojis are stripped (SetInlineBotResults does not support them). The
+ * caller supplies the already-built title/description and the styled message body.
+ */
+export async function answerInlineQueryStyled(params: {
+ gramjsBot: GramJSBotClient;
+ queryId: string;
+ resultId: string;
+ title: string;
+ description: string;
+ html: string;
+ buttons: StyledButtonDef[][];
+}): Promise {
+ const { gramjsBot, queryId, resultId, title, description, html, buttons } = params;
+
+ const strippedHtml = stripCustomEmoji(html);
+ const { text: plainText, entities } = parseHtml(strippedHtml);
+ const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined;
+
+ await gramjsBot.answerInlineQuery({
+ queryId,
+ results: [
+ new Api.InputBotInlineResult({
+ id: resultId,
+ type: "article",
+ title,
+ description,
+ sendMessage: new Api.InputBotInlineMessageText({
+ message: plainText,
+ entities: entities.length > 0 ? entities : undefined,
+ noWebpage: true,
+ replyMarkup: markup,
+ }),
+ }),
+ ],
+ cacheTime: 0,
+ });
+}
diff --git a/src/bot/services/message-builder.ts b/src/bot/services/message-builder.ts
index 3535e57c..6f38f30f 100644
--- a/src/bot/services/message-builder.ts
+++ b/src/bot/services/message-builder.ts
@@ -8,7 +8,7 @@
*/
import type { DealContext } from "../types.js";
-import type { StyledButtonDef, DealMessage } from "./styled-keyboard.js";
+import type { StyledButtonDef, DealMessage } from "../../sdk/formatting.js";
/**
* Escape HTML special characters
@@ -34,6 +34,16 @@ function formatAsset(type: "ton" | "gift", tonAmount?: number, giftSlug?: string
return "???";
}
+/** What the user gives in a deal, formatted. */
+function formatUserGives(deal: DealContext): string {
+ return formatAsset(deal.userGivesType, deal.userGivesTonAmount, deal.userGivesGiftSlug);
+}
+
+/** What the agent gives in a deal, formatted. */
+function formatAgentGives(deal: DealContext): string {
+ return formatAsset(deal.agentGivesType, deal.agentGivesTonAmount, deal.agentGivesGiftSlug);
+}
+
/**
* Format remaining time
*/
@@ -50,16 +60,8 @@ function getRemainingTime(expiresAt: number): string {
* Proposal state - Accept/Decline
*/
export function buildProposalMessage(deal: DealContext): DealMessage {
- const userGives = formatAsset(
- deal.userGivesType,
- deal.userGivesTonAmount,
- deal.userGivesGiftSlug
- );
- const agentGives = formatAsset(
- deal.agentGivesType,
- deal.agentGivesTonAmount,
- deal.agentGivesGiftSlug
- );
+ const userGives = formatUserGives(deal);
+ const agentGives = formatAgentGives(deal);
const remaining = getRemainingTime(deal.expiresAt);
const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`;
@@ -84,16 +86,8 @@ ${TIMER_EMOJI} Expires in ${remaining}`;
* Accepted state - Payment/gift instructions
*/
export function buildAcceptedMessage(deal: DealContext, agentWallet: string): DealMessage {
- const userGives = formatAsset(
- deal.userGivesType,
- deal.userGivesTonAmount,
- deal.userGivesGiftSlug
- );
- const agentGives = formatAsset(
- deal.agentGivesType,
- deal.agentGivesTonAmount,
- deal.agentGivesGiftSlug
- );
+ const userGives = formatUserGives(deal);
+ const agentGives = formatAgentGives(deal);
let instructions: string;
let buttons: StyledButtonDef[][];
@@ -157,11 +151,7 @@ This usually takes 10-30 seconds.`;
* Verified - Sending agent's part
*/
export function buildSendingMessage(deal: DealContext): DealMessage {
- const agentGives = formatAsset(
- deal.agentGivesType,
- deal.agentGivesTonAmount,
- deal.agentGivesGiftSlug
- );
+ const agentGives = formatAgentGives(deal);
const text = `β
Payment Verified
@@ -175,16 +165,8 @@ Sending ${agentGives}...`;
* Completed - Final recap
*/
export function buildCompletedMessage(deal: DealContext): DealMessage {
- const userGives = formatAsset(
- deal.userGivesType,
- deal.userGivesTonAmount,
- deal.userGivesGiftSlug
- );
- const agentGives = formatAsset(
- deal.agentGivesType,
- deal.agentGivesTonAmount,
- deal.agentGivesGiftSlug
- );
+ const userGives = formatUserGives(deal);
+ const agentGives = formatAgentGives(deal);
const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`;
const duration = deal.completedAt
diff --git a/src/bot/services/styled-keyboard.ts b/src/bot/services/styled-keyboard.ts
deleted file mode 100644
index 3bfce4d6..00000000
--- a/src/bot/services/styled-keyboard.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * Styled keyboard helpers for deal buttons
- * Converts button definitions to GramJS TL objects (with color styles + copy buttons)
- * or Grammy InlineKeyboard (fallback, no colors, popup copy)
- */
-
-import { Api } from "telegram";
-import { InlineKeyboard } from "grammy";
-
-export type ButtonStyle = "success" | "danger" | "primary";
-
-export interface StyledButtonDef {
- text: string;
- callbackData: string;
- style?: ButtonStyle;
- /** If set, renders as KeyboardButtonCopy (click-to-clipboard) via MTProto */
- copyText?: string;
-}
-
-/**
- * Result type for all message builders
- */
-export interface DealMessage {
- text: string;
- buttons: StyledButtonDef[][];
-}
-
-/**
- * Convert styled button definitions to GramJS TL markup (with colors + copy buttons)
- * Uses native Layer 223 constructors (KeyboardButtonStyle, KeyboardButtonCopy)
- */
-export function toTLMarkup(buttons: StyledButtonDef[][]): Api.ReplyInlineMarkup {
- return new Api.ReplyInlineMarkup({
- rows: buttons
- .filter((row) => row.length > 0)
- .map(
- (row) =>
- new Api.KeyboardButtonRow({
- buttons: row.map((btn) => {
- // Copy button: native click-to-clipboard (no callback needed)
- if (btn.copyText) {
- return new Api.KeyboardButtonCopy({
- text: btn.text,
- copyText: btn.copyText,
- });
- }
-
- // Callback button: with optional color style
- const style = btn.style
- ? new Api.KeyboardButtonStyle({
- bgSuccess: btn.style === "success",
- bgDanger: btn.style === "danger",
- bgPrimary: btn.style === "primary",
- })
- : undefined;
- return new Api.KeyboardButtonCallback({
- text: btn.text,
- data: Buffer.from(btn.callbackData),
- style,
- });
- }),
- })
- ),
- });
-}
-
-/**
- * Convert styled button definitions to Grammy InlineKeyboard (fallback, no colors)
- * Copy buttons use Bot API's native copy_text field (click-to-clipboard)
- */
-export function toGrammyKeyboard(buttons: StyledButtonDef[][]): InlineKeyboard {
- const kb = new InlineKeyboard();
- for (let i = 0; i < buttons.length; i++) {
- if (i > 0) kb.row();
- for (const btn of buttons[i]) {
- if (btn.copyText) {
- kb.copyText(btn.text, btn.copyText);
- } else {
- kb.text(btn.text, btn.callbackData);
- }
- }
- }
- return kb;
-}
-
-/**
- * Check if button array has any buttons
- */
-export function hasStyledButtons(buttons: StyledButtonDef[][]): boolean {
- return buttons.some((row) => row.length > 0);
-}
-
-/**
- * Convert plugin ButtonDef[][] to StyledButtonDef[][] with prefixed callbacks.
- * Shared by both sdk/bot.ts and bot/inline-router.ts.
- */
-export function prefixButtons(
- rows: { text: string; callback?: string; url?: string; copy?: string; style?: ButtonStyle }[][],
- pluginName: string
-): StyledButtonDef[][] {
- return rows.map((row) =>
- row.map((btn) => {
- if (btn.copy) {
- return { text: btn.text, callbackData: "", copyText: btn.copy, style: btn.style };
- }
- return {
- text: btn.text,
- callbackData: btn.callback ? `${pluginName}:${btn.callback}` : "",
- style: btn.style,
- };
- })
- );
-}
diff --git a/src/bot/services/verification-poller.ts b/src/bot/services/verification-poller.ts
index 9426ab09..fd97b153 100644
--- a/src/bot/services/verification-poller.ts
+++ b/src/bot/services/verification-poller.ts
@@ -187,11 +187,10 @@ export class VerificationPoller {
): Promise<{ verified: boolean; giftMsgId?: string }> {
try {
// Get agent's own user ID
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto path
- const me = (this.bridge.getRawClient() as any)?.getMe?.();
- if (!me) return { verified: false };
+ const ownUserId = this.bridge.getOwnUserId();
+ if (!ownUserId) return { verified: false };
- const botUserId = Number(me.id);
+ const botUserId = Number(ownUserId);
// Import gift executor
const { telegramGetMyGiftsExecutor } =
diff --git a/src/bot/types.ts b/src/bot/types.ts
index e40aac89..26347b96 100644
--- a/src/bot/types.ts
+++ b/src/bot/types.ts
@@ -60,10 +60,40 @@ export interface CallbackData {
dealId: string;
}
+/**
+ * Split a `prefix:rest` string on its first colon.
+ * Returns null if there is no colon, or the colon is the first character
+ * (i.e. there is no non-empty prefix). The `rest` may itself contain colons.
+ *
+ * Shared routing primitive: the inline-router uses this to peel a plugin prefix
+ * off inline queries / callback data / chosen-result ids before dispatch.
+ */
+export function splitPrefix(raw: string): { prefix: string; rest: string } | null {
+ const colonIdx = raw.indexOf(":");
+ if (colonIdx <= 0) return null;
+ return { prefix: raw.slice(0, colonIdx), rest: raw.slice(colonIdx + 1) };
+}
+
export function encodeCallback(data: CallbackData): string {
return `${data.action}:${data.dealId}`;
}
+/**
+ * Callback-data routing contract (Grammy `callback_query:data` middleware chain).
+ *
+ * Two prefix conventions co-route in the same Grammy dispatch, distinguished by
+ * the first `:`-delimited segment:
+ *
+ * - DealBot RESERVES these six action prefixes, decoded here as `action:dealId`
+ * (exactly one colon): accept Β· decline Β· sent Β· copy_addr Β· copy_memo Β· refresh.
+ * decodeCallback returns null for anything else, so DealBot ignores it.
+ * - Every OTHER prefix belongs to a registered plugin and is claimed by the
+ * InlineRouter middleware (installed BEFORE DealBot) via its `prefix:rest`
+ * split; unmatched prefixes fall through to DealBot.
+ *
+ * New DealBot actions must be added to BOTH the union below and the whitelist in
+ * decodeCallback; plugins must avoid these reserved prefixes to prevent collisions.
+ */
export function decodeCallback(raw: string): CallbackData | null {
const parts = raw.split(":");
if (parts.length !== 2) return null;
diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts
index b4a998d5..76bf39ad 100644
--- a/src/cli/commands/doctor.ts
+++ b/src/cli/commands/doctor.ts
@@ -9,6 +9,7 @@ import {
type SupportedProvider,
} from "../../config/providers.js";
import { getErrorMessage } from "../../utils/errors.js";
+import { GREEN, YELLOW, RED } from "../prompts.js";
interface CheckResult {
name: string;
@@ -16,22 +17,21 @@ interface CheckResult {
message: string;
}
-const green = "\x1b[32m";
-const yellow = "\x1b[33m";
-const red = "\x1b[31m";
-const reset = "\x1b[0m";
+// ASCII banner colors (raw ANSI β chalk has no equivalent blue export)
const blue = "\x1b[34m";
+const reset = "\x1b[0m";
function formatResult(result: CheckResult): string {
const icon =
- result.status === "ok"
- ? `${green}β${reset}`
- : result.status === "warn"
- ? `${yellow}β ${reset}`
- : `${red}β${reset}`;
+ result.status === "ok" ? GREEN("β") : result.status === "warn" ? YELLOW("β ") : RED("β");
return `${icon} ${result.name}: ${result.message}`;
}
+/** Read and YAML-parse the config file (shared by the config-dependent checks). */
+function readAndParseConfig(configPath: string) {
+ return parse(readFileSync(configPath, "utf-8"));
+}
+
async function checkConfig(workspaceDir: string): Promise {
const configPath = join(workspaceDir, "config.yaml");
@@ -44,8 +44,7 @@ async function checkConfig(workspaceDir: string): Promise {
}
try {
- const content = readFileSync(configPath, "utf-8");
- const raw = parse(content);
+ const raw = readAndParseConfig(configPath);
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
@@ -82,8 +81,7 @@ async function checkTelegramCredentials(workspaceDir: string): Promise {
}
try {
- const content = readFileSync(configPath, "utf-8");
- const config = parse(content);
+ const config = readAndParseConfig(configPath);
const provider = (config.agent?.provider || "anthropic") as SupportedProvider;
const apiKey = config.agent?.api_key;
@@ -334,8 +331,7 @@ async function checkModel(workspaceDir: string): Promise {
}
try {
- const content = readFileSync(configPath, "utf-8");
- const config = parse(content);
+ const config = readAndParseConfig(configPath);
const provider = (config.agent?.provider || "anthropic") as SupportedProvider;
let model = config.agent?.model;
@@ -373,8 +369,7 @@ async function checkAdmins(workspaceDir: string): Promise {
}
try {
- const content = readFileSync(configPath, "utf-8");
- const config = parse(content);
+ const config = readAndParseConfig(configPath);
const admins = config.telegram?.admin_ids || [];
@@ -464,14 +459,16 @@ ${blue} βββββββββββββββββββββββ
if (errors > 0) {
console.log(
- `${red} β ${errors} error${errors > 1 ? "s" : ""} found - run 'teleton setup' to fix${reset}`
+ RED(` β ${errors} error${errors > 1 ? "s" : ""} found - run 'teleton setup' to fix`)
);
} else if (warnings > 0) {
console.log(
- `${yellow} β ${warnings} warning${warnings > 1 ? "s" : ""} - agent may work with limited features${reset}`
+ YELLOW(
+ ` β ${warnings} warning${warnings > 1 ? "s" : ""} - agent may work with limited features`
+ )
);
} else {
- console.log(`${green} β All ${ok} checks passed - system ready${reset}`);
+ console.log(GREEN(` β All ${ok} checks passed - system ready`));
}
console.log("");
diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts
index aeef5e7a..a3f8cf74 100644
--- a/src/cli/commands/onboard.ts
+++ b/src/cli/commands/onboard.ts
@@ -29,11 +29,12 @@ import {
type StepDef,
} from "../prompts.js";
-import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js";
+import { ensureWorkspace, isNewWorkspace, type Workspace } from "../../workspace/manager.js";
import { writeFileSync, readFileSync, existsSync } from "fs";
import { join } from "path";
import { TELETON_ROOT } from "../../workspace/paths.js";
import { TelegramUserClient } from "../../telegram/client.js";
+import { maskSecret } from "../../utils/mask.js";
import YAML from "yaml";
import { type Config, DealsConfigSchema } from "../../config/schema.js";
import { getModelsForProvider } from "../../config/model-catalog.js";
@@ -43,6 +44,7 @@ import {
saveWallet,
walletExists,
loadWallet,
+ type WalletData,
} from "../../ton/wallet-service.js";
import {
getSupportedProviders,
@@ -54,10 +56,6 @@ import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js";
import { fetchWithTimeout } from "../../utils/fetch.js";
import { getErrorMessage } from "../../utils/errors.js";
import ora from "ora";
-import {
- getClaudeCodeApiKey,
- isClaudeCodeTokenValid,
-} from "../../providers/claude-code-credentials.js";
import { getCodexApiKey, isCodexTokenValid } from "../../providers/codex-credentials.js";
export interface OnboardOptions {
@@ -102,57 +100,291 @@ function sleep(ms: number): Promise {
return new Promise((r) => setTimeout(r, ms));
}
-// Model catalog imported from shared source (see src/config/model-catalog.ts)
+/**
+ * Auto-detect provider credentials (Codex), with a manual
+ * API-key fallback. Returns the resolved api key (empty when auto-detected,
+ * since it is read at runtime) and the STEPS[1].value status string.
+ */
+async function handleAutoDetectedProvider(opts: {
+ getKey: () => string;
+ isValid: () => boolean;
+ displayName: string;
+ noteTitle: string;
+ detectedFromMsg: string;
+ statusExpiredMsg: string;
+ noteFooterMsg: string;
+ notFoundHint: string;
+ fallbackKeyLabel: string;
+ prompter: ReturnType;
+}): Promise<{ apiKey: string; stepValue: string }> {
+ let apiKey = "";
+ let detected = false;
+ try {
+ const key = opts.getKey();
+ const valid = opts.isValid();
+ apiKey = ""; // Don't store in config β auto-detected at runtime
+ detected = true;
+ const masked = maskSecret(key, 12, 4);
+ noteBox(
+ `${opts.detectedFromMsg}\n` +
+ `Key: ${masked}\n` +
+ `Status: ${valid ? GREEN("valid β") : opts.statusExpiredMsg}\n` +
+ opts.noteFooterMsg,
+ opts.noteTitle,
+ TON
+ );
+ await confirm({
+ message: "Continue with auto-detected credentials?",
+ default: true,
+ theme,
+ });
+ } catch (error) {
+ if (error instanceof CancelledError) throw error;
+ opts.prompter.warn(opts.notFoundHint);
+ const useFallback = await confirm({
+ message: "Enter an API key manually instead?",
+ default: true,
+ theme,
+ });
+ if (useFallback) {
+ apiKey = await password({
+ message: opts.fallbackKeyLabel,
+ theme,
+ validate: (value = "") => {
+ if (!value || value.trim().length === 0) return "API key is required";
+ return true;
+ },
+ });
+ } else {
+ throw new CancelledError();
+ }
+ }
+
+ const stepValue = detected
+ ? `${opts.displayName} ${DIM("auto-detected β")}`
+ : `${opts.displayName} ${DIM(maskSecret(apiKey))}`;
+ return { apiKey, stepValue };
+}
/**
- * Main onboard command
+ * Prompt for an optional integration key: ask to enable, show a note, then
+ * collect+validate the key. Returns the entered value, or undefined if skipped.
+ * Callers handle assignment and `extras.push` based on the return value.
*/
-export async function onboardCommand(options: OnboardOptions = {}): Promise {
- // Web UI mode
- if (options.ui) {
- const { SetupServer } = await import("../../webui/setup-server.js");
- const port = parseInt(options.uiPort || "7777") || 7777;
- const url = `http://localhost:${port}/setup`;
-
- const blue = "\x1b[34m";
- const reset = "\x1b[0m";
- const dim = "\x1b[2m";
- console.log(`
-${blue} βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- β β
- β ______________ ________________ _ __ ___ _____________ ________ β
- β /_ __/ ____/ / / ____/_ __/ __ \\/ | / / / | / ____/ ____/ | / /_ __/ β
- β / / / __/ / / / __/ / / / / / / |/ / / /| |/ / __/ __/ / |/ / / / β
- β / / / /___/ /___/ /___ / / / /_/ / /| / / ___ / /_/ / /___/ /| / / / β
- β /_/ /_____/_____/_____/ /_/ \\____/_/ |_/ /_/ |_\\____/_____/_/ |_/ /_/ β
- β β
- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ DEV: ZKPROOF.T.ME βββ${reset}
+async function promptOptionalKey(opts: {
+ confirmMsg: string;
+ note: string;
+ noteTitle: string;
+ inputMsg: string;
+ validate: (value: string) => true | string;
+}): Promise {
+ const enable = await confirm({
+ message: opts.confirmMsg,
+ default: false,
+ theme,
+ });
- ${dim}Setup wizard running at${reset} ${url}
- ${dim}Opening in your default browser...${reset}
- ${dim}Press Ctrl+C to cancel.${reset}
-`);
+ if (!enable) return undefined;
+
+ noteBox(opts.note, opts.noteTitle, TON);
+ return input({
+ message: opts.inputMsg,
+ theme,
+ validate: (v = "") => opts.validate(v),
+ });
+}
- const server = new SetupServer(port);
- await server.start();
+/** Shared bot-token format (id:hash). Used by all interactive + non-interactive sites. */
+const BOT_TOKEN_REGEX = /^[0-9]+:[A-Za-z0-9_-]+$/;
- process.on("SIGINT", () => {
- void server.stop().then(() => process.exit(0));
- });
+/** Validate bot-token format for an inquirer `validate` callback. */
+function validateBotTokenFormat(value: string): true | string {
+ if (!value) return "Bot token is required";
+ if (!BOT_TOKEN_REGEX.test(value)) return "Invalid format (expected 123456:ABC...)";
+ return true;
+}
+
+/**
+ * Call Telegram getMe to verify a bot token and fetch its username.
+ * Returns `ok:true` with the username when verified, `ok:false` when the
+ * token is rejected, or `networkError:true` when the API is unreachable.
+ */
+async function validateAndFetchBot(
+ token: string
+): Promise<{ ok: boolean; username?: string; networkError?: boolean }> {
+ try {
+ const res = await fetchWithTimeout(`https://api.telegram.org/bot${token}/getMe`);
+ const data = await res.json();
+ if (!data.ok) return { ok: false };
+ return { ok: true, username: data.result.username };
+ } catch {
+ return { ok: false, networkError: true };
+ }
+}
+
+/** Prompt for a 24-word mnemonic and import+save the wallet, with spinner feedback. */
+async function importWalletFlow(spinner: ReturnType): Promise {
+ const mnemonicInput = await input({
+ message: "Enter your 24-word mnemonic (space-separated)",
+ theme,
+ validate: (value = "") => {
+ const words = value.trim().split(/\s+/);
+ return words.length === 24 ? true : `Expected 24 words, got ${words.length}`;
+ },
+ });
+ spinner.start(DIM("Importing wallet..."));
+ const wallet = await importWallet(mnemonicInput.trim().split(/\s+/));
+ saveWallet(wallet);
+ spinner.succeed(DIM(`Wallet imported: ${wallet.address}`));
+ return wallet;
+}
+
+type Policy = "open" | "allowlist" | "admin-only" | "disabled";
+
+/** Variable inputs for {@link buildConfig}; everything else is a centralized default. */
+interface BuildConfigInput {
+ provider: SupportedProvider;
+ apiKey: string;
+ baseUrl?: string;
+ model: string;
+ maxAgenticIterations: number;
+ telegramMode: "user" | "bot";
+ apiId: number;
+ apiHash: string;
+ phone: string;
+ userId: number;
+ dmPolicy: Policy;
+ groupPolicy: Policy;
+ requireMention: boolean;
+ execMode: "off" | "yolo";
+ botToken?: string;
+ botUsername?: string;
+ tonapiKey?: string;
+ toncenterApiKey?: string;
+ tavilyApiKey?: string;
+ cocoonPort?: number;
+ sessionPath: string;
+ workspaceRoot: string;
+}
- // Wait for user to click "Start Agent" in the browser
- await server.waitForLaunch();
- console.log("\n Launch signal received β stopping setup server");
- await server.stop();
+/**
+ * Build the full Config object from the variable wizard inputs, centralizing
+ * every schema default in one place. Called by both the interactive and the
+ * non-interactive flows so they can never silently diverge.
+ */
+function buildConfig(input: BuildConfigInput): Config {
+ return {
+ meta: {
+ version: "1.0.0",
+ created_at: new Date().toISOString(),
+ onboard_command: "teleton setup",
+ },
+ agent: {
+ provider: input.provider,
+ api_key: input.apiKey,
+ ...(input.baseUrl ? { base_url: input.baseUrl } : {}),
+ model: input.model,
+ max_tokens: 4096,
+ temperature: 0.7,
+ system_prompt: null,
+ max_agentic_iterations: input.maxAgenticIterations,
+ session_reset_policy: {
+ daily_reset_enabled: true,
+ daily_reset_hour: 4,
+ idle_expiry_enabled: true,
+ idle_expiry_minutes: 1440,
+ },
+ },
+ telegram: {
+ mode: input.telegramMode,
+ api_id: input.telegramMode === "user" ? input.apiId : 0,
+ api_hash: input.telegramMode === "user" ? input.apiHash : "",
+ phone: input.telegramMode === "user" ? input.phone : "",
+ session_name: "teleton_session",
+ session_path: input.sessionPath,
+ dm_policy: input.dmPolicy,
+ allow_from: [],
+ group_policy: input.groupPolicy,
+ group_allow_from: [],
+ require_mention: input.requireMention,
+ max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH,
+ typing_simulation: true,
+ rate_limit_messages_per_second: 1.0,
+ rate_limit_groups_per_minute: 20,
+ admin_ids: [input.userId],
+ owner_id: input.userId,
+ agent_channel: null,
+ debounce_ms: 1500,
+ bot_token: input.botToken,
+ bot_username: input.botUsername,
+ stream_mode: "all",
+ guest_mode: false,
+ },
+ storage: {
+ sessions_file: `${input.workspaceRoot}/sessions.json`,
+ memory_file: `${input.workspaceRoot}/memory.json`,
+ history_limit: 100,
+ },
+ embedding: { provider: "local" },
+ deals: DealsConfigSchema.parse({ enabled: !!input.botToken }),
+ webui: {
+ enabled: false,
+ port: 7777,
+ host: "127.0.0.1",
+ cors_origins: ["http://localhost:5173", "http://localhost:7777"],
+ log_requests: false,
+ },
+ dev: { hot_reload: false },
+ tool_rag: {
+ enabled: true,
+ top_k: 25,
+ always_include: [
+ "telegram_send_message",
+ "telegram_quote_reply",
+ "telegram_send_photo",
+ "journal_*",
+ "workspace_*",
+ "web_*",
+ ],
+ skip_unlimited_providers: false,
+ },
+ logging: { level: "info", pretty: true },
+ mcp: { servers: {} },
+ capabilities: {
+ exec: {
+ mode: input.execMode,
+ scope: "admin-only",
+ allowlist: [],
+ limits: { timeout: 120, max_output: 50000 },
+ audit: { log_commands: true },
+ },
+ },
+ ton_proxy: { enabled: false, port: 8080 },
+ heartbeat: {
+ enabled: true,
+ interval_ms: 3_600_000,
+ prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.",
+ self_configurable: false,
+ },
+ plugins: {},
+ ...(input.provider === "cocoon" && input.cocoonPort
+ ? { cocoon: { port: input.cocoonPort } }
+ : {}),
+ tonapi_key: input.tonapiKey,
+ toncenter_api_key: input.toncenterApiKey,
+ tavily_api_key: input.tavilyApiKey,
+ };
+}
- // Boot TonnetApp on the same port
- console.log(" Starting TonnetApp...\n");
- const { TeletonApp } = await import("../../index.js");
- const configPath = join(TELETON_ROOT, "config.yaml");
- const app = new TeletonApp(configPath);
- await app.start();
+// Model catalog imported from shared source (see src/config/model-catalog.ts)
- // Keep process alive (TonnetApp manages its own lifecycle)
+/**
+ * Main onboard command
+ */
+export async function onboardCommand(options: OnboardOptions = {}): Promise {
+ // Web UI mode
+ if (options.ui) {
+ await runUiSetup(options);
return;
}
@@ -173,44 +405,99 @@ ${blue} βββββββββββββββββββββββ
}
}
+/**
+ * Web UI setup mode: serve the browser setup wizard, then boot TonnetApp once
+ * the user clicks "Start Agent". Keeps the CLI->App lifecycle in one place.
+ */
+async function runUiSetup(options: OnboardOptions): Promise {
+ const { SetupServer } = await import("../../webui/setup-server.js");
+ const port = parseInt(options.uiPort || "7777") || 7777;
+ const url = `http://localhost:${port}/setup`;
+
+ // ASCII banner colors (raw ANSI β chalk has no equivalent blue export)
+ const blue = "\x1b[34m";
+ const reset = "\x1b[0m";
+ console.log(`
+${blue} βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β β
+ β ______________ ________________ _ __ ___ _____________ ________ β
+ β /_ __/ ____/ / / ____/_ __/ __ \\/ | / / / | / ____/ ____/ | / /_ __/ β
+ β / / / __/ / / / __/ / / / / / / |/ / / /| |/ / __/ __/ / |/ / / / β
+ β / / / /___/ /___/ /___ / / / /_/ / /| / / ___ / /_/ / /___/ /| / / / β
+ β /_/ /_____/_____/_____/ /_/ \\____/_/ |_/ /_/ |_\\____/_____/_/ |_/ /_/ β
+ β β
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ DEV: ZKPROOF.T.ME βββ${reset}
+
+ ${DIM("Setup wizard running at")} ${url}
+ ${DIM("Opening in your default browser...")}
+ ${DIM("Press Ctrl+C to cancel.")}
+`);
+
+ const server = new SetupServer(port);
+ await server.start();
+
+ process.on("SIGINT", () => {
+ void server.stop().then(() => process.exit(0));
+ });
+
+ // Wait for user to click "Start Agent" in the browser
+ await server.waitForLaunch();
+ console.log("\n Launch signal received β stopping setup server");
+ await server.stop();
+
+ // Boot TonnetApp on the same port
+ console.log(" Starting TonnetApp...\n");
+ const { TeletonApp } = await import("../../index.js");
+ const configPath = join(TELETON_ROOT, "config.yaml");
+ const app = new TeletonApp(configPath);
+ await app.start();
+
+ // Keep process alive (TonnetApp manages its own lifecycle)
+}
+
/**
* Interactive onboarding wizard
*/
-async function runInteractiveOnboarding(
+// ββ Interactive wizard state ββββββββββββββββββββββββββββββββββββββββββ
+
+/** Mutable state shared across the interactive wizard steps. */
+interface OnboardState {
+ selectedProvider: SupportedProvider;
+ selectedModel: string;
+ apiKey: string;
+ localBaseUrl: string;
+ cocoonInstance: number;
+ apiId: number;
+ apiHash: string;
+ phone: string;
+ userId: number;
+ telegramMode: "user" | "bot";
+ botToken: string | undefined;
+ botUsername: string | undefined;
+ dmPolicy: Policy;
+ groupPolicy: Policy;
+ requireMention: boolean;
+ maxAgenticIterations: string;
+ execMode: "off" | "yolo";
+ tonapiKey: string | undefined;
+ toncenterApiKey: string | undefined;
+ tavilyApiKey: string | undefined;
+}
+
+/**
+ * Step 0: Agent β security warning, workspace, name, Telegram mode.
+ * Returns the workspace/spinner shared by later steps, plus the agent name and
+ * mode. Returns null when the user declines to overwrite an existing config.
+ */
+async function stepAgent(
options: OnboardOptions,
prompter: ReturnType
-): Promise {
- // ββ Shared state ββ
- let selectedProvider: SupportedProvider = "anthropic";
- let selectedModel = "";
- let apiKey = "";
- let apiId = 0;
- let apiHash = "";
- let phone = "";
- let userId = 0;
- let tonapiKey: string | undefined;
- let toncenterApiKey: string | undefined;
- let tavilyApiKey: string | undefined;
- let telegramMode: "user" | "bot" = "user";
- let botToken: string | undefined;
- let botUsername: string | undefined;
- let dmPolicy: "open" | "allowlist" | "admin-only" | "disabled" = "admin-only";
- let groupPolicy: "open" | "allowlist" | "admin-only" | "disabled" = "admin-only";
- let requireMention = true;
- let maxAgenticIterations = "5";
- let execMode: "off" | "yolo" = "off";
- let cocoonInstance = 10000;
-
- // Intro
- console.clear();
- console.log();
- console.log(wizardFrame(0, STEPS));
- console.log();
- await sleep(800);
-
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 0: Agent β security warning, workspace, name, mode, modules
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+): Promise<{
+ workspace: Workspace;
+ spinner: ReturnType;
+ agentName: string;
+ telegramMode: "user" | "bot";
+} | null> {
redraw(0);
noteBox(
@@ -258,7 +545,7 @@ async function runInteractiveOnboarding(
});
if (!shouldOverwrite) {
console.log(`\n ${DIM("Setup cancelled β existing configuration preserved.")}\n`);
- return;
+ return null;
}
}
@@ -275,7 +562,7 @@ async function runInteractiveOnboarding(
writeFileSync(workspace.identityPath, updated, "utf-8");
}
- telegramMode = await select({
+ const telegramMode = await select({
message: "Telegram mode",
default: "user",
theme,
@@ -294,14 +581,24 @@ async function runInteractiveOnboarding(
});
STEPS[0].value = agentName;
+ return { workspace, spinner, agentName, telegramMode };
+}
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 1: Provider β select + tool limit warning + API key
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+/** Step 1: Provider β select provider, resolve API key/base URL, pick model. */
+async function stepProvider(
+ options: OnboardOptions,
+ prompter: ReturnType
+): Promise<{
+ selectedProvider: SupportedProvider;
+ apiKey: string;
+ localBaseUrl: string;
+ cocoonInstance: number;
+ selectedModel: string;
+}> {
redraw(1);
const providers = getSupportedProviders();
- selectedProvider = await select({
+ const selectedProvider = await select({
message: "AI Provider",
default: "anthropic",
theme,
@@ -326,7 +623,9 @@ async function runInteractiveOnboarding(
}
// API key (or Cocoon / Local setup)
+ let apiKey = "";
let localBaseUrl = "";
+ let cocoonInstance = 10000;
if (selectedProvider === "cocoon") {
// Cocoon Network β no API key, managed externally via cocoon-cli
apiKey = "";
@@ -379,110 +678,23 @@ async function runInteractiveOnboarding(
);
STEPS[1].value = `${providerMeta.displayName} ${DIM(localBaseUrl)}`;
- } else if (selectedProvider === "claude-code") {
- // Claude Code β auto-detect credentials, fallback to manual key
- let detected = false;
- try {
- const key = getClaudeCodeApiKey();
- const valid = isClaudeCodeTokenValid();
- apiKey = ""; // Don't store in config β auto-detected at runtime
- detected = true;
- const masked = key.length > 16 ? key.slice(0, 12) + "..." + key.slice(-4) : "***";
- noteBox(
- `Credentials auto-detected from Claude Code\n` +
- `Key: ${masked}\n` +
- `Status: ${valid ? GREEN("valid β") : "expired (will refresh on use)"}\n` +
- `Token will auto-refresh when it expires.`,
- "Claude Code",
- TON
- );
- await confirm({
- message: "Continue with auto-detected credentials?",
- default: true,
- theme,
- });
- } catch (error) {
- if (error instanceof CancelledError) throw error;
- prompter.warn(
- "Claude Code credentials not found. Make sure Claude Code is installed and authenticated (claude login)."
- );
- const useFallback = await confirm({
- message: "Enter an API key manually instead?",
- default: true,
- theme,
- });
- if (useFallback) {
- apiKey = await password({
- message: `Anthropic API Key (fallback)`,
- theme,
- validate: (value = "") => {
- if (!value || value.trim().length === 0) return "API key is required";
- return true;
- },
- });
- } else {
- throw new CancelledError();
- }
- }
-
- if (detected) {
- STEPS[1].value = `${providerMeta.displayName} ${DIM("auto-detected β")}`;
- } else {
- const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***";
- STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`;
- }
} else if (selectedProvider === "codex") {
// Codex β auto-detect credentials from ~/.codex/auth.json
- let detected = false;
- try {
- const key = getCodexApiKey();
- const valid = isCodexTokenValid();
- apiKey = ""; // Don't store in config β auto-detected at runtime
- detected = true;
- const masked = key.length > 16 ? key.slice(0, 12) + "..." + key.slice(-4) : "***";
- noteBox(
- `Credentials auto-detected from Codex CLI\n` +
- `Key: ${masked}\n` +
- `Status: ${valid ? GREEN("valid β") : "expired (run codex to re-authenticate)"}\n` +
- `Token read from ~/.codex/auth.json`,
- "Codex",
- TON
- );
- await confirm({
- message: "Continue with auto-detected credentials?",
- default: true,
- theme,
- });
- } catch (error) {
- if (error instanceof CancelledError) throw error;
- prompter.warn(
- "Codex credentials not found. Make sure Codex CLI is installed and authenticated."
- );
- const useFallback = await confirm({
- message: "Enter an OpenAI API key manually instead?",
- default: true,
- theme,
- });
- if (useFallback) {
- apiKey = await password({
- message: `OpenAI API Key (fallback)`,
- theme,
- validate: (value = "") => {
- if (!value || value.trim().length === 0) return "API key is required";
- return true;
- },
- });
- } else {
- throw new CancelledError();
- }
- }
-
- if (detected) {
- STEPS[1].value = `${providerMeta.displayName} ${DIM("auto-detected β")}`;
- } else {
- const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***";
- STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`;
- }
+ const result = await handleAutoDetectedProvider({
+ getKey: getCodexApiKey,
+ isValid: isCodexTokenValid,
+ displayName: providerMeta.displayName,
+ noteTitle: "Codex",
+ detectedFromMsg: "Credentials auto-detected from Codex CLI",
+ statusExpiredMsg: "expired (run codex to re-authenticate)",
+ noteFooterMsg: "Token read from ~/.codex/auth.json",
+ notFoundHint:
+ "Codex credentials not found. Make sure Codex CLI is installed and authenticated.",
+ fallbackKeyLabel: "OpenAI API Key (fallback)",
+ prompter,
+ });
+ apiKey = result.apiKey;
+ STEPS[1].value = result.stepValue;
} else {
// Standard providers β API key required
const envApiKey = process.env.TELETON_API_KEY;
@@ -514,12 +726,12 @@ async function runInteractiveOnboarding(
});
}
- const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***";
+ const maskedKey = maskSecret(apiKey);
STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`;
}
// Model selection (advanced mode only, after provider + API key)
- selectedModel = providerMeta.defaultModel;
+ let selectedModel = providerMeta.defaultModel;
if (selectedProvider !== "cocoon" && selectedProvider !== "local") {
const providerModels = getModelsForProvider(selectedProvider);
@@ -550,9 +762,21 @@ async function runInteractiveOnboarding(
STEPS[1].value = `${STEPS[1].value ?? providerMeta.displayName}, ${modelLabel}`;
}
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 2: Config β admin + policies
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ return { selectedProvider, apiKey, localBaseUrl, cocoonInstance, selectedModel };
+}
+
+/** Step 2: Config β admin user ID, DM/group policies, iterations, exec mode. */
+async function stepConfig(
+ options: OnboardOptions,
+ telegramMode: "user" | "bot"
+): Promise<{
+ userId: number;
+ dmPolicy: Policy;
+ groupPolicy: Policy;
+ requireMention: boolean;
+ maxAgenticIterations: string;
+ execMode: "off" | "yolo";
+}> {
redraw(2);
// Admin User ID
@@ -575,8 +799,11 @@ async function runInteractiveOnboarding(
return true;
},
});
- userId = parseInt(userIdStr);
+ const userId = parseInt(userIdStr);
+ let dmPolicy: Policy = "admin-only";
+ let groupPolicy: Policy = "admin-only";
+ let requireMention = true;
if (telegramMode === "bot") {
dmPolicy = "admin-only";
groupPolicy = "admin-only";
@@ -621,7 +848,7 @@ async function runInteractiveOnboarding(
});
}
- maxAgenticIterations = await input({
+ const maxAgenticIterations = await input({
message: "Max agentic iterations (tool call loops per message)",
default: "5",
theme,
@@ -631,7 +858,7 @@ async function runInteractiveOnboarding(
},
});
- execMode = await select({
+ const execMode = await select({
message: "Coding Agent (system execution)",
choices: [
{ value: "off" as const, name: "Disabled", description: "No system execution capability" },
@@ -646,13 +873,25 @@ async function runInteractiveOnboarding(
});
STEPS[2].value = `${dmPolicy}/${groupPolicy}`;
+ return { userId, dmPolicy, groupPolicy, requireMention, maxAgenticIterations, execMode };
+}
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 3: Modules β optional API keys
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+/** Step 3: Modules β optional bot token (user mode) and TonAPI/TonCenter/Tavily keys. */
+async function stepModules(
+ telegramMode: "user" | "bot",
+ spinner: ReturnType
+): Promise<{
+ botToken: string | undefined;
+ botUsername: string | undefined;
+ tonapiKey: string | undefined;
+ toncenterApiKey: string | undefined;
+ tavilyApiKey: string | undefined;
+}> {
redraw(3);
const extras: string[] = [];
+ let botToken: string | undefined;
+ let botUsername: string | undefined;
// Bot token (recommended β required for deals module; skipped in bot mode, handled at step 5)
const setupBot =
@@ -677,26 +916,18 @@ async function runInteractiveOnboarding(
const tokenInput = await password({
message: "Bot token (from @BotFather)",
theme,
- validate: (value) => {
- if (!value || !value.includes(":")) return "Invalid format (expected id:hash)";
- return true;
- },
+ validate: (value = "") => validateBotTokenFormat(value),
});
// Validate bot token
spinner.start(DIM("Validating bot token..."));
- try {
- const res = await fetchWithTimeout(`https://api.telegram.org/bot${tokenInput}/getMe`);
- const data = await res.json();
- if (!data.ok) {
- spinner.warn(DIM("Bot token is invalid β skipping bot setup"));
- } else {
- botToken = tokenInput;
- botUsername = data.result.username;
- spinner.succeed(DIM(`Bot verified: @${botUsername}`));
- extras.push("Bot");
- }
- } catch {
+ const result = await validateAndFetchBot(tokenInput);
+ if (result.ok) {
+ botToken = tokenInput;
+ botUsername = result.username;
+ spinner.succeed(DIM(`Bot verified: @${botUsername}`));
+ extras.push("Bot");
+ } else if (result.networkError) {
spinner.warn(DIM("Could not validate bot token (network error) β saving anyway"));
botToken = tokenInput;
const usernameInput = await input({
@@ -709,109 +940,70 @@ async function runInteractiveOnboarding(
});
botUsername = usernameInput;
extras.push("Bot");
+ } else {
+ spinner.warn(DIM("Bot token is invalid β skipping bot setup"));
}
}
// TonAPI key
- const setupTonapi = await confirm({
- message: `Add a TonAPI key? ${DIM("(strongly recommended for TON features)")}`,
- default: false,
- theme,
+ const tonapiKey = await promptOptionalKey({
+ confirmMsg: `Add a TonAPI key? ${DIM("(strongly recommended for TON features)")}`,
+ note:
+ "Blockchain data β jettons, NFTs, prices, transaction history.\n" +
+ "Without key: 1 req/s (you WILL hit rate limits)\n" +
+ "With free key: 5 req/s\n" +
+ "\n" +
+ "Open @tonapibot on Telegram β mini app β generate a server key",
+ noteTitle: "TonAPI",
+ inputMsg: "TonAPI key",
+ validate: (v) => (!v || v.length < 10 ? "Key too short" : true),
});
-
- if (setupTonapi) {
- noteBox(
- "Blockchain data β jettons, NFTs, prices, transaction history.\n" +
- "Without key: 1 req/s (you WILL hit rate limits)\n" +
- "With free key: 5 req/s\n" +
- "\n" +
- "Open @tonapibot on Telegram β mini app β generate a server key",
- "TonAPI",
- TON
- );
- const keyInput = await input({
- message: "TonAPI key",
- theme,
- validate: (v) => {
- if (!v || v.length < 10) return "Key too short";
- return true;
- },
- });
- tonapiKey = keyInput;
- extras.push("TonAPI");
- }
+ if (tonapiKey) extras.push("TonAPI");
// TonCenter key
- const setupToncenter = await confirm({
- message: `Add a TonCenter API key? ${DIM("(optional, dedicated RPC endpoint)")}`,
- default: false,
- theme,
- });
-
- if (setupToncenter) {
- noteBox(
+ const toncenterApiKey = await promptOptionalKey({
+ confirmMsg: `Add a TonCenter API key? ${DIM("(optional, dedicated RPC endpoint)")}`,
+ note:
"Blockchain RPC β send transactions, check balances.\n" +
- "Without key: falls back to ORBS network (decentralized, slower)\n" +
- "With free key: dedicated RPC endpoint\n" +
- "\n" +
- "Go to https://toncenter.com β get a free API key (instant, no signup)",
- "TonCenter",
- TON
- );
- const keyInput = await input({
- message: "TonCenter API key",
- theme,
- validate: (v) => {
- if (!v || v.length < 10) return "Key too short";
- return true;
- },
- });
- toncenterApiKey = keyInput;
- extras.push("TonCenter");
- }
-
- // Tavily key
- const setupTavily = await confirm({
- message: `Enable web search? ${DIM("(free Tavily key β 1,000 req/month)")}`,
- default: false,
- theme,
+ "Without key: falls back to ORBS network (decentralized, slower)\n" +
+ "With free key: dedicated RPC endpoint\n" +
+ "\n" +
+ "Go to https://toncenter.com β get a free API key (instant, no signup)",
+ noteTitle: "TonCenter",
+ inputMsg: "TonCenter API key",
+ validate: (v) => (!v || v.length < 10 ? "Key too short" : true),
});
+ if (toncenterApiKey) extras.push("TonCenter");
- if (setupTavily) {
- noteBox(
+ // Tavily key
+ const tavilyApiKey = await promptOptionalKey({
+ confirmMsg: `Enable web search? ${DIM("(free Tavily key β 1,000 req/month)")}`,
+ note:
"Web search lets your agent search the internet and read web pages.\n" +
- "\n" +
- "To get your free API key (takes 30 seconds):\n" +
- "\n" +
- " 1. Go to https://app.tavily.com/sign-in\n" +
- " 2. Create an account (email or Google/GitHub)\n" +
- " 3. Your API key is displayed on the dashboard\n" +
- " (starts with tvly-)\n" +
- "\n" +
- "Free plan: 1,000 requests/month β no credit card required.",
- "Tavily β Web Search API",
- TON
- );
- const keyInput = await input({
- message: "Tavily API key (starts with tvly-)",
- theme,
- validate: (v) => {
- if (!v || !v.startsWith("tvly-")) return "Should start with tvly-";
- return true;
- },
- });
- tavilyApiKey = keyInput;
- extras.push("Tavily");
- }
+ "\n" +
+ "To get your free API key (takes 30 seconds):\n" +
+ "\n" +
+ " 1. Go to https://app.tavily.com/sign-in\n" +
+ " 2. Create an account (email or Google/GitHub)\n" +
+ " 3. Your API key is displayed on the dashboard\n" +
+ " (starts with tvly-)\n" +
+ "\n" +
+ "Free plan: 1,000 requests/month β no credit card required.",
+ noteTitle: "Tavily β Web Search API",
+ inputMsg: "Tavily API key (starts with tvly-)",
+ validate: (v) => (!v || !v.startsWith("tvly-") ? "Should start with tvly-" : true),
+ });
+ if (tavilyApiKey) extras.push("Tavily");
STEPS[3].value = extras.length ? extras.join(", ") : "defaults";
+ return { botToken, botUsername, tonapiKey, toncenterApiKey, tavilyApiKey };
+}
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 4: Wallet β generate / import / keep
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+/** Step 4: Wallet β generate / import / keep, then show the mnemonic backup. */
+async function stepWallet(spinner: ReturnType): Promise {
redraw(4);
- let wallet;
+ let wallet: WalletData;
const existingWallet = walletExists() ? loadWallet() : null;
if (existingWallet) {
@@ -835,18 +1027,7 @@ async function runInteractiveOnboarding(
if (walletAction === "keep") {
wallet = existingWallet;
} else if (walletAction === "import") {
- const mnemonicInput = await input({
- message: "Enter your 24-word mnemonic (space-separated)",
- theme,
- validate: (value = "") => {
- const words = value.trim().split(/\s+/);
- return words.length === 24 ? true : `Expected 24 words, got ${words.length}`;
- },
- });
- spinner.start(DIM("Importing wallet..."));
- wallet = await importWallet(mnemonicInput.trim().split(/\s+/));
- saveWallet(wallet);
- spinner.succeed(DIM(`Wallet imported: ${wallet.address}`));
+ wallet = await importWalletFlow(spinner);
} else {
spinner.start(DIM("Generating new TON wallet..."));
wallet = await generateWallet();
@@ -869,18 +1050,7 @@ async function runInteractiveOnboarding(
});
if (walletAction === "import") {
- const mnemonicInput = await input({
- message: "Enter your 24-word mnemonic (space-separated)",
- theme,
- validate: (value = "") => {
- const words = value.trim().split(/\s+/);
- return words.length === 24 ? true : `Expected 24 words, got ${words.length}`;
- },
- });
- spinner.start(DIM("Importing wallet..."));
- wallet = await importWallet(mnemonicInput.trim().split(/\s+/));
- saveWallet(wallet);
- spinner.succeed(DIM(`Wallet imported: ${wallet.address}`));
+ wallet = await importWalletFlow(spinner);
} else {
spinner.start(DIM("Generating TON wallet..."));
wallet = await generateWallet();
@@ -941,10 +1111,25 @@ async function runInteractiveOnboarding(
}
STEPS[4].value = `${wallet.address.slice(0, 8)}...${wallet.address.slice(-4)}`;
+ return wallet;
+}
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 5: Telegram β credentials
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+/**
+ * Step 5: Telegram β bot token (bot mode) or API id/hash/phone (user mode).
+ * In bot mode it returns the resolved bot token/username; in user mode it
+ * returns the captured credentials. Unset fields keep their incoming values.
+ */
+async function stepTelegram(
+ options: OnboardOptions,
+ telegramMode: "user" | "bot",
+ spinner: ReturnType
+): Promise<{
+ botToken?: string;
+ botUsername?: string;
+ apiId?: number;
+ apiHash?: string;
+ phone?: string;
+}> {
redraw(5);
if (telegramMode === "bot") {
@@ -960,195 +1145,120 @@ async function runInteractiveOnboarding(
const tokenInput = await password({
message: "Bot token (from @BotFather)",
theme,
- validate: (value = "") => {
- if (!value) return "Bot token is required";
- if (!/^[0-9]+:[A-Za-z0-9_-]+$/.test(value))
- return "Invalid format (expected 123456:ABC...)";
- return true;
- },
+ validate: (value = "") => validateBotTokenFormat(value),
});
- botToken = tokenInput;
+ const botToken = tokenInput;
+ let botUsername: string | undefined;
// Validate and fetch bot username
spinner.start(DIM("Validating bot token..."));
- try {
- const res = await fetchWithTimeout(`https://api.telegram.org/bot${botToken}/getMe`);
- const data = await res.json();
- if (!data.ok) {
- spinner.warn(DIM("Bot token validation failed β saving anyway"));
- } else {
- botUsername = data.result.username;
- spinner.succeed(DIM(`Bot verified: @${botUsername}`));
- }
- } catch {
+ const result = await validateAndFetchBot(botToken);
+ if (result.ok) {
+ botUsername = result.username;
+ spinner.succeed(DIM(`Bot verified: @${botUsername}`));
+ } else if (result.networkError) {
spinner.warn(DIM("Could not validate bot token (network error) β saving anyway"));
+ } else {
+ spinner.warn(DIM("Bot token validation failed β saving anyway"));
}
STEPS[5].value = botUsername ? `@${botUsername}` : "bot token set";
- } else {
- noteBox(
- "To get your API credentials:\n" +
- "\n" +
- " 1. Go to https://my.telegram.org/apps\n" +
- " 2. Log in with your phone number\n" +
- ' 3. Click "API development tools"\n' +
- " 4. Create an application (any name/short name works)\n" +
- " 5. Copy the API ID (number) and API Hash (hex string)\n" +
- "\n" +
- "β Do NOT use a VPN β Telegram will block the login page.",
- "Telegram",
- TON
- );
+ return { botToken, botUsername };
+ }
- const envApiId = process.env.TELETON_TG_API_ID;
- const envApiHash = process.env.TELETON_TG_API_HASH;
- const envPhone = process.env.TELETON_TG_PHONE;
+ noteBox(
+ "To get your API credentials:\n" +
+ "\n" +
+ " 1. Go to https://my.telegram.org/apps\n" +
+ " 2. Log in with your phone number\n" +
+ ' 3. Click "API development tools"\n' +
+ " 4. Create an application (any name/short name works)\n" +
+ " 5. Copy the API ID (number) and API Hash (hex string)\n" +
+ "\n" +
+ "β Do NOT use a VPN β Telegram will block the login page.",
+ "Telegram",
+ TON
+ );
- const apiIdStr = options.apiId
- ? options.apiId.toString()
- : await input({
- message: envApiId ? "API ID (from env)" : "API ID (from my.telegram.org)",
- default: envApiId,
- theme,
- validate: (value) => {
- if (!value || isNaN(parseInt(value))) return "Invalid API ID (must be a number)";
- return true;
- },
- });
- apiId = parseInt(apiIdStr);
+ const envApiId = process.env.TELETON_TG_API_ID;
+ const envApiHash = process.env.TELETON_TG_API_HASH;
+ const envPhone = process.env.TELETON_TG_PHONE;
- apiHash = options.apiHash
- ? options.apiHash
- : await input({
- message: envApiHash ? "API Hash (from env)" : "API Hash (from my.telegram.org)",
- default: envApiHash,
- theme,
- validate: (value) => {
- if (!value || value.length < 10) return "Invalid API Hash";
- return true;
- },
- });
+ const apiIdStr = options.apiId
+ ? options.apiId.toString()
+ : await input({
+ message: envApiId ? "API ID (from env)" : "API ID (from my.telegram.org)",
+ default: envApiId,
+ theme,
+ validate: (value) => {
+ if (!value || isNaN(parseInt(value))) return "Invalid API ID (must be a number)";
+ return true;
+ },
+ });
+ const apiId = parseInt(apiIdStr);
- phone = options.phone
- ? options.phone
- : await input({
- message: envPhone ? "Phone number (from env)" : "Phone number (international format)",
- default: envPhone,
- theme,
- validate: (value) => {
- if (!value || !value.startsWith("+")) return "Must start with +";
- return true;
- },
- });
+ const apiHash = options.apiHash
+ ? options.apiHash
+ : await input({
+ message: envApiHash ? "API Hash (from env)" : "API Hash (from my.telegram.org)",
+ default: envApiHash,
+ theme,
+ validate: (value) => {
+ if (!value || value.length < 10) return "Invalid API Hash";
+ return true;
+ },
+ });
- STEPS[5].value = phone;
- }
+ const phone = options.phone
+ ? options.phone
+ : await input({
+ message: envPhone ? "Phone number (from env)" : "Phone number (international format)",
+ default: envPhone,
+ theme,
+ validate: (value) => {
+ if (!value || !value.startsWith("+")) return "Must start with +";
+ return true;
+ },
+ });
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Step 6: Connect β save config + Telegram auth
- // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ STEPS[5].value = phone;
+ return { apiId, apiHash, phone };
+}
+
+/** Step 6: Connect β build+save config, optionally authenticate with Telegram. */
+async function stepConnect(
+ state: OnboardState,
+ workspace: Workspace,
+ spinner: ReturnType,
+ prompter: ReturnType
+): Promise {
redraw(6);
- // Build config
- const config: Config = {
- meta: {
- version: "1.0.0",
- created_at: new Date().toISOString(),
- onboard_command: "teleton setup",
- },
- agent: {
- provider: selectedProvider,
- api_key: apiKey,
- ...(selectedProvider === "local" && localBaseUrl ? { base_url: localBaseUrl } : {}),
- model: selectedModel,
- max_tokens: 4096,
- temperature: 0.7,
- system_prompt: null,
- max_agentic_iterations: parseInt(maxAgenticIterations, 10),
- toolset: "full",
- session_reset_policy: {
- daily_reset_enabled: true,
- daily_reset_hour: 4,
- idle_expiry_enabled: true,
- idle_expiry_minutes: 1440,
- },
- },
- telegram: {
- mode: telegramMode,
- api_id: telegramMode === "user" ? apiId : 0,
- api_hash: telegramMode === "user" ? apiHash : "",
- phone: telegramMode === "user" ? phone : "",
- session_name: "teleton_session",
- session_path: workspace.sessionPath,
- dm_policy: dmPolicy,
- allow_from: [],
- group_policy: groupPolicy,
- group_allow_from: [],
- require_mention: requireMention,
- max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH,
- typing_simulation: true,
- rate_limit_messages_per_second: 1.0,
- rate_limit_groups_per_minute: 20,
- admin_ids: [userId],
- owner_id: userId,
- agent_channel: null,
- debounce_ms: 1500,
- bot_token: botToken,
- bot_username: botUsername,
- stream_mode: "all",
- },
- storage: {
- sessions_file: `${workspace.root}/sessions.json`,
- memory_file: `${workspace.root}/memory.json`,
- history_limit: 100,
- },
- embedding: { provider: "local" },
- deals: DealsConfigSchema.parse({ enabled: !!botToken }),
- webui: {
- enabled: false,
- port: 7777,
- host: "127.0.0.1",
- cors_origins: ["http://localhost:5173", "http://localhost:7777"],
- log_requests: false,
- },
- dev: { hot_reload: false },
- tool_rag: {
- enabled: true,
- top_k: 25,
- always_include: [
- "telegram_send_message",
- "telegram_quote_reply",
- "telegram_send_photo",
- "journal_*",
- "workspace_*",
- "web_*",
- ],
- skip_unlimited_providers: false,
- },
- logging: { level: "info", pretty: true },
- mcp: { servers: {} },
- capabilities: {
- exec: {
- mode: execMode,
- scope: "admin-only",
- allowlist: [],
- limits: { timeout: 120, max_output: 50000 },
- audit: { log_commands: true },
- },
- },
- ton_proxy: { enabled: false, port: 8080 },
- heartbeat: {
- enabled: true,
- interval_ms: 3_600_000,
- prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.",
- self_configurable: false,
- },
- plugins: {},
- ...(selectedProvider === "cocoon" ? { cocoon: { port: cocoonInstance } } : {}),
- tonapi_key: tonapiKey,
- toncenter_api_key: toncenterApiKey,
- tavily_api_key: tavilyApiKey,
- };
+ // Build config (shared with the non-interactive flow via buildConfig)
+ const config = buildConfig({
+ provider: state.selectedProvider,
+ apiKey: state.apiKey,
+ baseUrl: state.selectedProvider === "local" ? state.localBaseUrl : undefined,
+ model: state.selectedModel,
+ maxAgenticIterations: parseInt(state.maxAgenticIterations, 10),
+ telegramMode: state.telegramMode,
+ apiId: state.apiId,
+ apiHash: state.apiHash,
+ phone: state.phone,
+ userId: state.userId,
+ dmPolicy: state.dmPolicy,
+ groupPolicy: state.groupPolicy,
+ requireMention: state.requireMention,
+ execMode: state.execMode,
+ botToken: state.botToken,
+ botUsername: state.botUsername,
+ tonapiKey: state.tonapiKey,
+ toncenterApiKey: state.toncenterApiKey,
+ tavilyApiKey: state.tavilyApiKey,
+ cocoonPort: state.selectedProvider === "cocoon" ? state.cocoonInstance : undefined,
+ sessionPath: workspace.sessionPath,
+ workspaceRoot: workspace.root,
+ });
// Save config
spinner.start(DIM("Saving configuration..."));
@@ -1158,7 +1268,7 @@ async function runInteractiveOnboarding(
// Telegram authentication
let telegramConnected = false;
- if (telegramMode === "bot") {
+ if (state.telegramMode === "bot") {
console.log(`\n ${DIM("Bot mode β no Telegram auth required. Ready to start.")}\n`);
STEPS[6].value = "Bot mode β";
telegramConnected = true;
@@ -1176,9 +1286,9 @@ async function runInteractiveOnboarding(
try {
const sessionPath = join(TELETON_ROOT, "telegram_session.txt");
const client = new TelegramUserClient({
- apiId,
- apiHash,
- phone,
+ apiId: state.apiId,
+ apiHash: state.apiHash,
+ phone: state.phone,
sessionPath,
});
await client.connect();
@@ -1201,6 +1311,89 @@ async function runInteractiveOnboarding(
}
}
+ return telegramConnected;
+}
+
+async function runInteractiveOnboarding(
+ options: OnboardOptions,
+ prompter: ReturnType
+): Promise {
+ // ββ Mutable shared state ββ
+ const state: OnboardState = {
+ selectedProvider: "anthropic",
+ selectedModel: "",
+ apiKey: "",
+ localBaseUrl: "",
+ cocoonInstance: 10000,
+ apiId: 0,
+ apiHash: "",
+ phone: "",
+ userId: 0,
+ telegramMode: "user",
+ botToken: undefined,
+ botUsername: undefined,
+ dmPolicy: "admin-only",
+ groupPolicy: "admin-only",
+ requireMention: true,
+ maxAgenticIterations: "5",
+ execMode: "off",
+ tonapiKey: undefined,
+ toncenterApiKey: undefined,
+ tavilyApiKey: undefined,
+ };
+
+ // Intro
+ console.clear();
+ console.log();
+ console.log(wizardFrame(0, STEPS));
+ console.log();
+ await sleep(800);
+
+ // Step 0: Agent
+ const agentResult = await stepAgent(options, prompter);
+ if (!agentResult) return; // user declined to overwrite existing config
+ const { workspace, spinner } = agentResult;
+ state.telegramMode = agentResult.telegramMode;
+
+ // Step 1: Provider
+ const provider = await stepProvider(options, prompter);
+ state.selectedProvider = provider.selectedProvider;
+ state.apiKey = provider.apiKey;
+ state.localBaseUrl = provider.localBaseUrl;
+ state.cocoonInstance = provider.cocoonInstance;
+ state.selectedModel = provider.selectedModel;
+
+ // Step 2: Config
+ const cfg = await stepConfig(options, state.telegramMode);
+ state.userId = cfg.userId;
+ state.dmPolicy = cfg.dmPolicy;
+ state.groupPolicy = cfg.groupPolicy;
+ state.requireMention = cfg.requireMention;
+ state.maxAgenticIterations = cfg.maxAgenticIterations;
+ state.execMode = cfg.execMode;
+
+ // Step 3: Modules
+ const modules = await stepModules(state.telegramMode, spinner);
+ state.botToken = modules.botToken;
+ state.botUsername = modules.botUsername;
+ state.tonapiKey = modules.tonapiKey;
+ state.toncenterApiKey = modules.toncenterApiKey;
+ state.tavilyApiKey = modules.tavilyApiKey;
+
+ // Step 4: Wallet (produced + persisted inside the step; not needed downstream)
+ await stepWallet(spinner);
+
+ // Step 5: Telegram
+ const telegram = await stepTelegram(options, state.telegramMode, spinner);
+ if (telegram.botToken !== undefined) state.botToken = telegram.botToken;
+ if (telegram.botUsername !== undefined) state.botUsername = telegram.botUsername;
+ if (telegram.apiId !== undefined) state.apiId = telegram.apiId;
+ if (telegram.apiHash !== undefined) state.apiHash = telegram.apiHash;
+ if (telegram.phone !== undefined) state.phone = telegram.phone;
+
+ // Step 6: Connect
+ const telegramConnected = await stepConnect(state, workspace, spinner, prompter);
+
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Final summary
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -1231,7 +1424,7 @@ async function runNonInteractiveOnboarding(
prompter.error("Non-interactive bot mode requires: --bot-token");
process.exit(1);
}
- if (!/^[0-9]+:[A-Za-z0-9_-]+$/.test(options.botToken)) {
+ if (!BOT_TOKEN_REGEX.test(options.botToken)) {
prompter.error("--bot-token format invalid (expected 123456:ABC...)");
process.exit(1);
}
@@ -1261,102 +1454,27 @@ async function runNonInteractiveOnboarding(
const providerMeta = getProviderMetadata(selectedProvider);
- const config: Config = {
- meta: {
- version: "1.0.0",
- created_at: new Date().toISOString(),
- onboard_command: "teleton setup",
- },
- agent: {
- provider: selectedProvider,
- api_key: options.apiKey || "",
- ...(options.baseUrl ? { base_url: options.baseUrl } : {}),
- model: providerMeta.defaultModel,
- max_tokens: 4096,
- temperature: 0.7,
- system_prompt: null,
- max_agentic_iterations: 5,
- toolset: "full",
- session_reset_policy: {
- daily_reset_enabled: true,
- daily_reset_hour: 4,
- idle_expiry_enabled: true,
- idle_expiry_minutes: 1440,
- },
- },
- telegram: {
- mode: nonInteractiveMode,
- api_id: nonInteractiveMode === "user" ? (options.apiId ?? 0) : 0,
- api_hash: nonInteractiveMode === "user" ? (options.apiHash ?? "") : "",
- phone: nonInteractiveMode === "user" ? (options.phone ?? "") : "",
- session_name: "teleton_session",
- session_path: workspace.sessionPath,
- dm_policy: "admin-only",
- allow_from: [],
- group_policy: "admin-only",
- group_allow_from: [],
- require_mention: true,
- max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH,
- typing_simulation: true,
- rate_limit_messages_per_second: 1.0,
- rate_limit_groups_per_minute: 20,
- admin_ids: [options.userId ?? 0],
- owner_id: options.userId ?? 0,
- agent_channel: null,
- debounce_ms: 1500,
- bot_token: nonInteractiveMode === "bot" ? options.botToken : undefined,
- bot_username: undefined,
- stream_mode: "all",
- },
- storage: {
- sessions_file: `${workspace.root}/sessions.json`,
- memory_file: `${workspace.root}/memory.json`,
- history_limit: 100,
- },
- embedding: { provider: "local" },
- deals: DealsConfigSchema.parse({}),
- webui: {
- enabled: false,
- port: 7777,
- host: "127.0.0.1",
- cors_origins: ["http://localhost:5173", "http://localhost:7777"],
- log_requests: false,
- },
- dev: { hot_reload: false },
- tool_rag: {
- enabled: true,
- top_k: 25,
- always_include: [
- "telegram_send_message",
- "telegram_quote_reply",
- "telegram_send_photo",
- "journal_*",
- "workspace_*",
- "web_*",
- ],
- skip_unlimited_providers: false,
- },
- logging: { level: "info", pretty: true },
- capabilities: {
- exec: {
- mode: "off",
- scope: "admin-only",
- allowlist: [],
- limits: { timeout: 120, max_output: 50000 },
- audit: { log_commands: true },
- },
- },
- ton_proxy: { enabled: false, port: 8080 },
- heartbeat: {
- enabled: true,
- interval_ms: 3_600_000,
- prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.",
- self_configurable: false,
- },
- mcp: { servers: {} },
- plugins: {},
- tavily_api_key: options.tavilyApiKey,
- };
+ const config = buildConfig({
+ provider: selectedProvider,
+ apiKey: options.apiKey || "",
+ baseUrl: options.baseUrl,
+ model: providerMeta.defaultModel,
+ maxAgenticIterations: 5,
+ telegramMode: nonInteractiveMode,
+ apiId: options.apiId ?? 0,
+ apiHash: options.apiHash ?? "",
+ phone: options.phone ?? "",
+ userId: options.userId ?? 0,
+ dmPolicy: "admin-only",
+ groupPolicy: "admin-only",
+ requireMention: true,
+ execMode: "off",
+ botToken: nonInteractiveMode === "bot" ? options.botToken : undefined,
+ botUsername: undefined,
+ tavilyApiKey: options.tavilyApiKey,
+ sessionPath: workspace.sessionPath,
+ workspaceRoot: workspace.root,
+ });
const configYaml = YAML.stringify(config);
writeFileSync(workspace.configPath, configYaml, { encoding: "utf-8", mode: 0o600 });
diff --git a/src/cocoon/tool-adapter.ts b/src/cocoon/tool-adapter.ts
index b63d7fce..781479ed 100644
--- a/src/cocoon/tool-adapter.ts
+++ b/src/cocoon/tool-adapter.ts
@@ -10,7 +10,7 @@
import { randomUUID } from "crypto";
import { createLogger } from "../utils/logger.js";
-import type { Tool } from "@mariozechner/pi-ai";
+import type { Tool, UserMessage, ToolResultMessage } from "@mariozechner/pi-ai";
const log = createLogger("Cocoon");
@@ -215,3 +215,32 @@ export function wrapToolResult(resultText: string): string {
const safe = resultText.replace(/]]>/g, "]]]]>");
return `\n\n`;
}
+
+/**
+ * Build the message that carries a tool result back to the model. Cocoon has no
+ * native tool-result role, so its results are wrapped as a plain user message;
+ * every other provider uses the standard toolResult message. Centralising the
+ * branch here keeps the cocoon-specific shape out of the agentic loop.
+ */
+export function buildToolResultMessage(
+ provider: string,
+ block: { id: string; name: string },
+ resultText: string,
+ isError: boolean
+): UserMessage | ToolResultMessage {
+ if (provider === "cocoon") {
+ return {
+ role: "user",
+ content: [{ type: "text", text: wrapToolResult(resultText) }],
+ timestamp: Date.now(),
+ };
+ }
+ return {
+ role: "toolResult",
+ toolCallId: block.id,
+ toolName: block.name,
+ content: [{ type: "text", text: resultText }],
+ isError,
+ timestamp: Date.now(),
+ };
+}
diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts
index de0404c8..1d5081e8 100644
--- a/src/config/__tests__/configurable-keys.test.ts
+++ b/src/config/__tests__/configurable-keys.test.ts
@@ -254,8 +254,8 @@ describe("existing keys unchanged", () => {
expect(meta.validate("long-enough-key-here")).toBeUndefined();
});
- it("agent.provider still has all 16 options", () => {
+ it("agent.provider still has all 15 options", () => {
const meta = CONFIGURABLE_KEYS["agent.provider"];
- expect(meta.options).toHaveLength(16);
+ expect(meta.options).toHaveLength(15);
});
});
diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts
index a34e8593..4c4612c9 100644
--- a/src/config/configurable-keys.ts
+++ b/src/config/configurable-keys.ts
@@ -1,7 +1,7 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { parse, stringify } from "yaml";
import { expandPath } from "./loader.js";
-import { ConfigSchema } from "./schema.js";
+import { ConfigSchema, DMPolicy, GroupPolicy, ExecMode, ExecScope } from "./schema.js";
import { getSupportedProviders } from "./providers.js";
// ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -282,6 +282,7 @@ export const CONFIGURABLE_KEYS: Record = {
description: "Who can message the bot in private",
sensitive: false,
hotReload: "instant",
+ // UI order intentionally differs from the schema enum order
options: ["admin-only", "allowlist", "open", "disabled"],
optionLabels: {
"admin-only": "Admin Only",
@@ -289,7 +290,7 @@ export const CONFIGURABLE_KEYS: Record = {
open: "Open",
disabled: "Disabled",
},
- validate: enumValidator(["open", "allowlist", "admin-only", "disabled"]),
+ validate: enumValidator([...DMPolicy.options]),
mask: identity,
parse: identity,
},
@@ -300,14 +301,14 @@ export const CONFIGURABLE_KEYS: Record = {
description: "Which groups the bot can respond in",
sensitive: false,
hotReload: "instant",
- options: ["open", "allowlist", "admin-only", "disabled"],
+ options: [...GroupPolicy.options],
optionLabels: {
open: "Open",
allowlist: "Allow Groups",
"admin-only": "Admin Only",
disabled: "Disabled",
},
- validate: enumValidator(["open", "allowlist", "admin-only", "disabled"]),
+ validate: enumValidator([...GroupPolicy.options]),
mask: identity,
parse: identity,
},
@@ -457,6 +458,17 @@ export const CONFIGURABLE_KEYS: Record = {
mask: identity,
parse: (v) => Number(v),
},
+ "telegram.guest_mode": {
+ type: "boolean",
+ category: "Telegram",
+ label: "Guest Mode",
+ description: "Answer guest queries in chats the bot is not a member of",
+ sensitive: false,
+ hotReload: "instant",
+ validate: enumValidator(["true", "false"]),
+ mask: identity,
+ parse: (v) => v === "true",
+ },
// βββ Embedding βββββββββββββββββββββββββββββββββββββββββββββββββββββ
"embedding.provider": {
@@ -596,9 +608,9 @@ export const CONFIGURABLE_KEYS: Record = {
description: "System execution: off (disabled) or yolo (full system access)",
sensitive: false,
hotReload: "restart",
- options: ["off", "yolo"],
+ options: [...ExecMode.options],
optionLabels: { off: "Disabled", yolo: "YOLO" },
- validate: enumValidator(["off", "yolo"]),
+ validate: enumValidator([...ExecMode.options]),
mask: identity,
parse: identity,
},
@@ -609,9 +621,9 @@ export const CONFIGURABLE_KEYS: Record = {
description: "Who can trigger exec tools",
sensitive: false,
hotReload: "restart",
- options: ["admin-only", "allowlist", "all"],
+ options: [...ExecScope.options],
optionLabels: { "admin-only": "Admin Only", allowlist: "Allowlist", all: "Everyone" },
- validate: enumValidator(["admin-only", "allowlist", "all"]),
+ validate: enumValidator([...ExecScope.options]),
mask: identity,
parse: identity,
},
diff --git a/src/config/loader.ts b/src/config/loader.ts
index d2524def..f80800a6 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -47,6 +47,18 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
delete (raw as Record).market;
}
+ // Backward compatibility: the 'claude-code' provider was removed. Migrate existing
+ // configs to 'anthropic' so they keep loading instead of failing enum validation.
+ // The old credential auto-detection is gone β users must supply an Anthropic key.
+ const rawAgent = (raw as { agent?: { provider?: unknown } } | null)?.agent;
+ if (rawAgent && rawAgent.provider === "claude-code") {
+ log.warn(
+ "Provider 'claude-code' was removed; migrating to 'anthropic'. Set agent.api_key " +
+ "(or the TELETON_API_KEY env var) to your Anthropic key (sk-ant-...)."
+ );
+ rawAgent.provider = "anthropic";
+ }
+
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
throw new Error(`Invalid config: ${result.error.message}`);
@@ -54,11 +66,7 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
const config = result.data;
const provider = config.agent.provider as SupportedProvider;
- if (
- provider !== "anthropic" &&
- provider !== "claude-code" &&
- !(raw as Record>).agent?.model
- ) {
+ if (provider !== "anthropic" && !(raw as Record>).agent?.model) {
const meta = getProviderMetadata(provider);
config.agent.model = meta.defaultModel;
}
diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts
index 8c53cfd8..09514238 100644
--- a/src/config/model-catalog.ts
+++ b/src/config/model-catalog.ts
@@ -15,54 +15,54 @@ export const MODEL_OPTIONS: Record = {
{
value: "claude-opus-4-7",
name: "Claude Opus 4.7",
- description: "Most capable, 1M ctx, reasoning, $5/M",
+ description: "Most capable available, 1M ctx, reasoning, $5/$25",
},
{
value: "claude-opus-4-6",
name: "Claude Opus 4.6",
- description: "Previous gen, 1M ctx, reasoning, $5/M",
+ description: "Previous gen, 1M ctx, reasoning, $5/$25",
},
{
value: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
- description: "Older gen, 200K ctx, $5/M",
+ description: "Older gen, 200K ctx, $5/$25",
},
{
value: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
- description: "Balanced, 1M ctx, reasoning, $3/M",
+ description: "Balanced, 1M ctx, reasoning, $3/$15",
},
{
value: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
- description: "Fast & cheap, 200K ctx, $1/M (default)",
+ description: "Fast & cheap, 200K ctx, $1/$5 (default)",
},
],
openai: [
{
value: "gpt-5.5",
name: "GPT-5.5",
- description: "Latest frontier, reasoning, 272K ctx, $5/M",
+ description: "Latest frontier, reasoning, 272K ctx, $5/$30",
},
{
value: "gpt-5.5-pro",
name: "GPT-5.5 Pro",
- description: "Max capability, reasoning, 1M ctx, $30/M",
+ description: "Max capability, reasoning, 1M ctx, $30/$180",
},
- { value: "gpt-5.4", name: "GPT-5.4", description: "Reasoning, 272K ctx, $2.50/M" },
+ { value: "gpt-5.4", name: "GPT-5.4", description: "Reasoning, 272K ctx, $2.50/$15" },
{
value: "gpt-5.4-pro",
name: "GPT-5.4 Pro",
- description: "Extended thinking, 1M ctx, $30/M",
+ description: "Extended thinking, 1M ctx, $30/$180",
},
{
value: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
- description: "Fast & cheap, reasoning, 400K ctx, $0.75/M",
+ description: "Fast & cheap, reasoning, 400K ctx, $0.75/$4.50",
},
- { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/M" },
- { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/M" },
- { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/M" },
+ { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/$10" },
+ { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/$8" },
+ { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/$1.60" },
],
"openai-codex": [
{ value: "gpt-5.5", name: "GPT-5.5", description: "Latest frontier, reasoning, 272K ctx" },
@@ -79,31 +79,45 @@ export const MODEL_OPTIONS: Record = {
{
value: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
- description: "Preview, latest gen, reasoning, 1M ctx",
+ description: "Preview, latest gen, reasoning, 1M ctx, $2/$12",
+ },
+ {
+ value: "gemini-3.1-flash-lite-preview",
+ name: "Gemini 3.1 Flash Lite",
+ description: "Preview, fast & cheap, reasoning, 1M ctx, $0.25/$1.50",
+ },
+ { value: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Stable, 1M ctx, $1.25/$10" },
+ {
+ value: "gemini-2.5-flash",
+ name: "Gemini 2.5 Flash",
+ description: "Fast, 1M ctx, $0.30/$2.50",
},
- { value: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Stable, 1M ctx, $1.25/M" },
- { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" },
{
value: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite",
- description: "Ultra cheap, 1M ctx, $0.10/M",
+ description: "Ultra cheap, 1M ctx, $0.10/$0.40",
},
],
xai: [
{
value: "grok-4.3",
name: "Grok 4.3",
- description: "Latest, reasoning, vision, 1M ctx, $1.25/M",
+ description: "Latest, reasoning, vision, 1M ctx, $1.25/$2.50",
},
{
value: "grok-4.20-0309-reasoning",
name: "Grok 4.20 Reasoning",
- description: "Reasoning, vision, 2M ctx, $2/M",
+ description: "Reasoning, vision, 2M ctx, $2/$6",
},
{
value: "grok-4.20-0309-non-reasoning",
name: "Grok 4.20 Non-Reasoning",
- description: "Fast, vision, 2M ctx, $2/M",
+ description: "Fast, vision, 2M ctx, $2/$6",
+ },
+ {
+ value: "grok-4-1-fast-non-reasoning",
+ name: "Grok 4.1 Fast",
+ description: "Fast, vision, 2M ctx, $0.20/$0.50",
},
],
groq: [
@@ -250,9 +264,8 @@ export const MODEL_OPTIONS: Record = {
],
};
-/** Get models for a provider (claude-code β anthropic, codex β openai-codex) */
+/** Get models for a provider (codex β openai-codex) */
export function getModelsForProvider(provider: string): ModelOption[] {
- const key =
- provider === "claude-code" ? "anthropic" : provider === "codex" ? "openai-codex" : provider;
+ const key = provider === "codex" ? "openai-codex" : provider;
return MODEL_OPTIONS[key] || [];
}
diff --git a/src/config/providers.ts b/src/config/providers.ts
index 235517dd..d3a1d8fa 100644
--- a/src/config/providers.ts
+++ b/src/config/providers.ts
@@ -1,6 +1,5 @@
export type SupportedProvider =
| "anthropic"
- | "claude-code"
| "codex"
| "openai"
| "google"
@@ -30,18 +29,6 @@ export interface ProviderMetadata {
}
const PROVIDER_REGISTRY: Record = {
- "claude-code": {
- id: "claude-code",
- displayName: "Claude Code (Auto)",
- envVar: "ANTHROPIC_API_KEY",
- keyPrefix: "sk-ant-",
- keyHint: "Auto-detected from Claude Code",
- consoleUrl: "https://console.anthropic.com/",
- defaultModel: "claude-haiku-4-5-20251001",
- utilityModel: "claude-haiku-4-5-20251001",
- toolLimit: null,
- piAiProvider: "anthropic",
- },
codex: {
id: "codex",
displayName: "Codex (Auto)",
@@ -236,16 +223,19 @@ export function getSupportedProviders(): ProviderMetadata[] {
return Object.values(PROVIDER_REGISTRY);
}
+/**
+ * Provider ids as a non-empty tuple, derived from the single registry so the
+ * Zod `agent.provider` enum stays in sync with PROVIDER_REGISTRY (no 3rd copy).
+ */
+export const SUPPORTED_PROVIDER_IDS = Object.keys(PROVIDER_REGISTRY) as [
+ SupportedProvider,
+ ...SupportedProvider[],
+];
+
export function validateApiKeyFormat(provider: SupportedProvider, key: string): string | undefined {
const meta = PROVIDER_REGISTRY[provider];
if (!meta) return `Unknown provider: ${provider}`;
- if (
- provider === "cocoon" ||
- provider === "local" ||
- provider === "claude-code" ||
- provider === "codex"
- )
- return undefined;
+ if (provider === "cocoon" || provider === "local" || provider === "codex") return undefined;
if (!key || key.trim().length === 0) return "API key is required";
if (meta.keyPrefix && !key.startsWith(meta.keyPrefix)) {
return `Invalid format (should start with ${meta.keyPrefix})`;
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 09827664..689bda22 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -1,9 +1,15 @@
import { z } from "zod";
import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js";
+import { SUPPORTED_PROVIDER_IDS } from "./providers.js";
export const DMPolicy = z.enum(["allowlist", "open", "admin-only", "disabled"]);
export const GroupPolicy = z.enum(["open", "allowlist", "admin-only", "disabled"]);
+// Exec enums exported so the UI whitelist (configurable-keys.ts) reuses them
+// instead of re-listing the literals.
+export const ExecMode = z.enum(["off", "yolo"]);
+export const ExecScope = z.enum(["admin-only", "allowlist", "all"]);
+
export const SessionResetPolicySchema = z.object({
daily_reset_enabled: z.boolean().default(true).describe("Enable daily session reset"),
daily_reset_hour: z
@@ -20,26 +26,7 @@ export const SessionResetPolicySchema = z.object({
});
export const AgentConfigSchema = z.object({
- provider: z
- .enum([
- "anthropic",
- "claude-code",
- "codex",
- "openai",
- "google",
- "xai",
- "groq",
- "openrouter",
- "moonshot",
- "mistral",
- "cerebras",
- "zai",
- "minimax",
- "huggingface",
- "cocoon",
- "local",
- ])
- .default("anthropic"),
+ provider: z.enum(SUPPORTED_PROVIDER_IDS).default("anthropic"),
api_key: z.string().default(""),
base_url: z
.string()
@@ -58,10 +45,6 @@ export const AgentConfigSchema = z.object({
.number()
.default(5)
.describe("Maximum number of agentic loop iterations (tool call β result β tool call cycles)"),
- toolset: z
- .string()
- .default("full")
- .describe("Active toolset profile: minimal, standard, trading, full"),
session_reset_policy: SessionResetPolicySchema.default(SessionResetPolicySchema.parse({})),
});
@@ -105,6 +88,10 @@ export const TelegramConfigSchema = z
.describe(
"Bot streaming mode: replace=each iteration replaces draft (default), all=concatenate all iterations, off=no streaming"
),
+ guest_mode: z
+ .boolean()
+ .default(false)
+ .describe("Allow the bot to answer guest queries in chats it is not a member of"),
})
.superRefine((data, ctx) => {
if (data.mode === "user") {
@@ -315,14 +302,8 @@ const _ExecAuditObject = z.object({
});
const _ExecObject = z.object({
- mode: z
- .enum(["off", "yolo"])
- .default("off")
- .describe("Exec mode: off (disabled) or yolo (full system access)"),
- scope: z
- .enum(["admin-only", "allowlist", "all"])
- .default("admin-only")
- .describe("Who can trigger exec tools"),
+ mode: ExecMode.default("off").describe("Exec mode: off (disabled) or yolo (full system access)"),
+ scope: ExecScope.default("admin-only").describe("Who can trigger exec tools"),
allowlist: z
.array(z.number())
.default([])
diff --git a/src/constants/timeouts.ts b/src/constants/timeouts.ts
index 16059be2..7d718e63 100644
--- a/src/constants/timeouts.ts
+++ b/src/constants/timeouts.ts
@@ -10,6 +10,9 @@ export const RETRY_DEFAULT_TIMEOUT_MS = 15_000;
export const RETRY_BLOCKCHAIN_BASE_DELAY_MS = 2_000;
export const RETRY_BLOCKCHAIN_MAX_DELAY_MS = 15_000;
export const RETRY_BLOCKCHAIN_TIMEOUT_MS = 30_000;
+/** Max wait for an outgoing transfer to commit on-chain (TON finality is sub-second). */
+export const TON_CONFIRM_TIMEOUT_MS = 20_000;
+export const TON_CONFIRM_POLL_INTERVAL_MS = 1_000;
export const GRAMJS_RETRY_DELAY_MS = 1_000;
export const GRAMJS_CONNECT_RETRY_DELAY_MS = 3_000;
export const TOOL_EXECUTION_TIMEOUT_MS = 90_000;
diff --git a/src/deals/executor.ts b/src/deals/executor.ts
index 7af7c90a..9f39c0b5 100644
--- a/src/deals/executor.ts
+++ b/src/deals/executor.ts
@@ -7,9 +7,11 @@ import type Database from "better-sqlite3";
import type { ITelegramBridge } from "../telegram/bridge-interface.js";
import type { Deal } from "./types.js";
import { sendTon } from "../ton/transfer.js";
+import { tonExplorerTxUrl } from "../ton/confirm.js";
import { formatAsset } from "./utils.js";
import { JournalStore } from "../memory/journal-store.js";
import { getErrorMessage } from "../utils/errors.js";
+import { getClient } from "../sdk/telegram-utils.js";
import { createLogger } from "../utils/logger.js";
const log = createLogger("Deal");
@@ -88,15 +90,16 @@ export async function executeDeal(
);
// Send TON to user's wallet
- const txHash = await sendTon({
+ const sendResult = await sendTon({
toAddress: deal.user_payment_wallet,
amount: deal.agent_gives_ton_amount,
comment: `Deal #${dealId} - ${formatAsset(deal.agent_gives_type, deal.agent_gives_ton_amount, deal.agent_gives_gift_slug)}`,
});
- if (!txHash) {
- throw new Error("TON transfer failed (wallet not initialized or invalid parameters)");
+ if (!sendResult) {
+ throw new Error("TON transfer failed or could not be confirmed on-chain");
}
+ const txHash = sendResult.hash;
// Update deal: mark as completed (agent_sent_at already set by lock)
db.prepare(
@@ -120,6 +123,7 @@ export async function executeDeal(
I've sent **${deal.agent_gives_ton_amount} TON** to your wallet.
TX Hash: \`${txHash}\`
+${tonExplorerTxUrl(txHash)}
Thank you for trading! π`,
});
@@ -144,8 +148,7 @@ Thank you for trading! π`,
);
// Transfer collectible gift using Telegram API
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto path
- const gramJsClient = bridge.getRawClient() as any;
+ const gramJsClient = getClient(bridge);
const Api = (await import("telegram")).Api;
try {
diff --git a/src/deals/module.ts b/src/deals/module.ts
index dde71482..8b2c19f5 100644
--- a/src/deals/module.ts
+++ b/src/deals/module.ts
@@ -50,23 +50,35 @@ const dealsModule: PluginModule = {
tools(config) {
if (!config.deals?.enabled) return [];
+ // Deals run on the GramJS DealBot β userbot only (skipped in bot mode).
return [
{
tool: dealProposeTool,
executor: withDealsDb(dealProposeExecutor),
scope: "dm-only" as const,
+ mode: "user" as const,
},
{
tool: dealVerifyPaymentTool,
executor: withDealsDb(dealVerifyPaymentExecutor),
scope: "dm-only" as const,
+ mode: "user" as const,
+ },
+ {
+ tool: dealStatusTool,
+ executor: withDealsDb(dealStatusExecutor),
+ mode: "user" as const,
+ },
+ {
+ tool: dealListTool,
+ executor: withDealsDb(dealListExecutor),
+ mode: "user" as const,
},
- { tool: dealStatusTool, executor: withDealsDb(dealStatusExecutor) },
- { tool: dealListTool, executor: withDealsDb(dealListExecutor) },
{
tool: dealCancelTool,
executor: withDealsDb(dealCancelExecutor),
scope: "dm-only" as const,
+ mode: "user" as const,
},
];
},
diff --git a/src/index.ts b/src/index.ts
index af696894..80e26294 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -207,6 +207,7 @@ export class TeletonApp {
this.bridge,
this.config.telegram,
this.agent,
+ this.configPath,
modulePermissions,
this.toolRegistry
);
@@ -366,6 +367,7 @@ ${blue} βββββββββββββββββββββββ
this.bridge,
this.config.telegram,
this.agent,
+ this.configPath,
modulePermissions,
this.toolRegistry
);
@@ -609,6 +611,27 @@ ${blue} βββββββββββββββββββββββ
void this.debouncer!.enqueue(msg);
});
if (firstStart) {
+ this.bridge.onGuestMessage(async (msg) => {
+ if (!this.config.telegram.guest_mode) return "";
+ if (this.adminHandler.isPaused()) return "";
+ const response = await this.agent.processMessage({
+ chatId: `telegram:guest:${msg.chatId}`,
+ userMessage: msg.text,
+ userName: msg.senderFirstName,
+ senderUsername: msg.senderUsername,
+ isGroup: true,
+ isGuest: true,
+ timestamp: msg.timestamp.getTime(),
+ messageId: msg.id,
+ toolContext: {
+ bridge: this.bridge,
+ db: getDatabase().getDb(),
+ senderId: msg.senderId,
+ config: this.config,
+ },
+ });
+ return response.content;
+ });
this.bridge.startPolling();
}
void this.bridge.syncCommands();
diff --git a/src/memory/__tests__/schema.test.ts b/src/memory/__tests__/schema.test.ts
index 67554f37..8faa0ba5 100644
--- a/src/memory/__tests__/schema.test.ts
+++ b/src/memory/__tests__/schema.test.ts
@@ -1081,7 +1081,7 @@ describe("Memory Schema", () => {
});
it("CURRENT_SCHEMA_VERSION is set to expected value", () => {
- expect(CURRENT_SCHEMA_VERSION).toBe("1.18.0");
+ expect(CURRENT_SCHEMA_VERSION).toBe("1.19.0");
});
});
@@ -1360,5 +1360,94 @@ describe("Memory Schema", () => {
};
expect(row.enabled).toBe(1);
});
+
+ it("migration 1.19.0 adds scope_level column and keeps legacy ones", () => {
+ ensureSchema(db);
+ runMigrations(db);
+
+ const cols = (
+ db.prepare(`PRAGMA table_info(tool_config)`).all() as Array<{ name: string }>
+ ).map((c) => c.name);
+
+ expect(cols).toContain("enabled");
+ expect(cols).toContain("scope");
+ expect(cols).toContain("scope_level");
+ });
+
+ it("migration 1.19.0 backfills scope_level from legacy (enabled, scope)", () => {
+ ensureSchema(db);
+ // Simulate a pre-1.19 tool_config table populated by an existing user.
+ db.exec(`
+ CREATE TABLE tool_config (
+ tool_name TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
+ scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_by INTEGER
+ );
+ `);
+ const seed: Array<[string, number, string | null]> = [
+ ["t_open", 1, "open"],
+ ["t_always", 1, "always"],
+ ["t_dm", 1, "dm-only"], // context scope collapses to 'all'
+ ["t_group", 1, "group-only"], // collapses to 'all'
+ ["t_admin", 1, "admin-only"],
+ ["t_allow", 1, "allowlist"],
+ ["t_disabled", 1, "disabled"],
+ ["t_enabled0", 0, "admin-only"], // enabled=0 overrides scope β off
+ ["t_null", 1, null],
+ ];
+ const ins = db.prepare(
+ `INSERT INTO tool_config (tool_name, enabled, scope, updated_at) VALUES (?, ?, ?, unixepoch())`
+ );
+ for (const [n, e, s] of seed) ins.run(n, e, s);
+
+ setSchemaVersion(db, "1.18.0");
+ runMigrations(db);
+
+ const level = (name: string) =>
+ (
+ db.prepare(`SELECT scope_level FROM tool_config WHERE tool_name = ?`).get(name) as {
+ scope_level: string;
+ }
+ ).scope_level;
+
+ expect(level("t_open")).toBe("all");
+ expect(level("t_always")).toBe("all");
+ expect(level("t_dm")).toBe("all");
+ expect(level("t_group")).toBe("all");
+ expect(level("t_admin")).toBe("admin");
+ expect(level("t_allow")).toBe("allowlist");
+ expect(level("t_disabled")).toBe("off");
+ expect(level("t_enabled0")).toBe("off");
+ expect(level("t_null")).toBe("all");
+ });
+
+ it("migration 1.19.0 is idempotent on re-run", () => {
+ ensureSchema(db);
+ db.exec(`
+ CREATE TABLE tool_config (
+ tool_name TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
+ scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_by INTEGER
+ );
+ `);
+ db.prepare(
+ `INSERT INTO tool_config (tool_name, enabled, scope, updated_at) VALUES ('t_admin', 1, 'admin-only', unixepoch())`
+ ).run();
+
+ setSchemaVersion(db, "1.18.0");
+ runMigrations(db);
+ // Force the 1.19.0 block to run a second time.
+ setSchemaVersion(db, "1.18.0");
+ expect(() => runMigrations(db)).not.toThrow();
+
+ const row = db
+ .prepare(`SELECT scope_level FROM tool_config WHERE tool_name = 't_admin'`)
+ .get() as { scope_level: string };
+ expect(row.scope_level).toBe("admin");
+ });
});
});
diff --git a/src/memory/agent/tasks.ts b/src/memory/agent/tasks.ts
index a531778e..698ab507 100644
--- a/src/memory/agent/tasks.ts
+++ b/src/memory/agent/tasks.ts
@@ -147,11 +147,8 @@ export class TaskStore {
return this.getTask(taskId);
}
- getTask(id: string): Task | undefined {
- const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow | undefined;
-
- if (!row) return undefined;
-
+ /** Map a tasks row to a Task (single source for the 13-field projection). */
+ private rowToTask(row: TaskRow): Task {
return {
id: row.id,
description: row.description,
@@ -170,6 +167,11 @@ export class TaskStore {
};
}
+ getTask(id: string): Task | undefined {
+ const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow | undefined;
+ return row ? this.rowToTask(row) : undefined;
+ }
+
listTasks(filter?: { status?: TaskStatus; createdBy?: string }): Task[] {
let sql = `SELECT * FROM tasks WHERE 1=1`;
const params: (string | number)[] = [];
@@ -188,22 +190,7 @@ export class TaskStore {
const rows = this.db.prepare(sql).all(...params) as TaskRow[];
- return rows.map((row) => ({
- id: row.id,
- description: row.description,
- status: row.status as TaskStatus,
- priority: row.priority,
- createdBy: row.created_by ?? undefined,
- createdAt: new Date(row.created_at * 1000),
- startedAt: row.started_at ? new Date(row.started_at * 1000) : undefined,
- completedAt: row.completed_at ? new Date(row.completed_at * 1000) : undefined,
- result: row.result ?? undefined,
- error: row.error ?? undefined,
- scheduledFor: row.scheduled_for ? new Date(row.scheduled_for * 1000) : undefined,
- payload: row.payload ?? undefined,
- reason: row.reason ?? undefined,
- scheduledMessageId: row.scheduled_message_id ?? undefined,
- }));
+ return rows.map((row) => this.rowToTask(row));
}
getActiveTasks(): Task[] {
@@ -217,22 +204,7 @@ export class TaskStore {
)
.all() as TaskRow[];
- return rows.map((row) => ({
- id: row.id,
- description: row.description,
- status: row.status as TaskStatus,
- priority: row.priority,
- createdBy: row.created_by ?? undefined,
- createdAt: new Date(row.created_at * 1000),
- startedAt: row.started_at ? new Date(row.started_at * 1000) : undefined,
- completedAt: row.completed_at ? new Date(row.completed_at * 1000) : undefined,
- result: row.result ?? undefined,
- error: row.error ?? undefined,
- scheduledFor: row.scheduled_for ? new Date(row.scheduled_for * 1000) : undefined,
- payload: row.payload ?? undefined,
- reason: row.reason ?? undefined,
- scheduledMessageId: row.scheduled_message_id ?? undefined,
- }));
+ return rows.map((row) => this.rowToTask(row));
}
deleteTask(taskId: string): boolean {
diff --git a/src/memory/ai-summarization.ts b/src/memory/ai-summarization.ts
index a286e333..e6a9354c 100644
--- a/src/memory/ai-summarization.ts
+++ b/src/memory/ai-summarization.ts
@@ -1,11 +1,6 @@
-import {
- complete,
- type Context,
- type Message,
- type TextContent,
- type ToolCall,
-} from "@mariozechner/pi-ai";
-import { getUtilityModel } from "../agent/client.js";
+import { complete, type Context, type Message, type TextContent } from "@mariozechner/pi-ai";
+import { extractText, extractToolNames, stripEnvelopePrefix } from "../utils/pi-message.js";
+import { getUtilityModel } from "../providers/model-resolver.js";
import type { SupportedProvider } from "../config/providers.js";
import {
CHARS_PER_TOKEN_ESTIMATE,
@@ -84,10 +79,7 @@ function extractMessageContent(message: Message): string {
if (message.role === "user") {
return typeof message.content === "string" ? message.content : "[complex content]";
} else if (message.role === "assistant") {
- return message.content
- .filter((block): block is TextContent => block.type === "text")
- .map((block) => block.text)
- .join("\n");
+ return extractText(message);
}
return "";
}
@@ -98,19 +90,16 @@ export function formatMessagesForSummary(messages: Message[]): string {
for (const msg of messages) {
if (msg.role === "user") {
const content = typeof msg.content === "string" ? msg.content : "[complex]";
- const bodyMatch = content.match(/\] (.+)/s);
- const body = bodyMatch ? bodyMatch[1] : content;
+ const body = stripEnvelopePrefix(content);
formatted.push(`User: ${body}`);
} else if (msg.role === "assistant") {
const textBlocks = msg.content.filter((b): b is TextContent => b.type === "text");
if (textBlocks.length > 0) {
- const text = textBlocks.map((b) => b.text).join("\n");
- formatted.push(`Assistant: ${text}`);
+ formatted.push(`Assistant: ${extractText(msg)}`);
}
- const toolCalls = msg.content.filter((b): b is ToolCall => b.type === "toolCall");
- if (toolCalls.length > 0) {
- const toolNames = toolCalls.map((b) => b.name).join(", ");
- formatted.push(`[Used tools: ${toolNames}]`);
+ const toolNames = extractToolNames(msg);
+ if (toolNames.length > 0) {
+ formatted.push(`[Used tools: ${toolNames.join(", ")}]`);
}
} else if (msg.role === "toolResult") {
formatted.push(`[Tool result: ${msg.toolName}]`);
diff --git a/src/memory/compaction.ts b/src/memory/compaction.ts
index 413c05e8..002702cb 100644
--- a/src/memory/compaction.ts
+++ b/src/memory/compaction.ts
@@ -1,4 +1,5 @@
import type { Context, Message, TextContent } from "@mariozechner/pi-ai";
+import { truncate } from "../utils/pi-message.js";
import { appendToTranscript, readTranscript } from "../session/transcript.js";
import { randomUUID } from "crypto";
import { writeSummaryToDailyLog } from "./daily-logs.js";
@@ -16,6 +17,7 @@ import {
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_SUMMARY_TOKENS,
MEMORY_FLUSH_RECENT_MESSAGES,
+ CHARS_PER_TOKEN_ESTIMATE,
} from "../constants/limits.js";
const COMPACTION_PREFIX = "[Auto-compacted";
@@ -65,7 +67,7 @@ function estimateContextTokens(context: Context): number {
}
}
- return Math.ceil(charCount / 4);
+ return Math.ceil(charCount / CHARS_PER_TOKEN_ESTIMATE);
}
export function shouldFlushMemory(
@@ -97,12 +99,12 @@ function flushMemoryToDailyLog(context: Context): void {
for (const msg of recentMessages) {
if (msg.role === "user") {
const content = typeof msg.content === "string" ? msg.content : "[complex content]";
- summary.push(`- User: ${content.substring(0, 100)}${content.length > 100 ? "..." : ""}`);
+ summary.push(`- User: ${truncate(content, 100)}`);
} else if (msg.role === "assistant") {
const textBlocks = msg.content.filter((b): b is TextContent => b.type === "text");
if (textBlocks.length > 0) {
const text = textBlocks[0].text || "";
- summary.push(`- Assistant: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`);
+ summary.push(`- Assistant: ${truncate(text, 100)}`);
}
}
}
diff --git a/src/memory/schema.ts b/src/memory/schema.ts
index ecdee1fd..5e2a4577 100644
--- a/src/memory/schema.ts
+++ b/src/memory/schema.ts
@@ -4,6 +4,78 @@ import { createLogger } from "../utils/logger.js";
const log = createLogger("Memory");
+// ββ Shared DDL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Single source for table DDL that is otherwise duplicated verbatim between
+// ensureSchema (fresh DB) and a historical migration (existing DB). Only tables
+// whose inline and migration DDL were already byte-identical are factored here.
+// Tables whose two definitions diverge (exec_audit) or whose migration replaces
+// rather than creates (tg_messages FTS triggers: DROP+CREATE vs CREATE IF NOT
+// EXISTS) are deliberately left inline to avoid changing applied-migration
+// semantics on existing databases.
+
+const DDL_TASK_DEPENDENCIES = `
+ CREATE TABLE IF NOT EXISTS task_dependencies (
+ task_id TEXT NOT NULL,
+ depends_on_task_id TEXT NOT NULL,
+ PRIMARY KEY (task_id, depends_on_task_id),
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id);
+ CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id);
+`;
+
+const DDL_EMBEDDING_CACHE = `
+ CREATE TABLE IF NOT EXISTS embedding_cache (
+ hash TEXT NOT NULL,
+ model TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ embedding BLOB NOT NULL,
+ dims INTEGER NOT NULL,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ PRIMARY KEY (hash, model, provider)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at);
+`;
+
+const DDL_PLUGIN_CONFIG = `
+ CREATE TABLE IF NOT EXISTS plugin_config (
+ plugin_name TEXT PRIMARY KEY,
+ priority INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+`;
+
+const DDL_USER_HOOK_CONFIG = `
+ CREATE TABLE IF NOT EXISTS user_hook_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+`;
+
+/**
+ * Idempotent ALTER TABLE ... ADD COLUMN: swallows the "duplicate column name"
+ * error so migrations can re-run. Single definition shared by all migrations.
+ */
+function addColumnIfNotExists(
+ db: Database.Database,
+ table: string,
+ column: string,
+ type: string
+): void {
+ try {
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
+ } catch (error: unknown) {
+ if (!(error instanceof Error) || !error.message.includes("duplicate column name")) {
+ throw error;
+ }
+ }
+}
+
function compareSemver(a: string, b: string): number {
const parseVersion = (v: string) => {
const parts = v.split("-")[0].split(".").map(Number);
@@ -135,16 +207,7 @@ export function ensureSchema(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by) WHERE created_by IS NOT NULL;
-- Task Dependencies (for chained tasks)
- CREATE TABLE IF NOT EXISTS task_dependencies (
- task_id TEXT NOT NULL,
- depends_on_task_id TEXT NOT NULL,
- PRIMARY KEY (task_id, depends_on_task_id),
- FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
- FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE
- );
-
- CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id);
- CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id);
+ ${DDL_TASK_DEPENDENCIES}
-- ============================================
-- TELEGRAM FEED
@@ -243,18 +306,7 @@ export function ensureSchema(db: Database.Database): void {
-- EMBEDDING CACHE
-- ============================================
- CREATE TABLE IF NOT EXISTS embedding_cache (
- hash TEXT NOT NULL,
- model TEXT NOT NULL,
- provider TEXT NOT NULL,
- embedding BLOB NOT NULL,
- dims INTEGER NOT NULL,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
- PRIMARY KEY (hash, model, provider)
- );
-
- CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at);
+ ${DDL_EMBEDDING_CACHE}
-- =====================================================
-- EXEC AUDIT (Command Execution History)
@@ -284,21 +336,13 @@ export function ensureSchema(db: Database.Database): void {
-- PLUGIN CONFIG (Plugin Priority Order)
-- =====================================================
- CREATE TABLE IF NOT EXISTS plugin_config (
- plugin_name TEXT PRIMARY KEY,
- priority INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
+ ${DDL_PLUGIN_CONFIG}
-- =====================================================
-- USER HOOK CONFIG (Keyword Blocklist + Context Triggers)
-- =====================================================
- CREATE TABLE IF NOT EXISTS user_hook_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
+ ${DDL_USER_HOOK_CONFIG}
-- =====================================================
-- JOURNAL (Trading & Business Operations)
@@ -356,7 +400,7 @@ export function setSchemaVersion(db: Database.Database, version: string): void {
).run(version);
}
-export const CURRENT_SCHEMA_VERSION = "1.18.0";
+export const CURRENT_SCHEMA_VERSION = "1.19.0";
export function runMigrations(db: Database.Database): void {
const currentVersion = getSchemaVersion(db);
@@ -393,18 +437,7 @@ export function runMigrations(db: Database.Database): void {
`CREATE INDEX IF NOT EXISTS idx_tasks_scheduled ON tasks(scheduled_for) WHERE scheduled_for IS NOT NULL`
);
- db.exec(`
- CREATE TABLE IF NOT EXISTS task_dependencies (
- task_id TEXT NOT NULL,
- depends_on_task_id TEXT NOT NULL,
- PRIMARY KEY (task_id, depends_on_task_id),
- FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
- FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE
- );
-
- CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id);
- CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id);
- `);
+ db.exec(DDL_TASK_DEPENDENCIES);
log.info("Migration 1.1.0 complete: Scheduled tasks support added");
} catch (error) {
@@ -417,28 +450,19 @@ export function runMigrations(db: Database.Database): void {
log.info("Running migration 1.2.0: Extend sessions table for SQLite backend");
// Add missing columns to sessions table
- const addColumnIfNotExists = (table: string, column: string, type: string) => {
- try {
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
- } catch (error: unknown) {
- if (!(error instanceof Error) || !error.message.includes("duplicate column name")) {
- throw error;
- }
- }
- };
-
addColumnIfNotExists(
+ db,
"sessions",
"updated_at",
"INTEGER NOT NULL DEFAULT (unixepoch() * 1000)"
);
- addColumnIfNotExists("sessions", "last_message_id", "INTEGER");
- addColumnIfNotExists("sessions", "last_channel", "TEXT");
- addColumnIfNotExists("sessions", "last_to", "TEXT");
- addColumnIfNotExists("sessions", "context_tokens", "INTEGER");
- addColumnIfNotExists("sessions", "model", "TEXT");
- addColumnIfNotExists("sessions", "provider", "TEXT");
- addColumnIfNotExists("sessions", "last_reset_date", "TEXT");
+ addColumnIfNotExists(db, "sessions", "last_message_id", "INTEGER");
+ addColumnIfNotExists(db, "sessions", "last_channel", "TEXT");
+ addColumnIfNotExists(db, "sessions", "last_to", "TEXT");
+ addColumnIfNotExists(db, "sessions", "context_tokens", "INTEGER");
+ addColumnIfNotExists(db, "sessions", "model", "TEXT");
+ addColumnIfNotExists(db, "sessions", "provider", "TEXT");
+ addColumnIfNotExists(db, "sessions", "last_reset_date", "TEXT");
const sessions = db.prepare("SELECT started_at FROM sessions LIMIT 1").all() as Array<{
started_at: number;
@@ -461,19 +485,7 @@ export function runMigrations(db: Database.Database): void {
log.info("Running migration 1.9.0: Upgrade embedding_cache to BLOB storage");
try {
db.exec(`DROP TABLE IF EXISTS embedding_cache`);
- db.exec(`
- CREATE TABLE IF NOT EXISTS embedding_cache (
- hash TEXT NOT NULL,
- model TEXT NOT NULL,
- provider TEXT NOT NULL,
- embedding BLOB NOT NULL,
- dims INTEGER NOT NULL,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
- PRIMARY KEY (hash, model, provider)
- );
- CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at);
- `);
+ db.exec(DDL_EMBEDDING_CACHE);
log.info("Migration 1.9.0 complete: embedding_cache upgraded to BLOB storage");
} catch (error) {
log.error({ err: error }, "Migration 1.9.0 failed");
@@ -597,18 +609,8 @@ export function runMigrations(db: Database.Database): void {
if (!currentVersion || versionLessThan(currentVersion, "1.13.0")) {
log.info("Running migration 1.13.0: Add token usage columns to sessions");
try {
- const addColumnIfNotExists = (table: string, column: string, type: string) => {
- try {
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
- } catch (error: unknown) {
- if (!(error instanceof Error) || !error.message.includes("duplicate column name")) {
- throw error;
- }
- }
- };
-
- addColumnIfNotExists("sessions", "input_tokens", "INTEGER DEFAULT 0");
- addColumnIfNotExists("sessions", "output_tokens", "INTEGER DEFAULT 0");
+ addColumnIfNotExists(db, "sessions", "input_tokens", "INTEGER DEFAULT 0");
+ addColumnIfNotExists(db, "sessions", "output_tokens", "INTEGER DEFAULT 0");
log.info("Migration 1.13.0 complete: Token usage columns added to sessions");
} catch (error) {
@@ -620,13 +622,7 @@ export function runMigrations(db: Database.Database): void {
if (!currentVersion || versionLessThan(currentVersion, "1.14.0")) {
log.info("Running migration 1.14.0: Add plugin_config table for plugin priority");
try {
- db.exec(`
- CREATE TABLE IF NOT EXISTS plugin_config (
- plugin_name TEXT PRIMARY KEY,
- priority INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
- `);
+ db.exec(DDL_PLUGIN_CONFIG);
log.info("Migration 1.14.0 complete: plugin_config table created");
} catch (error) {
log.error({ err: error }, "Migration 1.14.0 failed");
@@ -637,13 +633,7 @@ export function runMigrations(db: Database.Database): void {
if (!currentVersion || versionLessThan(currentVersion, "1.15.0")) {
log.info("Running migration 1.15.0: Add user_hook_config table");
try {
- db.exec(`
- CREATE TABLE IF NOT EXISTS user_hook_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
- `);
+ db.exec(DDL_USER_HOOK_CONFIG);
log.info("Migration 1.15.0 complete: user_hook_config table created");
} catch (error) {
log.error({ err: error }, "Migration 1.15.0 failed");
@@ -685,21 +675,11 @@ export function runMigrations(db: Database.Database): void {
"Running migration 1.17.0: Add importance, access tracking, and lifecycle columns to knowledge"
);
try {
- const addColumnIfNotExists = (table: string, column: string, type: string) => {
- try {
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
- } catch (error: unknown) {
- if (!(error instanceof Error) || !error.message.includes("duplicate column name")) {
- throw error;
- }
- }
- };
-
- addColumnIfNotExists("knowledge", "importance", "REAL DEFAULT 0.5");
- addColumnIfNotExists("knowledge", "access_count", "INTEGER DEFAULT 0");
- addColumnIfNotExists("knowledge", "last_accessed_at", "INTEGER");
- addColumnIfNotExists("knowledge", "status", "TEXT DEFAULT 'active'");
- addColumnIfNotExists("knowledge", "memory_type", "TEXT DEFAULT 'semantic'");
+ addColumnIfNotExists(db, "knowledge", "importance", "REAL DEFAULT 0.5");
+ addColumnIfNotExists(db, "knowledge", "access_count", "INTEGER DEFAULT 0");
+ addColumnIfNotExists(db, "knowledge", "last_accessed_at", "INTEGER");
+ addColumnIfNotExists(db, "knowledge", "status", "TEXT DEFAULT 'active'");
+ addColumnIfNotExists(db, "knowledge", "memory_type", "TEXT DEFAULT 'semantic'");
db.exec(`CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge(status)`);
@@ -752,5 +732,58 @@ export function runMigrations(db: Database.Database): void {
}
}
+ if (!currentVersion || versionLessThan(currentVersion, "1.19.0")) {
+ log.info(
+ "Running migration 1.19.0: Add tool_config.scope_level (per-tool access: all/allowlist/admin/off)"
+ );
+ try {
+ const tableExists = db
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tool_config'")
+ .get();
+
+ if (tableExists) {
+ db.transaction(() => {
+ // Additive: keep the legacy enabled/scope columns intact (rollback to
+ // pre-1.19 code still reads a coherent table) and add scope_level.
+ addColumnIfNotExists(
+ db,
+ "tool_config",
+ "scope_level",
+ "TEXT NOT NULL DEFAULT 'all' CHECK(scope_level IN ('all', 'allowlist', 'admin', 'off'))"
+ );
+ // Backfill from the legacy (enabled, scope) pair. enabled=0 wins β off.
+ // The old context dimension (dm-only/group-only) collapses to 'all' β
+ // channel gating now lives in the global DM/Group policies. Full
+ // recompute (no WHERE) so a re-run is idempotent.
+ db.exec(`
+ UPDATE tool_config SET
+ scope_level = CASE
+ WHEN enabled = 0 THEN 'off'
+ WHEN scope = 'admin-only' THEN 'admin'
+ WHEN scope = 'allowlist' THEN 'allowlist'
+ WHEN scope = 'disabled' THEN 'off'
+ ELSE 'all'
+ END;
+ `);
+ })();
+ } else {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS tool_config (
+ tool_name TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
+ scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')),
+ scope_level TEXT NOT NULL DEFAULT 'all' CHECK(scope_level IN ('all', 'allowlist', 'admin', 'off')),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_by INTEGER
+ );
+ `);
+ }
+ log.info("Migration 1.19.0 complete: tool_config.scope_level added");
+ } catch (error) {
+ log.error({ err: error }, "Migration 1.19.0 failed");
+ throw error;
+ }
+ }
+
setSchemaVersion(db, CURRENT_SCHEMA_VERSION);
}
diff --git a/src/memory/search/fts-utils.ts b/src/memory/search/fts-utils.ts
new file mode 100644
index 00000000..6fcb7062
--- /dev/null
+++ b/src/memory/search/fts-utils.ts
@@ -0,0 +1,25 @@
+/**
+ * Shared SQLite FTS5 query helpers.
+ *
+ * The escape function is a security-relevant surface (prevents FTS5 syntax
+ * injection); keeping a single definition avoids the escaped-character list
+ * drifting between the knowledge RAG, the tool index and session search.
+ */
+
+/**
+ * Escape FTS5 special characters to prevent syntax errors.
+ */
+export function escapeFts5Query(query: string): string {
+ return query
+ .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * Convert BM25 rank to normalized score.
+ * FTS5 rank is negative; more negative = better match.
+ */
+export function bm25ToScore(rank: number): number {
+ return 1 / (1 + Math.exp(rank));
+}
diff --git a/src/memory/search/hybrid.ts b/src/memory/search/hybrid.ts
index 540affeb..7182985f 100644
--- a/src/memory/search/hybrid.ts
+++ b/src/memory/search/hybrid.ts
@@ -8,6 +8,7 @@ import {
SECONDS_PER_HOUR,
} from "../../constants/limits.js";
import { createLogger } from "../../utils/logger.js";
+import { escapeFts5Query, bm25ToScore } from "./fts-utils.js";
const log = createLogger("Memory");
@@ -61,16 +62,6 @@ export function parseTemporalIntent(query: string): { afterTimestamp?: number }
return {};
}
-/**
- * Escape FTS5 special characters to prevent syntax errors.
- */
-function escapeFts5Query(query: string): string {
- return query
- .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ")
- .replace(/\s+/g, " ")
- .trim();
-}
-
/**
* Hybrid search combining vector similarity and BM25 keyword search.
*/
@@ -235,7 +226,7 @@ export class HybridSearch {
return rows.map((row) => ({
...row,
- keywordScore: this.bm25ToScore(row.score),
+ keywordScore: bm25ToScore(row.score),
createdAt: row.created_at ?? undefined,
importance: row.importance ?? 0.5,
lastAccessedAt: row.last_accessed_at ?? undefined,
@@ -346,7 +337,7 @@ export class HybridSearch {
return rows.map((row) => ({
...row,
text: row.text ?? "",
- keywordScore: this.bm25ToScore(row.score),
+ keywordScore: bm25ToScore(row.score),
createdAt: row.timestamp ?? undefined,
}));
} catch (error) {
@@ -401,12 +392,4 @@ export class HybridSearch {
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
-
- /**
- * Convert BM25 rank to normalized score.
- * FTS5 rank is negative; more negative = better match.
- */
- private bm25ToScore(rank: number): number {
- return 1 / (1 + Math.exp(rank));
- }
}
diff --git a/src/memory/tool-config.ts b/src/memory/tool-config.ts
index e4b075e2..84ed10a9 100644
--- a/src/memory/tool-config.ts
+++ b/src/memory/tool-config.ts
@@ -1,43 +1,42 @@
import type Database from "better-sqlite3";
-import type { ToolScope } from "../agent/tools/types.js";
+import { levelToScope, type ToolAccessLevel } from "../agent/tools/scope.js";
export interface ToolConfig {
toolName: string;
- enabled: boolean;
- scope: ToolScope | null; // null = use default from tool definition
+ level: ToolAccessLevel;
updatedAt: number;
updatedBy: number | null;
}
+interface ToolConfigRow {
+ tool_name: string;
+ scope_level: ToolAccessLevel;
+ updated_at: number;
+ updated_by: number | null;
+}
+
+function rowToConfig(row: ToolConfigRow): ToolConfig {
+ return {
+ toolName: row.tool_name,
+ level: row.scope_level,
+ updatedAt: row.updated_at,
+ updatedBy: row.updated_by,
+ };
+}
+
/**
* Load tool configuration from database
*/
export function loadToolConfig(db: Database.Database, toolName: string): ToolConfig | null {
const row = db
.prepare(
- `SELECT tool_name, enabled, scope, updated_at, updated_by
+ `SELECT tool_name, scope_level, updated_at, updated_by
FROM tool_config
WHERE tool_name = ?`
)
- .get(toolName) as
- | {
- tool_name: string;
- enabled: number;
- scope: ToolScope | null;
- updated_at: number;
- updated_by: number | null;
- }
- | undefined;
-
- if (!row) return null;
+ .get(toolName) as ToolConfigRow | undefined;
- return {
- toolName: row.tool_name,
- enabled: row.enabled === 1,
- scope: row.scope,
- updatedAt: row.updated_at,
- updatedBy: row.updated_by,
- };
+ return row ? rowToConfig(row) : null;
}
/**
@@ -46,49 +45,41 @@ export function loadToolConfig(db: Database.Database, toolName: string): ToolCon
export function loadAllToolConfigs(db: Database.Database): Map {
const rows = db
.prepare(
- `SELECT tool_name, enabled, scope, updated_at, updated_by
+ `SELECT tool_name, scope_level, updated_at, updated_by
FROM tool_config`
)
- .all() as Array<{
- tool_name: string;
- enabled: number;
- scope: ToolScope | null;
- updated_at: number;
- updated_by: number | null;
- }>;
+ .all() as ToolConfigRow[];
const configs = new Map();
for (const row of rows) {
- configs.set(row.tool_name, {
- toolName: row.tool_name,
- enabled: row.enabled === 1,
- scope: row.scope,
- updatedAt: row.updated_at,
- updatedBy: row.updated_by,
- });
+ configs.set(row.tool_name, rowToConfig(row));
}
return configs;
}
/**
- * Save or update tool configuration
+ * Save or update a tool's access level. The legacy enabled/scope columns are
+ * kept in sync (derived) so a downgrade to pre-1.19 code still sees coherent
+ * values.
*/
export function saveToolConfig(
db: Database.Database,
toolName: string,
- enabled: boolean,
- scope: ToolScope | null,
+ level: ToolAccessLevel,
updatedBy?: number
): void {
+ const legacyScope = levelToScope(level);
+ const legacyEnabled = level === "off" ? 0 : 1;
db.prepare(
- `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by)
- VALUES (?, ?, ?, unixepoch(), ?)
+ `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by)
+ VALUES (?, ?, ?, ?, unixepoch(), ?)
ON CONFLICT(tool_name) DO UPDATE SET
enabled = excluded.enabled,
scope = excluded.scope,
+ scope_level = excluded.scope_level,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`
- ).run(toolName, enabled ? 1 : 0, scope, updatedBy ?? null);
+ ).run(toolName, legacyEnabled, legacyScope, level, updatedBy ?? null);
}
/**
@@ -97,15 +88,16 @@ export function saveToolConfig(
export function initializeToolConfig(
db: Database.Database,
toolName: string,
- defaultEnabled: boolean,
- defaultScope: ToolScope
+ level: ToolAccessLevel
): void {
const existing = loadToolConfig(db, toolName);
if (!existing) {
+ const legacyScope = levelToScope(level);
+ const legacyEnabled = level === "off" ? 0 : 1;
db.prepare(
- `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by)
- VALUES (?, ?, ?, unixepoch(), NULL)`
- ).run(toolName, defaultEnabled ? 1 : 0, defaultScope);
+ `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by)
+ VALUES (?, ?, ?, ?, unixepoch(), NULL)`
+ ).run(toolName, legacyEnabled, legacyScope, level);
}
}
diff --git a/src/providers/__tests__/claude-code-credentials.test.ts b/src/providers/__tests__/claude-code-credentials.test.ts
deleted file mode 100644
index 2aba36d6..00000000
--- a/src/providers/__tests__/claude-code-credentials.test.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { join } from "path";
-import { mkdirSync, writeFileSync, rmSync } from "fs";
-import { tmpdir } from "os";
-import { randomBytes } from "crypto";
-
-// ββ Test fixtures βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const TEST_DIR = join(tmpdir(), `claude-creds-test-${randomBytes(8).toString("hex")}`);
-const CREDS_FILE = join(TEST_DIR, ".credentials.json");
-
-function validCredentials(overrides: Record