chore(ci): fix lint and swiftformat failures

main
Peter Steinberger 2025-12-08 01:48:53 +01:00
parent 68d19d4717
commit f65702a8a8
21 changed files with 161 additions and 102 deletions

View File

@ -11,7 +11,7 @@ struct GeneralSettings: View {
@State private var remoteStatus: RemoteStatus = .idle @State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
if !self.state.onboardingSeen { if !self.state.onboardingSeen {
@ -128,7 +128,8 @@ var body: some View {
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
switch self.remoteStatus { switch self.remoteStatus {
case .idle: case .idle:
@ -307,7 +308,7 @@ extension GeneralSettings {
} }
@MainActor @MainActor
fileprivate func testRemote() async { private func testRemote() async {
self.remoteStatus = .checking self.remoteStatus = .checking
let command = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"]) let command = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"])
let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10) let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10)

View File

@ -1,6 +1,6 @@
import Foundation import Foundation
import SwiftUI
import OSLog import OSLog
import SwiftUI
struct HealthSnapshot: Codable, Sendable { struct HealthSnapshot: Codable, Sendable {
struct Web: Codable, Sendable { struct Web: Codable, Sendable {

View File

@ -257,7 +257,8 @@ struct OnboardingView: View {
self.onboardingPage { self.onboardingPage {
Text("Link WhatsApp") Text("Link WhatsApp")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.") Text(
"Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -367,7 +368,11 @@ struct OnboardingView: View {
.frame(width: self.pageWidth, alignment: .top) .frame(width: self.pageWidth, alignment: .top)
} }
private func onboardingCard(spacing: CGFloat = 12, padding: CGFloat = 16, @ViewBuilder _ content: () -> some View) -> some View { private func onboardingCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: spacing) { VStack(alignment: .leading, spacing: spacing) {
content() content()
} }

View File

@ -282,7 +282,11 @@ enum CommandResolver {
static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] { static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
let settings = self.connectionSettings() let settings = self.connectionSettings()
if settings.mode == .remote, let ssh = self.sshCommand(subcommand: subcommand, extraArgs: extraArgs, settings: settings) { if settings.mode == .remote, let ssh = self.sshCommand(
subcommand: subcommand,
extraArgs: extraArgs,
settings: settings)
{
return ssh return ssh
} }
if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) { if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) {
@ -337,7 +341,11 @@ enum CommandResolver {
let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
return RemoteSettings(mode: mode, target: self.sanitizedTarget(target), identity: identity, projectRoot: projectRoot) return RemoteSettings(
mode: mode,
target: self.sanitizedTarget(target),
identity: identity,
projectRoot: projectRoot)
} }
private static func sanitizedTarget(_ raw: String) -> String { private static func sanitizedTarget(_ raw: String) -> String {

View File

@ -60,7 +60,6 @@ struct VoiceWakeSettings: View {
self.micPicker self.micPicker
self.levelMeter self.levelMeter
VoiceWakeTestCard( VoiceWakeTestCard(
testState: self.$testState, testState: self.$testState,
isTesting: self.$isTesting, isTesting: self.$isTesting,

View File

@ -162,27 +162,27 @@ final class WebChatServer: @unchecked Sendable {
private func statusText(_ code: Int) -> String { private func statusText(_ code: Int) -> String {
switch code { switch code {
case 200: return "OK" case 200: "OK"
case 403: return "Forbidden" case 403: "Forbidden"
case 404: return "Not Found" case 404: "Not Found"
default: return "Error" default: "Error"
} }
} }
private func mimeType(forExtension ext: String) -> String { private func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() { switch ext.lowercased() {
case "html", "htm": return "text/html; charset=utf-8" case "html", "htm": "text/html; charset=utf-8"
case "js", "mjs": return "application/javascript; charset=utf-8" case "js", "mjs": "application/javascript; charset=utf-8"
case "css": return "text/css; charset=utf-8" case "css": "text/css; charset=utf-8"
case "json", "map": return "application/json; charset=utf-8" case "json", "map": "application/json; charset=utf-8"
case "svg": return "image/svg+xml" case "svg": "image/svg+xml"
case "png": return "image/png" case "png": "image/png"
case "jpg", "jpeg": return "image/jpeg" case "jpg", "jpeg": "image/jpeg"
case "gif": return "image/gif" case "gif": "image/gif"
case "woff2": return "font/woff2" case "woff2": "font/woff2"
case "woff": return "font/woff" case "woff": "font/woff"
case "ttf": return "font/ttf" case "ttf": "font/ttf"
default: return "application/octet-stream" default: "application/octet-stream"
} }
} }
} }

View File

@ -9,6 +9,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
private let webView: WKWebView private let webView: WKWebView
private let sessionKey: String private let sessionKey: String
private let initialMessagesJSON: String private let initialMessagesJSON: String
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
init(sessionKey: String) { init(sessionKey: String) {
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)") webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")

View File

@ -579,10 +579,7 @@ Examples:
"--webhook-secret <secret>", "--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests", "Secret token to verify Telegram webhook requests",
) )
.option( .option("--port <port>", "Port for webhook server (default 8787)")
"--port <port>",
"Port for webhook server (default 8787)",
)
.option( .option(
"--webhook-url <url>", "--webhook-url <url>",
"Public webhook URL to register (overrides localhost autodetect)", "Public webhook URL to register (overrides localhost autodetect)",
@ -602,7 +599,9 @@ Examples:
process.env.TELEGRAM_BOT_TOKEN ?? loadConfig().telegram?.botToken; process.env.TELEGRAM_BOT_TOKEN ?? loadConfig().telegram?.botToken;
if (!token) { if (!token) {
defaultRuntime.error( defaultRuntime.error(
danger("Set TELEGRAM_BOT_TOKEN or telegram.botToken to use telegram relay"), danger(
"Set TELEGRAM_BOT_TOKEN or telegram.botToken to use telegram relay",
),
); );
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
@ -612,7 +611,9 @@ Examples:
const port = opts.port ? Number.parseInt(String(opts.port), 10) : 8787; const port = opts.port ? Number.parseInt(String(opts.port), 10) : 8787;
const path = opts.webhookPath ?? "/telegram-webhook"; const path = opts.webhookPath ?? "/telegram-webhook";
try { try {
const { monitorTelegramProvider } = await import("../telegram/monitor.js"); const { monitorTelegramProvider } = await import(
"../telegram/monitor.js"
);
await monitorTelegramProvider({ await monitorTelegramProvider({
token, token,
useWebhook: true, useWebhook: true,

View File

@ -1,4 +1,4 @@
import { beforeEach, afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@ -66,9 +66,7 @@ describe("sendCommand", () => {
it("falls back to direct send when IPC fails", async () => { it("falls back to direct send when IPC fails", async () => {
sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" }); sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" });
const deps = makeDeps({ const deps = makeDeps({
sendMessageWhatsApp: vi sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "direct1" }),
.fn()
.mockResolvedValue({ messageId: "direct1" }),
}); });
await sendCommand( await sendCommand(
{ {
@ -104,9 +102,7 @@ describe("sendCommand", () => {
it("emits json output", async () => { it("emits json output", async () => {
sendViaIpcMock.mockResolvedValueOnce(null); sendViaIpcMock.mockResolvedValueOnce(null);
const deps = makeDeps({ const deps = makeDeps({
sendMessageWhatsApp: vi sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "direct2" }),
.fn()
.mockResolvedValue({ messageId: "direct2" }),
}); });
await sendCommand( await sendCommand(
{ {

View File

@ -32,7 +32,7 @@ export type LoggerSettings = {
file?: string; file?: string;
}; };
type LogObj = Record<string, unknown>; type LogObj = { date?: Date } & Record<string, unknown>;
type ResolvedSettings = { type ResolvedSettings = {
level: Level; level: Level;
@ -48,7 +48,9 @@ let consolePatched = false;
function normalizeLevel(level?: string): Level { function normalizeLevel(level?: string): Level {
if (isVerbose()) return "trace"; if (isVerbose()) return "trace";
const candidate = level ?? "info"; const candidate = level ?? "info";
return ALLOWED_LEVELS.includes(candidate as Level) ? (candidate as Level) : "info"; return ALLOWED_LEVELS.includes(candidate as Level)
? (candidate as Level)
: "info";
} }
function resolveSettings(): ResolvedSettings { function resolveSettings(): ResolvedSettings {
@ -90,17 +92,15 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
type: "hidden", // no ansi formatting type: "hidden", // no ansi formatting
}); });
logger.attachTransport( logger.attachTransport((logObj: LogObj) => {
(logObj) => {
try { try {
const time = (logObj as any)?.date?.toISOString?.() ?? new Date().toISOString(); const time = logObj.date?.toISOString?.() ?? new Date().toISOString();
const line = JSON.stringify({ ...logObj, time }); const line = JSON.stringify({ ...logObj, time });
fs.appendFileSync(settings.file, line + "\n", { encoding: "utf8" }); fs.appendFileSync(settings.file, `${line}\n`, { encoding: "utf8" });
} catch { } catch {
// never block on logging failures // never block on logging failures
} }
} });
);
return logger; return logger;
} }
@ -137,7 +137,9 @@ export function toPinoLikeLogger(
): PinoLikeLogger { ): PinoLikeLogger {
const buildChild = (bindings?: Record<string, unknown>) => const buildChild = (bindings?: Record<string, unknown>) =>
toPinoLikeLogger( toPinoLikeLogger(
logger.getSubLogger({ name: bindings ? JSON.stringify(bindings) : undefined }), logger.getSubLogger({
name: bindings ? JSON.stringify(bindings) : undefined,
}),
level, level,
); );

View File

@ -3,11 +3,12 @@ import { describe, expect, it, vi } from "vitest";
const useSpy = vi.fn(); const useSpy = vi.fn();
const onSpy = vi.fn(); const onSpy = vi.fn();
const stopSpy = vi.fn(); const stopSpy = vi.fn();
const apiStub = { config: { use: useSpy } }; type ApiStub = { config: { use: (arg: unknown) => void } };
const apiStub: ApiStub = { config: { use: useSpy } };
vi.mock("grammy", () => ({ vi.mock("grammy", () => ({
Bot: class { Bot: class {
api = apiStub as any; api = apiStub;
on = onSpy; on = onSpy;
stop = stopSpy; stop = stopSpy;
constructor(public token: string) {} constructor(public token: string) {}

View File

@ -1,8 +1,7 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { Bot, InputFile, webhookCallback } from "grammy";
import { apiThrottler } from "@grammyjs/transformer-throttler"; import { apiThrottler } from "@grammyjs/transformer-throttler";
import type { ApiClientOptions } from "grammy"; import type { ApiClientOptions, Message } from "grammy";
import { Bot, InputFile, webhookCallback } from "grammy";
import { chunkText } from "../auto-reply/chunk.js"; import { chunkText } from "../auto-reply/chunk.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
@ -16,6 +15,19 @@ import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
type TelegramMessage = Message.CommonMessage;
type TelegramContext = {
message: TelegramMessage;
me?: { username?: string; token?: string };
api?: { token?: string };
getFile: () => Promise<{
getUrl?: (token?: string) => string | Promise<string>;
download: () => Promise<Uint8Array | ArrayBuffer>;
file_path?: string;
}>;
};
export type TelegramBotOptions = { export type TelegramBotOptions = {
token: string; token: string;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
@ -26,8 +38,7 @@ export type TelegramBotOptions = {
}; };
export function createTelegramBot(opts: TelegramBotOptions) { export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv = const runtime: RuntimeEnv = opts.runtime ?? {
opts.runtime ?? {
log: console.log, log: console.log,
error: console.error, error: console.error,
exit: (code: number): never => { exit: (code: number): never => {
@ -94,7 +105,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, From: isGroup ? `group:${chatId}` : `telegram:${chatId}`,
To: `telegram:${chatId}`, To: `telegram:${chatId}`,
ChatType: isGroup ? "group" : "direct", ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? msg.chat.title ?? undefined : undefined, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
SenderName: buildSenderName(msg), SenderName: buildSenderName(msg),
Surface: "telegram", Surface: "telegram",
MessageSid: String(msg.message_id), MessageSid: String(msg.message_id),
@ -164,7 +175,7 @@ async function deliverReplies(params: {
const media = await loadWebMedia(mediaUrl); const media = await loadWebMedia(mediaUrl);
const kind = mediaKindFromMime(media.contentType ?? undefined); const kind = mediaKindFromMime(media.contentType ?? undefined);
const file = new InputFile(media.buffer, media.fileName ?? "file"); const file = new InputFile(media.buffer, media.fileName ?? "file");
const caption = first ? reply.text ?? undefined : undefined; const caption = first ? (reply.text ?? undefined) : undefined;
first = false; first = false;
if (kind === "image") { if (kind === "image") {
await bot.api.sendPhoto(chatId, file, { caption }); await bot.api.sendPhoto(chatId, file, { caption });
@ -179,14 +190,16 @@ async function deliverReplies(params: {
} }
} }
function buildSenderName(msg: any) { function buildSenderName(msg: TelegramMessage) {
const name = const name =
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || [msg.from?.first_name, msg.from?.last_name]
msg.from?.username; .filter(Boolean)
.join(" ")
.trim() || msg.from?.username;
return name || undefined; return name || undefined;
} }
function hasBotMention(msg: any, botUsername: string) { function hasBotMention(msg: TelegramMessage, botUsername: string) {
const text = (msg.text ?? msg.caption ?? "").toLowerCase(); const text = (msg.text ?? msg.caption ?? "").toLowerCase();
if (text.includes(`@${botUsername}`)) return true; if (text.includes(`@${botUsername}`)) return true;
const entities = msg.entities ?? msg.caption_entities ?? []; const entities = msg.entities ?? msg.caption_entities ?? [];
@ -202,7 +215,7 @@ function hasBotMention(msg: any, botUsername: string) {
} }
async function resolveMedia( async function resolveMedia(
ctx: any, ctx: TelegramContext,
maxBytes: number, maxBytes: number,
): Promise<{ path: string; contentType?: string; placeholder: string } | null> { ): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
const msg = ctx.message; const msg = ctx.message;

View File

@ -8,7 +8,9 @@ import {
describe("telegram download", () => { describe("telegram download", () => {
it("fetches file info", async () => { it("fetches file info", async () => {
const json = vi.fn().mockResolvedValue({ ok: true, result: { file_path: "photos/1.jpg" } }); const json = vi
.fn()
.mockResolvedValue({ ok: true, result: { file_path: "photos/1.jpg" } });
vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({ vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({
ok: true, ok: true,
status: 200, status: 200,
@ -20,7 +22,10 @@ describe("telegram download", () => {
}); });
it("downloads and saves", async () => { it("downloads and saves", async () => {
const info: TelegramFileInfo = { file_id: "fid", file_path: "photos/1.jpg" }; const info: TelegramFileInfo = {
file_id: "fid",
file_path: "photos/1.jpg",
};
const arrayBuffer = async () => new Uint8Array([1, 2, 3, 4]).buffer; const arrayBuffer = async () => new Uint8Array([1, 2, 3, 4]).buffer;
vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({ vi.spyOn(global, "fetch" as never).mockResolvedValueOnce({
ok: true, ok: true,

View File

@ -1,5 +1,5 @@
import { detectMime, extensionForMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import { saveMediaBuffer, type SavedMedia } from "../media/store.js"; import { type SavedMedia, saveMediaBuffer } from "../media/store.js";
export type TelegramFileInfo = { export type TelegramFileInfo = {
file_id: string; file_id: string;

View File

@ -1,4 +1,4 @@
export { sendMessageTelegram } from "./send.js";
export { monitorTelegramProvider } from "./monitor.js";
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
export { monitorTelegramProvider } from "./monitor.js";
export { sendMessageTelegram } from "./send.js";
export { startTelegramWebhook } from "./webhook.js"; export { startTelegramWebhook } from "./webhook.js";

View File

@ -2,8 +2,18 @@ import { describe, expect, it, vi } from "vitest";
import { monitorTelegramProvider } from "./monitor.js"; import { monitorTelegramProvider } from "./monitor.js";
type MockCtx = {
message: {
chat: { id: number; type: string; title?: string };
text?: string;
caption?: string;
};
me?: { username: string };
getFile: () => Promise<unknown>;
};
// Fake bot to capture handler and API calls // Fake bot to capture handler and API calls
const handlers: Record<string, (ctx: any) => Promise<void> | void> = {}; const handlers: Record<string, (ctx: MockCtx) => Promise<void> | void> = {};
const api = { const api = {
sendMessage: vi.fn(), sendMessage: vi.fn(),
sendPhoto: vi.fn(), sendPhoto: vi.fn(),
@ -16,7 +26,7 @@ const api = {
vi.mock("./bot.js", () => ({ vi.mock("./bot.js", () => ({
createTelegramBot: () => { createTelegramBot: () => {
handlers.message = async (ctx: any) => { handlers.message = async (ctx: MockCtx) => {
const chatId = ctx.message.chat.id; const chatId = ctx.message.chat.id;
const isGroup = ctx.message.chat.type !== "private"; const isGroup = ctx.message.chat.type !== "private";
const text = ctx.message.text ?? ctx.message.caption ?? ""; const text = ctx.message.text ?? ctx.message.caption ?? "";
@ -36,12 +46,16 @@ vi.mock("./bot.js", () => ({
})); }));
vi.mock("../auto-reply/reply.js", () => ({ vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: async (ctx: any) => ({ text: `echo:${ctx.Body}` }), getReplyFromConfig: async (ctx: { Body?: string }) => ({
text: `echo:${ctx.Body}`,
}),
})); }));
describe("monitorTelegramProvider (grammY)", () => { describe("monitorTelegramProvider (grammY)", () => {
it("processes a DM and sends reply", async () => { it("processes a DM and sends reply", async () => {
Object.values(api).forEach((fn) => fn?.mockReset?.()); Object.values(api).forEach((fn) => {
fn?.mockReset?.();
});
await monitorTelegramProvider({ token: "tok" }); await monitorTelegramProvider({ token: "tok" });
expect(handlers.message).toBeDefined(); expect(handlers.message).toBeDefined();
await handlers.message?.({ await handlers.message?.({
@ -51,7 +65,7 @@ describe("monitorTelegramProvider (grammY)", () => {
text: "hi", text: "hi",
}, },
me: { username: "mybot" }, me: { username: "mybot" },
getFile: vi.fn(), getFile: vi.fn(async () => ({})),
}); });
expect(api.sendMessage).toHaveBeenCalledWith(123, "echo:hi", { expect(api.sendMessage).toHaveBeenCalledWith(123, "echo:hi", {
parse_mode: "Markdown", parse_mode: "Markdown",
@ -59,7 +73,9 @@ describe("monitorTelegramProvider (grammY)", () => {
}); });
it("requires mention in groups by default", async () => { it("requires mention in groups by default", async () => {
Object.values(api).forEach((fn) => fn?.mockReset?.()); Object.values(api).forEach((fn) => {
fn?.mockReset?.();
});
await monitorTelegramProvider({ token: "tok" }); await monitorTelegramProvider({ token: "tok" });
await handlers.message?.({ await handlers.message?.({
message: { message: {
@ -68,7 +84,7 @@ describe("monitorTelegramProvider (grammY)", () => {
text: "hello all", text: "hello all",
}, },
me: { username: "mybot" }, me: { username: "mybot" },
getFile: vi.fn(), getFile: vi.fn(async () => ({})),
}); });
expect(api.sendMessage).not.toHaveBeenCalled(); expect(api.sendMessage).not.toHaveBeenCalled();
}); });

View File

@ -1,8 +1,6 @@
import { Bot } from "grammy";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; import { createTelegramBot } from "./bot.js";
import { makeProxyFetch } from "./proxy.js"; import { makeProxyFetch } from "./proxy.js";
import { startTelegramWebhook } from "./webhook.js"; import { startTelegramWebhook } from "./webhook.js";
@ -21,13 +19,15 @@ 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 = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim();
if (!token) { if (!token) {
throw new Error("TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram relay"); throw new Error(
"TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram relay",
);
} }
const proxyFetch = const proxyFetch =
opts.proxyFetch ?? opts.proxyFetch ??
(loadConfig().telegram?.proxy (loadConfig().telegram?.proxy
? makeProxyFetch(loadConfig().telegram!.proxy as string) ? makeProxyFetch(loadConfig().telegram?.proxy as string)
: undefined); : undefined);
const bot = createTelegramBot({ const bot = createTelegramBot({

View File

@ -3,5 +3,5 @@ import { ProxyAgent } from "undici";
export function makeProxyFetch(proxyUrl: string): typeof fetch { export function makeProxyFetch(proxyUrl: string): typeof fetch {
const agent = new ProxyAgent(proxyUrl); const agent = new ProxyAgent(proxyUrl);
return (input: RequestInfo | URL, init?: RequestInit) => return (input: RequestInfo | URL, init?: RequestInit) =>
fetch(input, { ...(init as any), dispatcher: agent } as RequestInit); fetch(input, { ...(init ?? {}), dispatcher: agent });
} }

View File

@ -1,4 +1,4 @@
import { beforeEach, afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { sendMessageTelegram } from "./send.js"; import { sendMessageTelegram } from "./send.js";
@ -60,9 +60,9 @@ describe("sendMessageTelegram", () => {
it("throws on api error", async () => { it("throws on api error", async () => {
apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token")); apiMock.sendMessage.mockRejectedValueOnce(new Error("bad token"));
await expect(sendMessageTelegram("1", "hi", { api: apiMock as never })).rejects.toThrow( await expect(
/bad token/i, sendMessageTelegram("1", "hi", { api: apiMock as never }),
); ).rejects.toThrow(/bad token/i);
}); });
it("sends media via appropriate method", async () => { it("sends media via appropriate method", async () => {

View File

@ -19,7 +19,9 @@ type TelegramSendResult = {
function resolveToken(explicit?: string): string { function resolveToken(explicit?: string): string {
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN; const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
if (!token) { if (!token) {
throw new Error("TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)"); throw new Error(
"TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)",
);
} }
return token.trim(); return token.trim();
} }
@ -39,7 +41,7 @@ export async function sendMessageTelegram(
const token = resolveToken(opts.token); const token = resolveToken(opts.token);
const chatId = normalizeChatId(to); const chatId = normalizeChatId(to);
const bot = opts.api ? null : new Bot(token); const bot = opts.api ? null : new Bot(token);
const api = opts.api ?? bot!.api; const api = opts.api ?? bot?.api;
const mediaUrl = opts.mediaUrl?.trim(); const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) { if (mediaUrl) {
@ -50,7 +52,11 @@ export async function sendMessageTelegram(
media.fileName ?? inferFilename(kind) ?? "file", media.fileName ?? inferFilename(kind) ?? "file",
); );
const caption = text?.trim() || undefined; const caption = text?.trim() || undefined;
let result; let result:
| Awaited<ReturnType<typeof api.sendPhoto>>
| Awaited<ReturnType<typeof api.sendVideo>>
| Awaited<ReturnType<typeof api.sendAudio>>
| Awaited<ReturnType<typeof api.sendDocument>>;
if (kind === "image") { if (kind === "image") {
result = await api.sendPhoto(chatId, file, { caption }); result = await api.sendPhoto(chatId, file, { caption });
} else if (kind === "video") { } else if (kind === "video") {

View File

@ -2,10 +2,15 @@ import { describe, expect, it, vi } from "vitest";
import { startTelegramWebhook } from "./webhook.js"; import { startTelegramWebhook } from "./webhook.js";
const handlerSpy = vi.fn((req: any, res: any) => { const handlerSpy = vi.fn(
(
_req: unknown,
res: { writeHead: (status: number) => void; end: (body?: string) => void },
) => {
res.writeHead(200); res.writeHead(200);
res.end("ok"); res.end("ok");
}); },
);
const setWebhookSpy = vi.fn(); const setWebhookSpy = vi.fn();
const stopSpy = vi.fn(); const stopSpy = vi.fn();