fix: align telegram token resolution

This commit is contained in:
Peter Steinberger
2026-01-01 21:22:59 +01:00
parent e0043906be
commit c7364de2f0
13 changed files with 278 additions and 72 deletions

View File

@@ -32,6 +32,7 @@
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
- macOS remote: route settings through gateway config and avoid local config reads in remote mode. - macOS remote: route settings through gateway config and avoid local config reads in remote mode.
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl - Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl. - Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS. - Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.

View File

@@ -51,6 +51,7 @@ function mockConfig(
storePath: string, storePath: string,
routingOverrides?: Partial<NonNullable<ClawdisConfig["routing"]>>, routingOverrides?: Partial<NonNullable<ClawdisConfig["routing"]>>,
agentOverrides?: Partial<NonNullable<ClawdisConfig["agent"]>>, agentOverrides?: Partial<NonNullable<ClawdisConfig["agent"]>>,
telegramOverrides?: Partial<NonNullable<ClawdisConfig["telegram"]>>,
) { ) {
configSpy.mockReturnValue({ configSpy.mockReturnValue({
agent: { agent: {
@@ -60,6 +61,7 @@ function mockConfig(
}, },
session: { store: storePath, mainKey: "main" }, session: { store: storePath, mainKey: "main" },
routing: routingOverrides ? { ...routingOverrides } : undefined, routing: routingOverrides ? { ...routingOverrides } : undefined,
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
}); });
} }
@@ -198,4 +200,46 @@ describe("agentCommand", () => {
expect(callArgs?.prompt).toBe("ping"); expect(callArgs?.prompt).toBe("ping");
}); });
}); });
it("passes telegram token when delivering", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
const deps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
};
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
await agentCommand(
{
message: "hi",
to: "123",
deliver: true,
provider: "telegram",
},
runtime,
deps,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"ok",
expect.objectContaining({ token: "t-1" }),
);
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
});
}); });

View File

@@ -38,6 +38,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
type AgentCommandOpts = { type AgentCommandOpts = {
@@ -218,6 +219,8 @@ export async function agentCommand(
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
: sessionEntry?.skillsSnapshot; : sessionEntry?.skillsSnapshot;
const { token: telegramToken } = resolveTelegramToken(cfg);
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
const current = sessionEntry ?? { const current = sessionEntry ?? {
sessionId, sessionId,
@@ -544,6 +547,7 @@ export async function agentCommand(
for (const chunk of chunkText(text, 4000)) { for (const chunk of chunkText(text, 4000)) {
await deps.sendMessageTelegram(telegramTarget, chunk, { await deps.sendMessageTelegram(telegramTarget, chunk, {
verbose: false, verbose: false,
token: telegramToken || undefined,
}); });
} }
} else { } else {
@@ -554,6 +558,7 @@ export async function agentCommand(
await deps.sendMessageTelegram(telegramTarget, caption, { await deps.sendMessageTelegram(telegramTarget, caption, {
verbose: false, verbose: false,
mediaUrl: url, mediaUrl: url,
token: telegramToken || undefined,
}); });
} }
} }

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
@@ -7,6 +5,7 @@ import { callGateway } from "../gateway/call.js";
import { info } from "../globals.js"; import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
@@ -55,25 +54,6 @@ export type HealthSummary = {
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
function loadTelegramToken(cfg: ReturnType<typeof loadConfig>): string {
const env = process.env.TELEGRAM_BOT_TOKEN?.trim();
if (env) return env;
const tokenFile = cfg.telegram?.tokenFile?.trim();
if (tokenFile) {
try {
if (fs.existsSync(tokenFile)) {
const token = fs.readFileSync(tokenFile, "utf-8").trim();
if (token) return token;
}
} catch {
// Ignore errors; health should be non-fatal.
}
}
return cfg.telegram?.botToken?.trim() ?? "";
}
export async function getHealthSnapshot( export async function getHealthSnapshot(
timeoutMs?: number, timeoutMs?: number,
): Promise<HealthSummary> { ): Promise<HealthSummary> {
@@ -95,7 +75,7 @@ export async function getHealthSnapshot(
const start = Date.now(); const start = Date.now();
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
const telegramToken = loadTelegramToken(cfg); const { token: telegramToken } = resolveTelegramToken(cfg);
const telegramConfigured = telegramToken.trim().length > 0; const telegramConfigured = telegramToken.trim().length > 0;
const telegramProxy = cfg.telegram?.proxy; const telegramProxy = cfg.telegram?.proxy;
const telegramProbe = telegramConfigured const telegramProbe = telegramConfigured

View File

@@ -4,6 +4,11 @@ import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js"; import { sendCommand } from "./send.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", () => ({
loadConfig: () => testConfig,
}));
const callGatewayMock = vi.fn(); const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args), callGateway: (...args: unknown[]) => callGatewayMock(...args),
@@ -16,6 +21,7 @@ const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => { beforeEach(() => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc"; process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord"; process.env.DISCORD_BOT_TOKEN = "token-discord";
testConfig = {};
}); });
afterAll(() => { afterAll(() => {
@@ -75,6 +81,7 @@ describe("sendCommand", () => {
.fn() .fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }), .mockResolvedValue({ messageId: "t1", chatId: "123" }),
}); });
testConfig = { telegram: { botToken: "token-abc" } };
await sendCommand( await sendCommand(
{ to: "123", message: "hi", provider: "telegram" }, { to: "123", message: "hi", provider: "telegram" },
deps, deps,
@@ -88,6 +95,26 @@ describe("sendCommand", () => {
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
}); });
it("uses config token for telegram when env is missing", async () => {
process.env.TELEGRAM_BOT_TOKEN = "";
testConfig = { telegram: { botToken: "cfg-token" } };
const deps = makeDeps({
sendMessageTelegram: vi
.fn()
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
});
await sendCommand(
{ to: "123", message: "hi", provider: "telegram" },
deps,
runtime,
);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ token: "cfg-token" }),
);
});
it("routes to discord provider", async () => { it("routes to discord provider", async () => {
const deps = makeDeps({ const deps = makeDeps({
sendMessageDiscord: vi sendMessageDiscord: vi

View File

@@ -1,7 +1,9 @@
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { success } from "../globals.js"; import { success } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramToken } from "../telegram/token.js";
export async function sendCommand( export async function sendCommand(
opts: { opts: {
@@ -25,8 +27,9 @@ export async function sendCommand(
} }
if (provider === "telegram") { if (provider === "telegram") {
const { token } = resolveTelegramToken(loadConfig());
const result = await deps.sendMessageTelegram(opts.to, opts.message, { const result = await deps.sendMessageTelegram(opts.to, opts.message, {
token: process.env.TELEGRAM_BOT_TOKEN, token: token || undefined,
mediaUrl: opts.media, mediaUrl: opts.media,
}); });
runtime.log( runtime.log(

View File

@@ -53,14 +53,19 @@ async function writeSessionStore(home: string) {
return storePath; return storePath;
} }
function makeCfg(home: string, storePath: string): ClawdisConfig { function makeCfg(
return { home: string,
storePath: string,
overrides: Partial<ClawdisConfig> = {},
): ClawdisConfig {
const base: ClawdisConfig = {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
}, },
session: { store: storePath, mainKey: "main" }, session: { store: storePath, mainKey: "main" },
} as ClawdisConfig; } as ClawdisConfig;
return { ...base, ...overrides };
} }
function makeJob(payload: CronJob["payload"]): CronJob { function makeJob(payload: CronJob["payload"]): CronJob {
@@ -91,6 +96,7 @@ describe("runCronIsolatedAgentTurn", () => {
sendMessageWhatsApp: vi.fn(), sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(), sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(), sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
}; };
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "first" }, { text: " " }, { text: " last " }], payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
@@ -121,6 +127,7 @@ describe("runCronIsolatedAgentTurn", () => {
sendMessageWhatsApp: vi.fn(), sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(), sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(), sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
}; };
const long = "a".repeat(2001); const long = "a".repeat(2001);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
@@ -152,6 +159,7 @@ describe("runCronIsolatedAgentTurn", () => {
sendMessageWhatsApp: vi.fn(), sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(), sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(), sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
}; };
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }], payloads: [{ text: "hello" }],
@@ -190,6 +198,7 @@ describe("runCronIsolatedAgentTurn", () => {
sendMessageWhatsApp: vi.fn(), sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(), sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(), sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
}; };
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello" }], payloads: [{ text: "hello" }],
@@ -220,6 +229,60 @@ describe("runCronIsolatedAgentTurn", () => {
}); });
}); });
it("passes telegram token from config for delivery", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, { telegram: { botToken: "t-1" } }),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
channel: "telegram",
to: "123",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"hello from cron",
expect.objectContaining({ token: "t-1" }),
);
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
});
it("delivers via discord when configured", async () => { it("delivers via discord when configured", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const storePath = await writeSessionStore(home); const storePath = await writeSessionStore(home);
@@ -230,6 +293,7 @@ describe("runCronIsolatedAgentTurn", () => {
messageId: "d1", messageId: "d1",
channelId: "chan", channelId: "chan",
}), }),
sendMessageSignal: vi.fn(),
}; };
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }], payloads: [{ text: "hello from cron" }],

View File

@@ -24,6 +24,7 @@ import {
type SessionEntry, type SessionEntry,
saveSessionStore, saveSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import type { CronJob } from "./types.js"; import type { CronJob } from "./types.js";
@@ -206,6 +207,7 @@ export async function runCronIsolatedAgentTurn(params: {
? params.job.payload.to ? params.job.payload.to
: undefined, : undefined,
}); });
const { token: telegramToken } = resolveTelegramToken(params.cfg);
const base = const base =
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
@@ -352,6 +354,7 @@ export async function runCronIsolatedAgentTurn(params: {
for (const chunk of chunkText(payload.text ?? "", 4000)) { for (const chunk of chunkText(payload.text ?? "", 4000)) {
await params.deps.sendMessageTelegram(chatId, chunk, { await params.deps.sendMessageTelegram(chatId, chunk, {
verbose: false, verbose: false,
token: telegramToken || undefined,
}); });
} }
} else { } else {
@@ -362,6 +365,7 @@ export async function runCronIsolatedAgentTurn(params: {
await params.deps.sendMessageTelegram(chatId, caption, { await params.deps.sendMessageTelegram(chatId, caption, {
verbose: false, verbose: false,
mediaUrl: url, mediaUrl: url,
token: telegramToken || undefined,
}); });
} }
} }

View File

@@ -144,6 +144,7 @@ import { probeSignal, type SignalProbe } from "../signal/probe.js";
import { monitorTelegramProvider } from "../telegram/monitor.js"; import { monitorTelegramProvider } from "../telegram/monitor.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { sendMessageTelegram } from "../telegram/send.js"; import { sendMessageTelegram } from "../telegram/send.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164, resolveUserPath } from "../utils.js"; import { normalizeE164, resolveUserPath } from "../utils.js";
import type { WebProviderStatus } from "../web/auto-reply.js"; import type { WebProviderStatus } from "../web/auto-reply.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
@@ -292,33 +293,6 @@ const telegramRuntimeEnv = runtimeForLogger(logTelegram);
const discordRuntimeEnv = runtimeForLogger(logDiscord); const discordRuntimeEnv = runtimeForLogger(logDiscord);
const signalRuntimeEnv = runtimeForLogger(logSignal); const signalRuntimeEnv = runtimeForLogger(logSignal);
function loadTelegramToken(
config: ClawdisConfig,
opts: { logMissing?: boolean } = {},
): string {
if (process.env.TELEGRAM_BOT_TOKEN) {
return process.env.TELEGRAM_BOT_TOKEN.trim();
}
if (config.telegram?.tokenFile) {
const filePath = config.telegram.tokenFile;
if (!fs.existsSync(filePath)) {
if (opts.logMissing) {
logTelegram.warn(`telegram.tokenFile not found: ${filePath}`);
}
return "";
}
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch (err) {
if (opts.logMissing) {
logTelegram.warn(`telegram.tokenFile read failed: ${String(err)}`);
}
return "";
}
}
return config.telegram?.botToken?.trim() ?? "";
}
function resolveBonjourCliPath(): string | undefined { function resolveBonjourCliPath(): string | undefined {
const envPath = process.env.CLAWDIS_CLI_PATH?.trim(); const envPath = process.env.CLAWDIS_CLI_PATH?.trim();
if (envPath) return envPath; if (envPath) return envPath;
@@ -1956,7 +1930,9 @@ export async function startGatewayServer(
logTelegram.info("skipping provider start (telegram.enabled=false)"); logTelegram.info("skipping provider start (telegram.enabled=false)");
return; return;
} }
const telegramToken = loadTelegramToken(cfg, { logMissing: true }); const { token: telegramToken } = resolveTelegramToken(cfg, {
logMissingFile: (message) => logTelegram.warn(message),
});
if (!telegramToken.trim()) { if (!telegramToken.trim()) {
telegramRuntime = { telegramRuntime = {
...telegramRuntime, ...telegramRuntime,
@@ -1964,7 +1940,7 @@ export async function startGatewayServer(
lastError: "not configured", lastError: "not configured",
}; };
logTelegram.info( logTelegram.info(
"skipping provider start (no TELEGRAM_BOT_TOKEN/config)", "skipping provider start (no TELEGRAM_BOT_TOKEN/telegram config)",
); );
return; return;
} }
@@ -4058,14 +4034,8 @@ export async function startGatewayServer(
? Math.max(1000, timeoutMsRaw) ? Math.max(1000, timeoutMsRaw)
: 10_000; : 10_000;
const cfg = loadConfig(); const cfg = loadConfig();
const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim(); const { token: telegramToken, source: tokenSource } =
const configToken = cfg.telegram?.botToken?.trim(); resolveTelegramToken(cfg);
const telegramToken = envToken || configToken || "";
const tokenSource = envToken
? "env"
: configToken
? "config"
: "none";
let telegramProbe: TelegramProbe | undefined; let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null; let lastProbeAt: number | null = null;
if (probe && telegramToken) { if (probe && telegramToken) {
@@ -6023,7 +5993,7 @@ export async function startGatewayServer(
try { try {
if (provider === "telegram") { if (provider === "telegram") {
const cfg = loadConfig(); const cfg = loadConfig();
const token = loadTelegramToken(cfg); const { token } = resolveTelegramToken(cfg);
const result = await sendMessageTelegram(to, message, { const result = await sendMessageTelegram(to, message, {
mediaUrl: params.mediaUrl, mediaUrl: params.mediaUrl,
verbose: isVerbose(), verbose: isVerbose(),

View File

@@ -1,6 +1,6 @@
import fs from "node:fs";
import chalk from "chalk"; import chalk from "chalk";
import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
@@ -35,12 +35,8 @@ export async function buildProviderSummary(
if (!telegramEnabled) { if (!telegramEnabled) {
lines.push(chalk.cyan("Telegram: disabled")); lines.push(chalk.cyan("Telegram: disabled"));
} else { } else {
const telegramToken = const { token: telegramToken } = resolveTelegramToken(effective);
process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken; const telegramConfigured = Boolean(telegramToken);
const telegramTokenFile = effective.telegram?.tokenFile?.trim();
const telegramConfigured =
Boolean(telegramToken) ||
Boolean(telegramTokenFile ? fs.existsSync(telegramTokenFile) : false);
lines.push( lines.push(
telegramConfigured telegramConfigured
? chalk.green("Telegram: configured") ? chalk.green("Telegram: configured")

View File

@@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { createTelegramBot } from "./bot.js"; import { createTelegramBot } from "./bot.js";
import { makeProxyFetch } from "./proxy.js"; import { makeProxyFetch } from "./proxy.js";
import { resolveTelegramToken } from "./token.js";
import { startTelegramWebhook } from "./webhook.js"; import { startTelegramWebhook } from "./webhook.js";
export type MonitorTelegramOpts = { export type MonitorTelegramOpts = {
@@ -17,10 +18,12 @@ export type MonitorTelegramOpts = {
}; };
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const token = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim(); const { token } = resolveTelegramToken(loadConfig(), {
envToken: opts.token,
});
if (!token) { if (!token) {
throw new Error( throw new Error(
"TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram gateway", "TELEGRAM_BOT_TOKEN or telegram.botToken/tokenFile is required for Telegram gateway",
); );
} }

View File

@@ -0,0 +1,59 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdisConfig } from "../config/config.js";
import { resolveTelegramToken } from "./token.js";
function withTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-telegram-token-"));
}
describe("resolveTelegramToken", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("prefers env token over config", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token");
const cfg = { telegram: { botToken: "cfg-token" } } as ClawdisConfig;
const res = resolveTelegramToken(cfg);
expect(res.token).toBe("env-token");
expect(res.source).toBe("env");
});
it("uses tokenFile when configured", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const dir = withTempDir();
const tokenFile = path.join(dir, "token.txt");
fs.writeFileSync(tokenFile, "file-token\n", "utf-8");
const cfg = { telegram: { tokenFile } } as ClawdisConfig;
const res = resolveTelegramToken(cfg);
expect(res.token).toBe("file-token");
expect(res.source).toBe("tokenFile");
fs.rmSync(dir, { recursive: true, force: true });
});
it("falls back to config token when no env or tokenFile", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const cfg = { telegram: { botToken: "cfg-token" } } as ClawdisConfig;
const res = resolveTelegramToken(cfg);
expect(res.token).toBe("cfg-token");
expect(res.source).toBe("config");
});
it("does not fall back to config when tokenFile is missing", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const dir = withTempDir();
const tokenFile = path.join(dir, "missing-token.txt");
const cfg = {
telegram: { tokenFile, botToken: "cfg-token" },
} as ClawdisConfig;
const res = resolveTelegramToken(cfg);
expect(res.token).toBe("");
expect(res.source).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
});

50
src/telegram/token.ts Normal file
View File

@@ -0,0 +1,50 @@
import fs from "node:fs";
import type { ClawdisConfig } from "../config/config.js";
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
export type TelegramTokenResolution = {
token: string;
source: TelegramTokenSource;
};
type ResolveTelegramTokenOpts = {
envToken?: string | null;
logMissingFile?: (message: string) => void;
};
export function resolveTelegramToken(
cfg?: ClawdisConfig,
opts: ResolveTelegramTokenOpts = {},
): TelegramTokenResolution {
const envToken = (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim();
if (envToken) {
return { token: envToken, source: "env" };
}
const tokenFile = cfg?.telegram?.tokenFile?.trim();
if (tokenFile) {
if (!fs.existsSync(tokenFile)) {
opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`);
return { token: "", source: "none" };
}
try {
const token = fs.readFileSync(tokenFile, "utf-8").trim();
if (token) {
return { token, source: "tokenFile" };
}
} catch (err) {
opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`);
return { token: "", source: "none" };
}
return { token: "", source: "none" };
}
const configToken = cfg?.telegram?.botToken?.trim();
if (configToken) {
return { token: configToken, source: "config" };
}
return { token: "", source: "none" };
}