chore(ci): fix lint and swiftformat failures
parent
68d19d4717
commit
f65702a8a8
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -267,7 +266,7 @@ struct VoiceWakeSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.date?.toISOString?.() ?? new Date().toISOString();
|
||||||
const time = (logObj as any)?.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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
|
|
||||||
|
|
@ -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,14 +38,13 @@ 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 => {
|
throw new Error(`exit ${code}`);
|
||||||
throw new Error(`exit ${code}`);
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
const client: ApiClientOptions | undefined = opts.proxyFetch
|
const client: ApiClientOptions | undefined = opts.proxyFetch
|
||||||
? { fetch: opts.proxyFetch as unknown as ApiClientOptions["fetch"] }
|
? { fetch: opts.proxyFetch as unknown as ApiClientOptions["fetch"] }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
res.writeHead(200);
|
(
|
||||||
res.end("ok");
|
_req: unknown,
|
||||||
});
|
res: { writeHead: (status: number) => void; end: (body?: string) => void },
|
||||||
|
) => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("ok");
|
||||||
|
},
|
||||||
|
);
|
||||||
const setWebhookSpy = vi.fn();
|
const setWebhookSpy = vi.fn();
|
||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue