From 1f959a188ff5849683eaf45f232a1ba3c84867c7 Mon Sep 17 00:00:00 2001 From: Arne Moor Date: Sat, 6 Dec 2025 18:45:25 +0100 Subject: [PATCH] chore: apply biome formatting Applied formatting fixes to 21 files: - Fixed import ordering - Normalized indentation - Wrapped long lines No logic changes, formatting only. --- .env.example | 7 + .gitignore | 1 + src/agents/claude.ts | 3 +- src/agents/gemini.ts | 3 +- src/agents/opencode.ts | 3 +- src/agents/pi.ts | 3 +- src/auto-reply/command-reply.test.ts | 39 ++ src/auto-reply/command-reply.ts | 16 + src/auto-reply/reply.ts | 33 +- src/cli/multi-relay.test.ts | 791 +++++++++++++-------------- src/cli/multi-relay.ts | 148 ++--- src/cli/program.ts | 31 +- src/commands/agent.ts | 18 +- src/commands/identity.ts | 20 +- src/commands/send.ts | 4 +- src/config/sessions.test.ts | 12 +- src/config/sessions.ts | 12 +- src/identity/index.ts | 4 +- src/identity/normalize.test.ts | 15 +- src/telegram/client.test.ts | 3 +- src/telegram/download.test.ts | 28 +- src/telegram/download.ts | 40 +- src/telegram/inbound.test.ts | 69 ++- src/telegram/inbound.ts | 14 +- src/telegram/monitor.test.ts | 34 +- src/telegram/monitor.ts | 7 +- src/telegram/provider.test.ts | 4 +- src/telegram/utils.ts | 5 +- src/twilio/webhook.ts | 4 +- src/utils.ts | 49 +- src/web/logout.test.ts | 6 +- src/web/session.ts | 166 +++--- 32 files changed, 895 insertions(+), 697 deletions(-) diff --git a/.env.example b/.env.example index 29652fe46..313711f44 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_here # Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp: TWILIO_WHATSAPP_FROM=whatsapp:+17343367101 + +# Optional: override config directory when HOME is read-only +WARELAY_CONFIG_DIR=/tmp/clawdis-config + +# Optional: override temp directories for downloads (defaults to ~/.clawdis/telegram-temp) +TELEGRAM_TEMP_DIR=/tmp/warelay-telegram-temp +WARELAY_TEMP_DIR=/tmp/warelay-temp diff --git a/.gitignore b/.gitignore index 0e2ef4328..45b087669 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist pnpm-lock.yaml coverage .pnpm-store +.clawdis diff --git a/src/agents/claude.ts b/src/agents/claude.ts index 3db224221..08fb3d8d2 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -55,7 +55,8 @@ export const claudeSpec: AgentSpec = { beforeBody.push("-p"); } - const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); + const shouldPrependIdentity = + ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); const bodyWithIdentity = shouldPrependIdentity ? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body] .filter(Boolean) diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts index 10ab48289..0ccfae7d3 100644 --- a/src/agents/gemini.ts +++ b/src/agents/gemini.ts @@ -40,7 +40,8 @@ export const geminiSpec: AgentSpec = { } } - const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); + const shouldPrependIdentity = + ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); const bodyWithIdentity = shouldPrependIdentity && body ? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body] diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index e33708301..3f1338373 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -41,7 +41,8 @@ export const opencodeSpec: AgentSpec = { // Identity prefix // Opencode streams text tokens; we still seed an identity so the agent // keeps context on first turn. - const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); + const shouldPrependIdentity = + ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); const bodyWithIdentity = shouldPrependIdentity && body ? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body] diff --git a/src/agents/pi.ts b/src/agents/pi.ts index 629e1ee18..d77c0a800 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -158,7 +158,8 @@ export const piSpec: AgentSpec = { } // Session defaults // Identity prefix optional; Pi usually doesn't need it, but allow injection - const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); + const shouldPrependIdentity = + ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); if (shouldPrependIdentity && argv[ctx.bodyIndex]) { const existingBody = argv[ctx.bodyIndex]; argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody] diff --git a/src/auto-reply/command-reply.test.ts b/src/auto-reply/command-reply.test.ts index 2c6d09267..ba35a0388 100644 --- a/src/auto-reply/command-reply.test.ts +++ b/src/auto-reply/command-reply.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import * as agents from "../agents/index.js"; +import { CLAUDE_IDENTITY_PREFIX } from "./claude.js"; import { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js"; import type { ReplyPayload } from "./types.js"; @@ -92,6 +94,43 @@ describe("runCommandReply", () => { expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)"); }); + it("adds identity prefix when Claude buildArgs omits it", async () => { + const captures: ReplyPayload[] = []; + const runner = makeRunner({ stdout: "ok" }, captures); + const getAgentSpy = vi.spyOn(agents, "getAgentSpec"); + getAgentSpy.mockReturnValue({ + kind: "claude", + isInvocation: () => true, + buildArgs: ({ argv }) => argv, // Missing identity prefix on purpose + parseOutput: () => ({ texts: ["ok"] }), + }); + + try { + await runCommandReply({ + reply: { + mode: "command", + command: ["claude", "{{Body}}"], + agent: { kind: "claude" }, + }, + templatingCtx: noopTemplateCtx, + sendSystemOnce: false, + isNewSession: true, + isFirstTurnInSession: true, + systemSent: false, + timeoutMs: 1000, + timeoutSeconds: 1, + commandRunner: runner, + enqueue: enqueueImmediate, + }); + } finally { + getAgentSpy.mockRestore(); + } + + const finalArgv = captures[0].argv as string[]; + const body = finalArgv.at(-1) ?? ""; + expect(body.startsWith(CLAUDE_IDENTITY_PREFIX)).toBe(true); + }); + it("omits identity prefix on resumed session when sendSystemOnce=true", async () => { const captures: ReplyPayload[] = []; const runner = makeRunner({ stdout: "ok" }, captures); diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 5dd77ff93..d94e36aa8 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -11,6 +11,7 @@ import { splitMediaFromOutput } from "../media/parse.js"; import { enqueueCommand } from "../process/command-queue.js"; import type { runCommandWithTimeout } from "../process/exec.js"; import { runPiRpc } from "../process/tau-rpc.js"; +import { CLAUDE_IDENTITY_PREFIX } from "./claude.js"; import { applyTemplate, type TemplateContext } from "./templating.js"; import { formatToolAggregate, @@ -399,6 +400,21 @@ export async function runCommandReply( }) : argv; + // Safety net: ensure Claude invocations include the identity prefix on turns where it should apply. + if ( + agentKind === "claude" && + shouldApplyAgent && + (!sendSystemOnce || !systemSent || isNewSession) + ) { + const idx = finalArgv.length > 0 ? finalArgv.length - 1 : 0; + const body = finalArgv[idx]; + if (typeof body === "string" && !body.includes(CLAUDE_IDENTITY_PREFIX)) { + finalArgv[idx] = [CLAUDE_IDENTITY_PREFIX, body] + .filter(Boolean) + .join("\n\n"); + } + } + logVerbose( `Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`, ); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 056a062ad..12a35dee2 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -17,6 +17,11 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { TwilioRequester } from "../twilio/types.js"; import { sendTypingIndicator } from "../twilio/typing.js"; +import { + normalizeAllowFromEntry, + normalizeE164, + TELEGRAM_PREFIX, +} from "../utils.js"; import { chunkText } from "./chunk.js"; import { runCommandReply } from "./command-reply.js"; import { @@ -409,10 +414,26 @@ export async function getReplyFromConfig( return { text: ack }; } - // Optional allowlist by origin number (E.164 without whatsapp: prefix) + // Optional allowlist by origin number (E.164 for WhatsApp, telegram: prefix for Telegram) const allowFrom = cfg.inbound?.allowFrom; - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); - const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); + const rawFrom = ctx.From ?? ""; + const rawTo = ctx.To ?? ""; + const from = rawFrom.replace(/^whatsapp:/, ""); + const to = rawTo.replace(/^whatsapp:/, ""); + const isTelegramSender = rawFrom.startsWith(TELEGRAM_PREFIX); + const normalizedFrom = isTelegramSender + ? normalizeAllowFromEntry(rawFrom, "telegram") + : normalizeE164(from); + const normalizedAllowFrom = + Array.isArray(allowFrom) && allowFrom.length > 0 + ? allowFrom.map((entry) => { + if (entry === "*") return "*"; + if (isTelegramSender) { + return normalizeAllowFromEntry(entry, "telegram"); + } + return normalizeE164(entry.replace(/^whatsapp:/, "")); + }) + : []; const isSamePhone = from && to && from === to; const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined); const rawBodyNormalized = (sessionCtx.BodyStripped ?? sessionCtx.Body ?? "") @@ -428,9 +449,11 @@ export async function getReplyFromConfig( logVerbose(`Allowing same-phone mode: from === to (${from})`); } else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { // Support "*" as wildcard to allow all senders - if (!allowFrom.includes("*") && !allowFrom.includes(from)) { + const allowAll = normalizedAllowFrom.includes("*"); + if (!allowAll && !normalizedAllowFrom.includes(normalizedFrom)) { + const displayFrom = normalizedFrom || rawFrom || ""; logVerbose( - `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, + `Skipping auto-reply: sender ${displayFrom} not in allowFrom list`, ); cleanupTyping(); return undefined; diff --git a/src/cli/multi-relay.test.ts b/src/cli/multi-relay.test.ts index c9d655101..51055edd1 100644 --- a/src/cli/multi-relay.test.ts +++ b/src/cli/multi-relay.test.ts @@ -1,418 +1,407 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type { Provider } from "../utils.js"; -import type { RuntimeEnv } from "../runtime.js"; import type { WarelayConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { Provider } from "../utils.js"; import type { CliDeps } from "./deps.js"; import { runMultiProviderRelay } from "./multi-relay.js"; // Mock the monitor modules vi.mock("../telegram/monitor.js", () => ({ - monitorTelegramProvider: vi.fn(), + monitorTelegramProvider: vi.fn(), })); vi.mock("../web/auto-reply.js", () => ({ - monitorWebProvider: vi.fn(), + monitorWebProvider: vi.fn(), })); vi.mock("../twilio/monitor.js", () => ({ - monitorTwilio: vi.fn(), + monitorTwilio: vi.fn(), })); describe("runMultiProviderRelay", () => { - let mockRuntime: RuntimeEnv; - let mockConfig: WarelayConfig; - let mockDeps: CliDeps; - let originalProcessOn: typeof process.on; - let originalProcessOff: typeof process.off; - let sigintHandler: ((...args: unknown[]) => void) | undefined; - - beforeEach(() => { - mockRuntime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - mockConfig = { - telegram: { allowFrom: [] }, - }; - - mockDeps = {} as CliDeps; - - // Capture SIGINT handler - originalProcessOn = process.on; - originalProcessOff = process.off; - process.on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - if (event === "SIGINT") { - sigintHandler = handler; - } - return process; - }) as typeof process.on; - process.off = vi.fn() as typeof process.off; - - vi.clearAllMocks(); - }); - - afterEach(() => { - process.on = originalProcessOn; - process.off = originalProcessOff; - sigintHandler = undefined; - }); - - test("starts telegram provider and logs startup message", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup message - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.log).toHaveBeenCalledWith( - "šŸ“” Starting 1 provider(s): telegram", - ); - expect(monitorTelegramProvider).toHaveBeenCalledWith( - true, - mockRuntime, - expect.any(AbortSignal), - true, - ); - }); - - test("starts web provider with verbose and webTuning options", async () => { - const { monitorWebProvider } = await import("../web/auto-reply.js"); - vi.mocked(monitorWebProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["web"]; - const webTuning = { heartbeatSeconds: 60 }; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - webTuning, - runtime: mockRuntime, - }); - - // Wait for startup message - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.log).toHaveBeenCalledWith( - "šŸ“” Starting 1 provider(s): web", - ); - expect(monitorWebProvider).toHaveBeenCalledWith( - true, - undefined, - true, - undefined, - mockRuntime, - expect.any(AbortSignal), - { ...webTuning, suppressStartMessage: true }, - ); - }); - - test("starts twilio provider with custom interval and lookback", async () => { - const { monitorTwilio } = await import("../twilio/monitor.js"); - vi.mocked(monitorTwilio).mockResolvedValue(undefined); - - const providers: Provider[] = ["twilio"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: false, - twilioInterval: 30, - twilioLookback: 10, - runtime: mockRuntime, - }); - - // Wait for startup message - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.log).toHaveBeenCalledWith( - "šŸ“” Starting 1 provider(s): twilio", - ); - expect(monitorTwilio).toHaveBeenCalledWith(30, 10); - }); - - test("starts multiple providers concurrently", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - const { monitorWebProvider } = await import("../web/auto-reply.js"); - - vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); - vi.mocked(monitorWebProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["telegram", "web"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup message - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.log).toHaveBeenCalledWith( - "šŸ“” Starting 2 provider(s): telegram, web", - ); - expect(monitorTelegramProvider).toHaveBeenCalled(); - expect(monitorWebProvider).toHaveBeenCalled(); - }); - - test("shows startup complete message after 1.5s", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - vi.mocked(monitorTelegramProvider).mockImplementation( - () => new Promise(() => {}), // Never resolves - ); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup complete message (1.5s + buffer) - await new Promise((resolve) => setTimeout(resolve, 1600)); - - expect(mockRuntime.log).toHaveBeenCalledWith( - "āœ… All 1 provider(s) active. Listening for messages... (Ctrl+C to stop)", - ); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - // Wait for abort to complete - await Promise.race([promise, new Promise((resolve) => setTimeout(resolve, 500))]); - }); - - test("handles SIGINT gracefully", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - let abortSignal: AbortSignal | undefined; - vi.mocked(monitorTelegramProvider).mockImplementation( - async (_verbose, _runtime, signal) => { - abortSignal = signal; - // Simulate waiting for abort - return new Promise((resolve) => { - signal?.addEventListener("abort", () => resolve(undefined)); - }); - }, - ); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for monitor to start - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Trigger SIGINT - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.log).toHaveBeenCalledWith( - "\nā¹ Stopping all providers...", - ); - expect(mockRuntime.log).toHaveBeenCalledWith("āœ… All providers stopped"); - expect(abortSignal?.aborted).toBe(true); - }); - - test("handles provider errors without crashing other providers", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - const { monitorWebProvider } = await import("../web/auto-reply.js"); - - vi.mocked(monitorTelegramProvider).mockRejectedValue( - new Error("Telegram connection failed"), - ); - vi.mocked(monitorWebProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["telegram", "web"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup and error handling - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(mockRuntime.error).toHaveBeenCalledWith( - "āŒ telegram error: Error: Telegram connection failed", - ); - // Web provider should still have been started - expect(monitorWebProvider).toHaveBeenCalled(); - }); - - test("removes SIGINT handler after completion", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(process.off).toHaveBeenCalledWith("SIGINT", sigintHandler); - }); - - test("passes suppressStartMessage=true to telegram monitor", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait for startup - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(monitorTelegramProvider).toHaveBeenCalledWith( - true, - mockRuntime, - expect.any(AbortSignal), - true, // suppressStartMessage - ); - }); - - test("does not log startup complete if aborted before timeout", async () => { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - vi.mocked(monitorTelegramProvider).mockImplementation( - async (_verbose, _runtime, signal) => { - return new Promise((resolve) => { - signal?.addEventListener("abort", () => resolve(undefined)); - }); - }, - ); - - const providers: Provider[] = ["telegram"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: true, - runtime: mockRuntime, - }); - - // Wait briefly, then abort before 1.5s timeout - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - // Should not have logged startup complete message - const startupCompleteCalls = vi - .mocked(mockRuntime.log) - .mock.calls.filter((call) => - String(call[0]).includes("All 1 provider(s) active"), - ); - expect(startupCompleteCalls.length).toBe(0); - }); - - test("uses default values for twilio interval and lookback", async () => { - const { monitorTwilio } = await import("../twilio/monitor.js"); - vi.mocked(monitorTwilio).mockResolvedValue(undefined); - - const providers: Provider[] = ["twilio"]; - - const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { - verbose: false, - runtime: mockRuntime, - }); - - // Wait for startup - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Abort the relay - if (sigintHandler) { - sigintHandler(); - } - - await promise; - - expect(monitorTwilio).toHaveBeenCalledWith(10, 5); // defaults - }); + let mockRuntime: RuntimeEnv; + let mockConfig: WarelayConfig; + let mockDeps: CliDeps; + let originalProcessOn: typeof process.on; + let originalProcessOff: typeof process.off; + let sigintHandler: ((...args: unknown[]) => void) | undefined; + + beforeEach(() => { + mockRuntime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + mockConfig = { + telegram: { allowFrom: [] }, + }; + + mockDeps = {} as CliDeps; + + // Capture SIGINT handler + originalProcessOn = process.on; + originalProcessOff = process.off; + process.on = vi.fn( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === "SIGINT") { + sigintHandler = handler; + } + return process; + }, + ) as typeof process.on; + process.off = vi.fn() as typeof process.off; + + vi.clearAllMocks(); + }); + + afterEach(() => { + process.on = originalProcessOn; + process.off = originalProcessOff; + sigintHandler = undefined; + }); + + test("starts telegram provider and logs startup message", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup message + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.log).toHaveBeenCalledWith( + "šŸ“” Starting 1 provider(s): telegram", + ); + expect(monitorTelegramProvider).toHaveBeenCalledWith( + true, + mockRuntime, + expect.any(AbortSignal), + true, + ); + }); + + test("starts web provider with verbose and webTuning options", async () => { + const { monitorWebProvider } = await import("../web/auto-reply.js"); + vi.mocked(monitorWebProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["web"]; + const webTuning = { heartbeatSeconds: 60 }; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + webTuning, + runtime: mockRuntime, + }); + + // Wait for startup message + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.log).toHaveBeenCalledWith( + "šŸ“” Starting 1 provider(s): web", + ); + expect(monitorWebProvider).toHaveBeenCalledWith( + true, + undefined, + true, + undefined, + mockRuntime, + expect.any(AbortSignal), + { ...webTuning, suppressStartMessage: true }, + ); + }); + + test("starts twilio provider with custom interval and lookback", async () => { + const { monitorTwilio } = await import("../twilio/monitor.js"); + vi.mocked(monitorTwilio).mockResolvedValue(undefined); + + const providers: Provider[] = ["twilio"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: false, + twilioInterval: 30, + twilioLookback: 10, + runtime: mockRuntime, + }); + + // Wait for startup message + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.log).toHaveBeenCalledWith( + "šŸ“” Starting 1 provider(s): twilio", + ); + expect(monitorTwilio).toHaveBeenCalledWith(30, 10); + }); + + test("starts multiple providers concurrently", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + const { monitorWebProvider } = await import("../web/auto-reply.js"); + + vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); + vi.mocked(monitorWebProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["telegram", "web"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup message + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.log).toHaveBeenCalledWith( + "šŸ“” Starting 2 provider(s): telegram, web", + ); + expect(monitorTelegramProvider).toHaveBeenCalled(); + expect(monitorWebProvider).toHaveBeenCalled(); + }); + + test("shows startup complete message after 1.5s", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + vi.mocked(monitorTelegramProvider).mockImplementation( + () => new Promise(() => {}), // Never resolves + ); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup complete message (1.5s + buffer) + await new Promise((resolve) => setTimeout(resolve, 1600)); + + expect(mockRuntime.log).toHaveBeenCalledWith( + "āœ… All 1 provider(s) active. Listening for messages... (Ctrl+C to stop)", + ); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + // Wait for abort to complete + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, 500)), + ]); + }); + + test("handles SIGINT gracefully", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + let abortSignal: AbortSignal | undefined; + vi.mocked(monitorTelegramProvider).mockImplementation( + async (_verbose, _runtime, signal) => { + abortSignal = signal; + // Simulate waiting for abort + return new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(undefined)); + }); + }, + ); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for monitor to start + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Trigger SIGINT + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.log).toHaveBeenCalledWith( + "\nā¹ Stopping all providers...", + ); + expect(mockRuntime.log).toHaveBeenCalledWith("āœ… All providers stopped"); + expect(abortSignal?.aborted).toBe(true); + }); + + test("handles provider errors without crashing other providers", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + const { monitorWebProvider } = await import("../web/auto-reply.js"); + + vi.mocked(monitorTelegramProvider).mockRejectedValue( + new Error("Telegram connection failed"), + ); + vi.mocked(monitorWebProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["telegram", "web"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup and error handling + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(mockRuntime.error).toHaveBeenCalledWith( + "āŒ telegram error: Error: Telegram connection failed", + ); + // Web provider should still have been started + expect(monitorWebProvider).toHaveBeenCalled(); + }); + + test("removes SIGINT handler after completion", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(process.off).toHaveBeenCalledWith("SIGINT", sigintHandler); + }); + + test("passes suppressStartMessage=true to telegram monitor", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait for startup + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(monitorTelegramProvider).toHaveBeenCalledWith( + true, + mockRuntime, + expect.any(AbortSignal), + true, // suppressStartMessage + ); + }); + + test("does not log startup complete if aborted before timeout", async () => { + const { monitorTelegramProvider } = await import("../telegram/monitor.js"); + vi.mocked(monitorTelegramProvider).mockImplementation( + async (_verbose, _runtime, signal) => { + return new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(undefined)); + }); + }, + ); + + const providers: Provider[] = ["telegram"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: true, + runtime: mockRuntime, + }); + + // Wait briefly, then abort before 1.5s timeout + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + // Should not have logged startup complete message + const startupCompleteCalls = vi + .mocked(mockRuntime.log) + .mock.calls.filter((call) => + String(call[0]).includes("All 1 provider(s) active"), + ); + expect(startupCompleteCalls.length).toBe(0); + }); + + test("uses default values for twilio interval and lookback", async () => { + const { monitorTwilio } = await import("../twilio/monitor.js"); + vi.mocked(monitorTwilio).mockResolvedValue(undefined); + + const providers: Provider[] = ["twilio"]; + + const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, { + verbose: false, + runtime: mockRuntime, + }); + + // Wait for startup + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Abort the relay + if (sigintHandler) { + sigintHandler(); + } + + await promise; + + expect(monitorTwilio).toHaveBeenCalledWith(10, 5); // defaults + }); }); diff --git a/src/cli/multi-relay.ts b/src/cli/multi-relay.ts index f96fee7fa..538252fe6 100644 --- a/src/cli/multi-relay.ts +++ b/src/cli/multi-relay.ts @@ -1,5 +1,5 @@ -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { WarelayConfig } from "../config/config.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; import type { WebMonitorTuning } from "../web/auto-reply.js"; import type { CliDeps } from "./deps.js"; @@ -9,87 +9,87 @@ import type { CliDeps } from "./deps.js"; * Handles graceful shutdown and per-provider error recovery. */ export async function runMultiProviderRelay( - providers: Provider[], - config: WarelayConfig, - deps: CliDeps, - opts: { - verbose?: boolean; - webTuning?: WebMonitorTuning; - twilioInterval?: number; - twilioLookback?: number; - runtime?: RuntimeEnv; - }, + providers: Provider[], + config: WarelayConfig, + deps: CliDeps, + opts: { + verbose?: boolean; + webTuning?: WebMonitorTuning; + twilioInterval?: number; + twilioLookback?: number; + runtime?: RuntimeEnv; + }, ): Promise { - const runtime = opts.runtime ?? defaultRuntime; - const abortController = new AbortController(); - const { signal } = abortController; + const runtime = opts.runtime ?? defaultRuntime; + const abortController = new AbortController(); + const { signal } = abortController; - // Setup Ctrl+C handler - const sigintHandler = () => { - runtime.log("\nā¹ Stopping all providers..."); - abortController.abort(); - }; - process.on("SIGINT", sigintHandler); + // Setup Ctrl+C handler + const sigintHandler = () => { + runtime.log("\nā¹ Stopping all providers..."); + abortController.abort(); + }; + process.on("SIGINT", sigintHandler); - runtime.log( - `šŸ“” Starting ${providers.length} provider(s): ${providers.join(", ")}`, - ); + runtime.log( + `šŸ“” Starting ${providers.length} provider(s): ${providers.join(", ")}`, + ); - let startupComplete = false; + let startupComplete = false; - // Spawn monitors concurrently - const monitorPromises = providers.map(async (provider) => { - try { - if (provider === "telegram") { - const { monitorTelegramProvider } = await import( - "../telegram/monitor.js" - ); - await monitorTelegramProvider( - Boolean(opts.verbose), - runtime, - signal, - true, // suppressStartMessage - ); - } else if (provider === "web") { - const { monitorWebProvider } = await import("../web/auto-reply.js"); - await monitorWebProvider( - Boolean(opts.verbose), - undefined, - true, - undefined, - runtime, - signal, - { ...opts.webTuning, suppressStartMessage: true }, - ); - } else if (provider === "twilio") { - const { monitorTwilio } = await import("../twilio/monitor.js"); - const intervalSeconds = opts.twilioInterval ?? 10; - const lookbackMinutes = opts.twilioLookback ?? 5; + // Spawn monitors concurrently + const monitorPromises = providers.map(async (provider) => { + try { + if (provider === "telegram") { + const { monitorTelegramProvider } = await import( + "../telegram/monitor.js" + ); + await monitorTelegramProvider( + Boolean(opts.verbose), + runtime, + signal, + true, // suppressStartMessage + ); + } else if (provider === "web") { + const { monitorWebProvider } = await import("../web/auto-reply.js"); + await monitorWebProvider( + Boolean(opts.verbose), + undefined, + true, + undefined, + runtime, + signal, + { ...opts.webTuning, suppressStartMessage: true }, + ); + } else if (provider === "twilio") { + const { monitorTwilio } = await import("../twilio/monitor.js"); + const intervalSeconds = opts.twilioInterval ?? 10; + const lookbackMinutes = opts.twilioLookback ?? 5; - await monitorTwilio(intervalSeconds, lookbackMinutes); - } - } catch (err) { - if (signal.aborted) return; // Graceful shutdown - runtime.error(`āŒ ${provider} error: ${String(err)}`); - // Continue - don't crash other providers - } - }); + await monitorTwilio(intervalSeconds, lookbackMinutes); + } + } catch (err) { + if (signal.aborted) return; // Graceful shutdown + runtime.error(`āŒ ${provider} error: ${String(err)}`); + // Continue - don't crash other providers + } + }); - // Wait a brief moment for all providers to initialize, then show summary - setTimeout(() => { - if (!startupComplete && !signal.aborted) { - startupComplete = true; - runtime.log( - `āœ… All ${providers.length} provider(s) active. Listening for messages... (Ctrl+C to stop)`, - ); - } - }, 1500); + // Wait a brief moment for all providers to initialize, then show summary + setTimeout(() => { + if (!startupComplete && !signal.aborted) { + startupComplete = true; + runtime.log( + `āœ… All ${providers.length} provider(s) active. Listening for messages... (Ctrl+C to stop)`, + ); + } + }, 1500); - // Wait for all monitors (or abort) - await Promise.allSettled(monitorPromises); + // Wait for all monitors (or abort) + await Promise.allSettled(monitorPromises); - // Remove SIGINT handler - process.off("SIGINT", sigintHandler); + // Remove SIGINT handler + process.off("SIGINT", sigintHandler); - runtime.log("āœ… All providers stopped"); + runtime.log("āœ… All providers stopped"); } diff --git a/src/cli/program.ts b/src/cli/program.ts index be1f45f81..8570ea93e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -124,7 +124,9 @@ export function buildProgram() { program .command("login") - .description("Link your personal WhatsApp via QR (web provider) or Telegram") + .description( + "Link your personal WhatsApp via QR (web provider) or Telegram", + ) .option("--provider ", "Provider: web | telegram", "web") .option("--verbose", "Verbose connection logs", false) .action(async (opts) => { @@ -141,7 +143,9 @@ export function buildProgram() { } await loginWeb(Boolean(opts.verbose)); } catch (err) { - defaultRuntime.error(danger(`${provider} login failed: ${String(err)}`)); + defaultRuntime.error( + danger(`${provider} login failed: ${String(err)}`), + ); defaultRuntime.exit(1); } }); @@ -192,7 +196,11 @@ export function buildProgram() { "20", ) .option("-p, --poll ", "Polling interval while waiting", "2") - .option("--provider ", "Provider: twilio | web | telegram", "twilio") + .option( + "--provider ", + "Provider: twilio | web | telegram", + "twilio", + ) .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -383,7 +391,10 @@ Examples: .command("relay") .description("Auto-reply to inbound messages (auto-selects web or twilio)") .option("--provider ", "auto | web | twilio | telegram", "auto") - .option("--providers ", "Comma-separated list: web,telegram,twilio") + .option( + "--providers ", + "Comma-separated list: web,telegram,twilio", + ) .option("-i, --interval ", "Polling interval for twilio mode", "5") .option( "-l, --lookback ", @@ -538,7 +549,9 @@ Examples: // Single-provider relay logic const providerPref = String(opts.provider ?? "auto"); if (!["auto", "web", "twilio", "telegram"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto, web, twilio, or telegram"); + defaultRuntime.error( + "--provider must be auto, web, twilio, or telegram", + ); defaultRuntime.exit(1); } @@ -825,7 +838,9 @@ Examples: // Identity management commands const identity = program .command("identity") - .description("Manage cross-provider identity mappings for shared Claude sessions"); + .description( + "Manage cross-provider identity mappings for shared Claude sessions", + ); identity .command("link") @@ -893,7 +908,9 @@ Examples: identity .command("unlink ") - .description("Unlink an identity mapping (providers will have separate sessions)") + .description( + "Unlink an identity mapping (providers will have separate sessions)", + ) .addHelpText( "after", ` diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 2b041414d..6539106c9 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -22,12 +22,14 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { readEnv } from "../env.js"; -import { ensureTwilioEnv } from "../env.js"; +import { ensureTwilioEnv, readEnv } from "../env.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { pickProvider } from "../provider-web.js"; +import type { + ProviderMedia, + TelegramProviderConfig, +} from "../providers/base/types.js"; import { createInitializedProvider } from "../providers/factory.js"; -import type { TelegramProviderConfig, ProviderMedia } from "../providers/base/types.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; import { sendViaIpc } from "../web/ipc.js"; @@ -442,8 +444,10 @@ export async function agentCommand( sessionDir: undefined, verbose: false, }; - const telegramProvider = - await createInitializedProvider("telegram", telegramConfig); + const telegramProvider = await createInitializedProvider( + "telegram", + telegramConfig, + ); try { const chunks = chunkText(text, 4096); @@ -451,7 +455,9 @@ export async function agentCommand( const firstChunk = chunks.length > 0 ? chunks[0] : ""; const firstMedia = media[0]; await telegramProvider.send(opts.to, firstChunk, { - media: firstMedia ? [{ type: detectMediaType(firstMedia), url: firstMedia }] : undefined, + media: firstMedia + ? [{ type: detectMediaType(firstMedia), url: firstMedia }] + : undefined, }); } for (let i = 1; i < chunks.length; i++) { diff --git a/src/commands/identity.ts b/src/commands/identity.ts index f52906d61..8ca6f3962 100644 --- a/src/commands/identity.ts +++ b/src/commands/identity.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import chalk from "chalk"; -import type { RuntimeEnv } from "../runtime.js"; +import { danger, info, success, warn } from "../globals.js"; import { deleteMapping, getMapping, @@ -8,7 +8,7 @@ import { setMapping, } from "../identity/storage.js"; import type { IdentityMapping } from "../identity/types.js"; -import { danger, info, success, warn } from "../globals.js"; +import type { RuntimeEnv } from "../runtime.js"; type IdentityLinkOpts = { whatsapp?: string; @@ -122,7 +122,9 @@ export async function identityLinkCommand( try { await setMapping(mapping); runtime.log( - success(`āœ“ Identity mapping created with shared ID: ${chalk.cyan(sharedId)}`), + success( + `āœ“ Identity mapping created with shared ID: ${chalk.cyan(sharedId)}`, + ), ); runtime.log(""); runtime.log(info("Linked identities:")); @@ -179,9 +181,7 @@ export async function identityListCommand( ); } if (mapping.identities.telegram) { - runtime.log( - ` Telegram: ${chalk.blue(mapping.identities.telegram)}`, - ); + runtime.log(` Telegram: ${chalk.blue(mapping.identities.telegram)}`); } if (mapping.identities.twilio) { runtime.log(` Twilio: ${chalk.yellow(mapping.identities.twilio)}`); @@ -248,9 +248,7 @@ export async function identityShowCommand( } runtime.log(""); } catch (err) { - runtime.error( - danger(`Failed to show identity mapping: ${String(err)}`), - ); + runtime.error(danger(`Failed to show identity mapping: ${String(err)}`)); runtime.exit(1); } } @@ -304,9 +302,7 @@ export async function identityUnlinkCommand( runtime.exit(1); } } catch (err) { - runtime.error( - danger(`Failed to unlink identity mapping: ${String(err)}`), - ); + runtime.error(danger(`Failed to unlink identity mapping: ${String(err)}`)); runtime.exit(1); } } diff --git a/src/commands/send.ts b/src/commands/send.ts index 59cafa645..f3532f973 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -188,7 +188,9 @@ export async function sendCommand( ); } else { runtime.log( - success(`āœ… Sent to ${opts.to} via Telegram (id ${result.messageId})`), + success( + `āœ… Sent to ${opts.to} via Telegram (id ${result.messageId})`, + ), ); } } finally { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index bdf6d10b9..5ae78423f 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -4,9 +4,9 @@ import { deriveSessionKey } from "./sessions.js"; describe("sessions", () => { it("returns normalized per-sender key", async () => { - expect(await deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe( - "+1555", - ); + expect( + await deriveSessionKey("per-sender", { From: "whatsapp:+1555" }), + ).toBe("+1555"); }); it("falls back to unknown when sender missing", async () => { @@ -18,8 +18,8 @@ describe("sessions", () => { }); it("keeps group chats distinct", async () => { - expect(await deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe( - "group:12345-678@g.us", - ); + expect( + await deriveSessionKey("per-sender", { From: "12345-678@g.us" }), + ).toBe("group:12345-678@g.us"); }); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index f6146de13..d930afebd 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -4,8 +4,8 @@ import path from "node:path"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; -import { CONFIG_DIR, normalizeE164 } from "../utils.js"; import { normalizeSessionId } from "../identity/normalize.js"; +import { CONFIG_DIR, normalizeE164 } from "../utils.js"; export type SessionScope = "per-sender" | "global"; @@ -83,7 +83,10 @@ function detectProvider(from: string): "whatsapp" | "telegram" | "twilio" { /** * Extract raw ID from message context based on provider. */ -function extractRawId(from: string, provider: "whatsapp" | "telegram" | "twilio"): string { +function extractRawId( + from: string, + provider: "whatsapp" | "telegram" | "twilio", +): string { if (provider === "telegram") { if (from.startsWith("telegram:")) { return from.slice("telegram:".length); @@ -99,7 +102,10 @@ function extractRawId(from: string, provider: "whatsapp" | "telegram" | "twilio" // Decide which session bucket to use (per-sender vs global). // Now supports identity mapping for cross-provider session sharing. -export async function deriveSessionKey(scope: SessionScope, ctx: MsgContext): Promise { +export async function deriveSessionKey( + scope: SessionScope, + ctx: MsgContext, +): Promise { if (scope === "global") return "global"; const from = ctx.From ? ctx.From : ""; diff --git a/src/identity/index.ts b/src/identity/index.ts index be6574053..8dc1c110c 100644 --- a/src/identity/index.ts +++ b/src/identity/index.ts @@ -10,6 +10,6 @@ * 3. Unlink if needed: `warelay identity unlink ` */ -export * from "./types.js"; -export * from "./storage.js"; export * from "./normalize.js"; +export * from "./storage.js"; +export * from "./types.js"; diff --git a/src/identity/normalize.test.ts b/src/identity/normalize.test.ts index acfe552d7..92ef0ae51 100644 --- a/src/identity/normalize.test.ts +++ b/src/identity/normalize.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeSessionId, denormalizeSessionId } from "./normalize.js"; +import { denormalizeSessionId, normalizeSessionId } from "./normalize.js"; import * as storage from "./storage.js"; vi.mock("./storage.js"); @@ -78,19 +78,12 @@ describe("normalizeSessionId", () => { }; // First call for Telegram - vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce( - mockMapping, - ); + vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(mockMapping); const telegramResult = await normalizeSessionId("telegram", "987654321"); // Second call for WhatsApp - vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce( - mockMapping, - ); - const whatsappResult = await normalizeSessionId( - "whatsapp", - "+9876543210", - ); + vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(mockMapping); + const whatsappResult = await normalizeSessionId("whatsapp", "+9876543210"); expect(telegramResult).toBe("shared-xyz-456"); expect(whatsappResult).toBe("shared-xyz-456"); diff --git a/src/telegram/client.test.ts b/src/telegram/client.test.ts index 038be8917..536e3d2a6 100644 --- a/src/telegram/client.test.ts +++ b/src/telegram/client.test.ts @@ -25,7 +25,8 @@ vi.mock("telegram/sessions/index.js", () => ({ })); const { createTelegramClient, isClientConnected } = await import("./client.js"); -const { StringSession } = await import("telegram/sessions/index.js"); +// StringSession is mocked above but we import it to verify the mock works +await import("telegram/sessions/index.js"); describe("telegram client", () => { const mockRuntime: RuntimeEnv = { diff --git a/src/telegram/download.test.ts b/src/telegram/download.test.ts index b255357c5..3eb443166 100644 --- a/src/telegram/download.test.ts +++ b/src/telegram/download.test.ts @@ -6,7 +6,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Readable } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { cleanOrphanedTempFiles, ensureTempDir, @@ -23,6 +31,14 @@ describe("download", () => { `warelay-test-${process.pid}-download`, ); + beforeAll(() => { + process.env.TELEGRAM_TEMP_DIR = testTempDir; + }); + + afterAll(() => { + delete process.env.TELEGRAM_TEMP_DIR; + }); + beforeEach(async () => { // Clean test directory before each test await fs.rm(testTempDir, { recursive: true, force: true }).catch(() => {}); @@ -32,11 +48,7 @@ describe("download", () => { describe("getTelegramTempDir", () => { it("returns correct path", () => { const dir = getTelegramTempDir(); - // Should contain either .clawdis or .warelay (depending on which exists) - const hasCorrectDir = - dir.includes(".clawdis") || dir.includes(".warelay"); - expect(hasCorrectDir).toBe(true); - expect(dir).toContain("telegram-temp"); + expect(dir).toBe(testTempDir); expect(path.isAbsolute(dir)).toBe(true); }); }); @@ -110,8 +122,8 @@ describe("download", () => { expect(result.contentType).toBe("text/plain"); // Verify path is in temp directory - expect(result.tempPath).toContain("telegram-temp"); - expect(result.tempPath).toMatch(/telegram-dl-.*\.tmp$/); + expect(result.tempPath.startsWith(testTempDir)).toBe(true); + expect(path.basename(result.tempPath)).toMatch(/telegram-dl-.*\.tmp$/); } finally { await result.cleanup(); } diff --git a/src/telegram/download.ts b/src/telegram/download.ts index f933f7e18..e2d102246 100644 --- a/src/telegram/download.ts +++ b/src/telegram/download.ts @@ -1,25 +1,43 @@ import crypto from "node:crypto"; -import { createWriteStream } from "node:fs"; -import fsSync from "node:fs"; +import fsSync, { createWriteStream } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { canUseDir } from "../utils.js"; // Prefer ~/.clawdis/telegram-temp, but fall back to ~/.warelay for compatibility const TEMP_DIR_CLAWDIS = path.join(os.homedir(), ".clawdis", "telegram-temp"); const TEMP_DIR_LEGACY = path.join(os.homedir(), ".warelay", "telegram-temp"); function resolveTempDir(): string { + // Allow override for tests/sandboxes + const override = + process.env.TELEGRAM_TEMP_DIR || process.env.WARELAY_TEMP_DIR; + if (override) { + return path.resolve(override); + } + // Use CLAWDIS path if the main config directory exists, otherwise legacy const clawdisConfigExists = fsSync.existsSync( path.join(os.homedir(), ".clawdis"), ); - return clawdisConfigExists ? TEMP_DIR_CLAWDIS : TEMP_DIR_LEGACY; + const preferred = clawdisConfigExists ? TEMP_DIR_CLAWDIS : TEMP_DIR_LEGACY; + if (canUseDir(preferred)) return preferred; + if (canUseDir(TEMP_DIR_LEGACY)) return TEMP_DIR_LEGACY; + + // Sandbox-safe fallback inside workspace + const fallback = path.join(process.cwd(), ".clawdis", "telegram-temp"); + if (canUseDir(fallback)) return fallback; + + // Last resort: OS tmp + return path.join(os.tmpdir(), "warelay-telegram-temp"); } -const TEMP_DIR = resolveTempDir(); +function getTempDir(): string { + return resolveTempDir(); +} const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000; // 1 hour /** @@ -45,15 +63,16 @@ export interface DownloadResult { * Uses ~/.clawdis/telegram-temp for consistency with media store. */ export function getTelegramTempDir(): string { - return TEMP_DIR; + return getTempDir(); } /** * Ensure temp directory exists. */ export async function ensureTempDir(): Promise { - await fs.mkdir(TEMP_DIR, { recursive: true }); - return TEMP_DIR; + const dir = getTempDir(); + await fs.mkdir(dir, { recursive: true }); + return dir; } /** @@ -84,7 +103,7 @@ export async function streamDownloadToTemp( // Generate unique temp file name const filename = `telegram-dl-${crypto.randomUUID()}.tmp`; - const tempPath = path.join(TEMP_DIR, filename); + const tempPath = path.join(getTempDir(), filename); // Fetch response const response = await fetch(url); @@ -153,12 +172,13 @@ export async function cleanOrphanedTempFiles( ): Promise { try { await ensureTempDir(); - const entries = await fs.readdir(TEMP_DIR).catch(() => []); + const tempDir = getTempDir(); + const entries = await fs.readdir(tempDir).catch(() => []); const now = Date.now(); await Promise.all( entries.map(async (file) => { - const fullPath = path.join(TEMP_DIR, file); + const fullPath = path.join(tempDir, file); const stat = await fs.stat(fullPath).catch(() => null); if (!stat) return; diff --git a/src/telegram/inbound.test.ts b/src/telegram/inbound.test.ts index 7bd03754f..8c57b8d68 100644 --- a/src/telegram/inbound.test.ts +++ b/src/telegram/inbound.test.ts @@ -57,7 +57,7 @@ describe("convertTelegramMessage", () => { expect(result).toEqual({ id: "999", - from: "@testuser", + from: "telegram:@testuser", to: "me", body: "Hello, world!", timestamp: 1234567890000, @@ -121,7 +121,7 @@ describe("convertTelegramMessage", () => { }; const result = await convertTelegramMessage(mockEvent as NewMessageEvent); - expect(result?.from).toBe("+1234567890"); + expect(result?.from).toBe("telegram:+1234567890"); }); it("uses ID when neither username nor phone available", async () => { @@ -145,7 +145,7 @@ describe("convertTelegramMessage", () => { }; const result = await convertTelegramMessage(mockEvent as NewMessageEvent); - expect(result?.from).toBe("12345"); + expect(result?.from).toBe("telegram:12345"); }); it("uses Unknown when no identifiable info available", async () => { @@ -166,7 +166,7 @@ describe("convertTelegramMessage", () => { }; const result = await convertTelegramMessage(mockEvent as NewMessageEvent); - expect(result?.from).toBe("unknown"); + expect(result?.from).toBe("telegram:unknown"); expect(result?.displayName).toBe("Unknown"); }); @@ -501,7 +501,7 @@ describe("isAllowedSender", () => { it("allows all senders when no whitelist provided", () => { const message: ProviderMessage = { id: "1", - from: "@testuser", + from: "telegram:@testuser", to: "me", body: "Hello", timestamp: Date.now(), @@ -515,83 +515,102 @@ describe("isAllowedSender", () => { it("allows sender with matching username", () => { const message: ProviderMessage = { id: "1", - from: "@testuser", + from: "telegram:@testuser", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, ["@testuser"])).toBe(true); - expect(isAllowedSender(message, ["testuser"])).toBe(true); // Without @ + expect(isAllowedSender(message, ["telegram:@testuser"])).toBe(true); + expect(isAllowedSender(message, ["telegram:testuser"])).toBe(true); // Without @ }); it("allows sender with matching phone", () => { const message: ProviderMessage = { id: "1", - from: "+1234567890", + from: "telegram:+1234567890", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, ["+1234567890"])).toBe(true); + expect(isAllowedSender(message, ["telegram:+1234567890"])).toBe(true); }); it("rejects sender not in whitelist", () => { const message: ProviderMessage = { id: "1", - from: "@unauthorized", + from: "telegram:@unauthorized", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, ["@testuser", "+1234567890"])).toBe(false); + expect( + isAllowedSender(message, ["telegram:@testuser", "telegram:+1234567890"]), + ).toBe(false); }); it("is case insensitive", () => { const message: ProviderMessage = { id: "1", - from: "@TestUser", + from: "telegram:@TestUser", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, ["@testuser"])).toBe(true); - expect(isAllowedSender(message, ["TESTUSER"])).toBe(true); + expect(isAllowedSender(message, ["telegram:@testuser"])).toBe(true); + expect(isAllowedSender(message, ["telegram:TESTUSER"])).toBe(true); }); it("trims whitespace from whitelist entries", () => { const message: ProviderMessage = { id: "1", - from: "@testuser", + from: "telegram:@testuser", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, [" @testuser "])).toBe(true); + expect(isAllowedSender(message, [" telegram:@testuser "])).toBe(true); }); it("allows sender in multi-entry whitelist", () => { const message: ProviderMessage = { id: "1", - from: "@user2", + from: "telegram:@user2", to: "me", body: "Hello", timestamp: Date.now(), provider: "telegram", }; - expect(isAllowedSender(message, ["@user1", "@user2", "+1234567890"])).toBe( - true, - ); + expect( + isAllowedSender(message, [ + "telegram:@user1", + "telegram:@user2", + "telegram:+1234567890", + ]), + ).toBe(true); + }); + + it("allows all senders when allowFrom contains wildcard", () => { + const message: ProviderMessage = { + id: "1", + from: "telegram:@anyone", + to: "me", + body: "Hello", + timestamp: Date.now(), + provider: "telegram", + }; + + expect(isAllowedSender(message, ["*"])).toBe(true); }); }); @@ -671,7 +690,7 @@ describe("startMessageListener", () => { expect(mockHandler).toHaveBeenCalledWith( expect.objectContaining({ id: "999", - from: "@testuser", + from: "telegram:@testuser", body: "Hello!", }), ); @@ -715,7 +734,7 @@ describe("startMessageListener", () => { const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await startMessageListener(mockClient as TelegramClient, mockHandler, [ - "@allowed", + "telegram:@allowed", ]); // Simulate message from non-whitelisted user @@ -743,7 +762,7 @@ describe("startMessageListener", () => { expect(mockHandler).not.toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith( - "Ignored message from @unauthorized (not in allowFrom list)", + "Ignored message from telegram:@unauthorized (not in allowFrom list)", ); consoleLogSpy.mockRestore(); @@ -758,7 +777,7 @@ describe("startMessageListener", () => { }); await startMessageListener(mockClient as TelegramClient, mockHandler, [ - "@testuser", + "telegram:@testuser", ]); // Simulate message from whitelisted user diff --git a/src/telegram/inbound.ts b/src/telegram/inbound.ts index c5c377614..da21abfdd 100644 --- a/src/telegram/inbound.ts +++ b/src/telegram/inbound.ts @@ -7,7 +7,7 @@ import type { ProviderMedia, ProviderMessage, } from "../providers/base/types.js"; -import { normalizeAllowFromEntry } from "../utils.js"; +import { normalizeAllowFromEntry, TELEGRAM_PREFIX } from "../utils.js"; /** * Convert Telegram message to ProviderMessage format. @@ -67,17 +67,19 @@ export async function convertTelegramMessage( function extractSenderIdentifier(sender: Api.User | Api.Chat): string { if ("username" in sender && sender.username) { // Lowercase username for case-insensitive matching (Telegram usernames are case-insensitive) - return `telegram:@${sender.username.toLowerCase()}`; + return `${TELEGRAM_PREFIX}@${sender.username.toLowerCase()}`; } if ("phone" in sender && sender.phone) { // Ensure phone has + prefix for E.164 format to match normalized allowFrom entries - const phone = sender.phone.startsWith("+") ? sender.phone : `+${sender.phone}`; - return `telegram:${phone}`; + const phone = sender.phone.startsWith("+") + ? sender.phone + : `+${sender.phone}`; + return `${TELEGRAM_PREFIX}${phone}`; } if ("id" in sender && sender.id) { - return `telegram:${sender.id.toString()}`; + return `${TELEGRAM_PREFIX}${sender.id.toString()}`; } - return "telegram:unknown"; + return `${TELEGRAM_PREFIX}unknown`; } /** diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 9fbb9e588..910bbc8ae 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -27,7 +27,8 @@ vi.mock("../env.js", () => ({ vi.mock("../config/config.js", () => ({ loadConfig: vi.fn(() => ({ inbound: { - allowFrom: ["@testuser"], + allowFrom: ["telegram:@testuser"], + timestampPrefix: false, reply: { mode: "text", text: "Auto-reply test", @@ -141,7 +142,7 @@ describe("monitorTelegramProvider", () => { apiId: 12345, apiHash: "test-hash", sessionDir: undefined, - allowFrom: ["@testuser"], + allowFrom: ["telegram:@testuser"], verbose: true, }); @@ -160,8 +161,8 @@ describe("monitorTelegramProvider", () => { if (messageHandler) { const testMessage: ProviderMessage = { id: "test-msg-123", - from: "@testuser", - to: "@botuser", + from: "telegram:@testuser", + to: "telegram:@botuser", body: "Hello bot", timestamp: Date.now(), provider: "telegram", @@ -180,8 +181,8 @@ describe("monitorTelegramProvider", () => { expect(getReplyFromConfig).toHaveBeenCalledWith( expect.objectContaining({ Body: "Hello bot", - From: "@testuser", - To: "@botuser", + From: "telegram:@testuser", + To: "telegram:@botuser", }), expect.objectContaining({ onReplyStart: expect.any(Function), @@ -191,7 +192,7 @@ describe("monitorTelegramProvider", () => { // Verify reply was sent expect(mockProvider.send).toHaveBeenCalledWith( - "@testuser", + "telegram:@testuser", "Test reply", expect.anything(), ); @@ -206,7 +207,8 @@ describe("monitorTelegramProvider", () => { // Set allowFrom filter (loadConfig as Mock).mockReturnValueOnce({ inbound: { - allowFrom: ["@alloweduser"], + allowFrom: ["telegram:@alloweduser"], + timestampPrefix: false, reply: { mode: "text", text: "Auto-reply test", @@ -219,8 +221,8 @@ describe("monitorTelegramProvider", () => { if (messageHandler) { const testMessage: ProviderMessage = { id: "test-msg-123", - from: "@notallowed", - to: "@botuser", + from: "telegram:@notallowed", + to: "telegram:@botuser", body: "Hello", timestamp: Date.now(), provider: "telegram", @@ -254,8 +256,8 @@ describe("monitorTelegramProvider", () => { if (messageHandler) { const testMessage: ProviderMessage = { id: "test-msg-456", - from: "@testuser", - to: "@botuser", + from: "telegram:@testuser", + to: "telegram:@botuser", body: "Send me a picture", timestamp: Date.now(), provider: "telegram", @@ -279,7 +281,7 @@ describe("monitorTelegramProvider", () => { // Verify reply with media was sent expect(mockProvider.send).toHaveBeenCalledWith( - "@testuser", + "telegram:@testuser", "Here's an image", expect.objectContaining({ media: expect.arrayContaining([ @@ -307,8 +309,8 @@ describe("monitorTelegramProvider", () => { if (messageHandler) { const testMessage: ProviderMessage = { id: "test-msg-789", - from: "@testuser", - to: "@botuser", + from: "telegram:@testuser", + to: "telegram:@botuser", body: "Hello", timestamp: Date.now(), provider: "telegram", @@ -329,6 +331,6 @@ describe("monitorTelegramProvider", () => { } // Verify typing indicator was sent - expect(mockProvider.sendTyping).toHaveBeenCalledWith("@testuser"); + expect(mockProvider.sendTyping).toHaveBeenCalledWith("telegram:@testuser"); }); }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 1ca499a7c..1ab07567f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -26,7 +26,10 @@ const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit * Format timestamp in the same format as WhatsApp relay. * Example: [Dec 5 22:41] */ -function formatTimestamp(ts: number, config?: ReturnType): string { +function formatTimestamp( + ts: number, + config?: ReturnType, +): string { const tsCfg = config?.inbound?.timestampPrefix; const tsEnabled = tsCfg !== false; // default true if (!tsEnabled) return ""; @@ -52,7 +55,7 @@ async function providerMessageToContext( : message.body; // Handle media: if buffer exists but no URL, save the buffer and create a file path - let mediaUrl = message.media?.[0]?.url; + const mediaUrl = message.media?.[0]?.url; let mediaPath = message.media?.[0]?.fileName; if (!mediaUrl && message.media?.[0]?.buffer) { diff --git a/src/telegram/provider.test.ts b/src/telegram/provider.test.ts index 1ae6f7029..f25507b7f 100644 --- a/src/telegram/provider.test.ts +++ b/src/telegram/provider.test.ts @@ -345,7 +345,7 @@ describe("TelegramProvider", () => { kind: "telegram", apiId: 12345, apiHash: "test-hash", - allowFrom: ["@testuser", "+1234567890"], + allowFrom: ["telegram:@testuser", "telegram:+1234567890"], }; vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any); @@ -369,7 +369,7 @@ describe("TelegramProvider", () => { expect(inboundModule.startMessageListener).toHaveBeenCalledWith( mockClient, handler, - ["@testuser", "+1234567890"], + ["telegram:@testuser", "telegram:+1234567890"], ); }); diff --git a/src/telegram/utils.ts b/src/telegram/utils.ts index 7c3745dee..4517887bf 100644 --- a/src/telegram/utils.ts +++ b/src/telegram/utils.ts @@ -1,4 +1,5 @@ import type { Api, TelegramClient } from "telegram"; +import { TELEGRAM_PREFIX } from "../utils.js"; // Entity type - can be User, Chat, Channel, or their empty variants type Entity = Api.User | Api.Chat | Api.Channel; @@ -14,8 +15,8 @@ export async function resolveEntity( ): Promise { // Clean identifier and strip telegram: prefix if present let clean = identifier.trim(); - if (clean.startsWith("telegram:")) { - clean = clean.slice("telegram:".length); + if (clean.startsWith(TELEGRAM_PREFIX)) { + clean = clean.slice(TELEGRAM_PREFIX.length); } // Try as-is first (handles @username, phone, user ID) diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts index a45f9e41d..2d6e82c76 100644 --- a/src/twilio/webhook.ts +++ b/src/twilio/webhook.ts @@ -152,7 +152,9 @@ export async function startWebhook( function buildTwilioBasicAuth(env: EnvConfig) { if (!env.auth || !env.accountSid) { - throw new Error("Twilio credentials not configured for webhook authentication"); + throw new Error( + "Twilio credentials not configured for webhook authentication", + ); } if ("authToken" in env.auth) { return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString( diff --git a/src/utils.ts b/src/utils.ts index 5ce9555d2..d4c20300a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,20 @@ export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); } +/** + * Best-effort check that a directory exists and is writable. + * Creates the directory if missing; returns false if creation or access fails. + */ +export function canUseDir(dir: string): boolean { + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + export type Provider = "twilio" | "web" | "telegram"; export function assertProvider(input: string): asserts input is Provider { @@ -17,6 +31,13 @@ export function assertProvider(input: string): asserts input is Provider { export type AllowFromProvider = "telegram" | "web" | "twilio"; +export const TELEGRAM_PREFIX = "telegram:"; + +/** + * Normalize an allowFrom entry for a specific provider. + * - Telegram: ensure `telegram:` prefix and `@username` casing + * - WhatsApp/Web: return normalized E.164 without whatsapp: prefix + */ export function normalizeAllowFromEntry( entry: string, provider: AllowFromProvider, @@ -26,18 +47,18 @@ export function normalizeAllowFromEntry( if (provider === "telegram") { // Strip telegram: prefix if present (allowFrom entries may or may not have it) - const withoutPrefix = trimmed.startsWith("telegram:") - ? trimmed.slice("telegram:".length) + const withoutPrefix = trimmed.startsWith(TELEGRAM_PREFIX) + ? trimmed.slice(TELEGRAM_PREFIX.length) : trimmed; // Ensure @username format, then add telegram: prefix const username = withoutPrefix.startsWith("@") ? withoutPrefix : withoutPrefix.match(/^\d+$/) - ? withoutPrefix // numeric ID, keep as-is + ? withoutPrefix // numeric ID, keep as-is : `@${withoutPrefix}`; - return `telegram:${username}`; + return `${TELEGRAM_PREFIX}${username}`; } // WhatsApp (both web and twilio) use E.164 phone numbers @@ -102,8 +123,24 @@ export function sleep(ms: number) { // Prefer new branding directory; fall back to legacy for compatibility. export const CONFIG_DIR = (() => { + const override = process.env.WARELAY_CONFIG_DIR; + + if (override) { + const resolved = path.resolve(override); + if (canUseDir(resolved)) return resolved; + } + const clawdis = path.join(os.homedir(), ".clawdis"); const legacy = path.join(os.homedir(), ".warelay"); - if (fs.existsSync(clawdis)) return clawdis; - return legacy; + if (canUseDir(clawdis)) return clawdis; + if (canUseDir(legacy)) return legacy; + + // Sandbox-safe fallback inside the workspace when home isn't writable + const fallback = path.join(process.cwd(), ".clawdis"); + if (canUseDir(fallback)) return fallback; + + // Last resort: OS tmp (should be writable) + const tmp = path.join(os.tmpdir(), "clawdis"); + fs.mkdirSync(tmp, { recursive: true }); + return tmp; })(); diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index a1d822cae..f2bc903a2 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -32,12 +32,12 @@ describe("web logout", () => { }); it("deletes cached credentials when present", async () => { - const credsDir = path.join(tmpDir, ".warelay", "credentials"); + const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); + const credsDir = WA_WEB_AUTH_DIR; fs.mkdirSync(credsDir, { recursive: true }); fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - const sessionsPath = path.join(tmpDir, ".warelay", "sessions.json"); + const sessionsPath = path.join(path.dirname(credsDir), "sessions.json"); fs.writeFileSync(sessionsPath, "{}"); - const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); const result = await logoutWeb(runtime as never); diff --git a/src/web/session.ts b/src/web/session.ts index ad6c42fae..d87a0b32f 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -236,101 +236,101 @@ export async function pickProvider(pref: Provider | "auto"): Promise { * Check if Twilio is configured via environment variables. */ function isTwilioConfigured(): boolean { - const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; - const hasRequired = required.every((k) => Boolean(process.env[k])); - const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); - const hasKey = Boolean( - process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, - ); - return hasRequired && (hasToken || hasKey); + const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; + const hasRequired = required.every((k) => Boolean(process.env[k])); + const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); + const hasKey = Boolean( + process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, + ); + return hasRequired && (hasToken || hasKey); } export async function selectProviders( - prefs: (Provider | "auto")[], + prefs: (Provider | "auto")[], ): Promise { - const skipped: string[] = []; + const skipped: string[] = []; - // If 'auto' in list, expand to all authenticated providers - if (prefs.includes("auto")) { - const available: Provider[] = []; + // If 'auto' in list, expand to all authenticated providers + if (prefs.includes("auto")) { + const available: Provider[] = []; - // Check web - if (await webAuthExists()) { - available.push("web"); - } else { - skipped.push( - "web (not authenticated - run: warelay login --provider web)", - ); - } + // Check web + if (await webAuthExists()) { + available.push("web"); + } else { + skipped.push( + "web (not authenticated - run: warelay login --provider web)", + ); + } - // Check telegram - if (await telegramAuthExists()) { - available.push("telegram"); - } else { - skipped.push( - "telegram (not authenticated - run: warelay login --provider telegram)", - ); - } + // Check telegram + if (await telegramAuthExists()) { + available.push("telegram"); + } else { + skipped.push( + "telegram (not authenticated - run: warelay login --provider telegram)", + ); + } - // Check twilio - if (isTwilioConfigured()) { - available.push("twilio"); - } else { - skipped.push( - "twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)", - ); - } + // Check twilio + if (isTwilioConfigured()) { + available.push("twilio"); + } else { + skipped.push( + "twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)", + ); + } - if (available.length > 0 && skipped.length > 0) { - console.log( - `ā„¹ļø Auto-selected ${available.length} provider(s), skipped ${skipped.length}:`, - ); - for (const skip of skipped) { - console.log(` • ${skip}`); - } - } + if (available.length > 0 && skipped.length > 0) { + console.log( + `ā„¹ļø Auto-selected ${available.length} provider(s), skipped ${skipped.length}:`, + ); + for (const skip of skipped) { + console.log(` • ${skip}`); + } + } - return available; - } + return available; + } - // Explicit provider list - validate each one - const available: Provider[] = []; - for (const pref of prefs) { - if (pref === "auto") continue; // Already handled above + // Explicit provider list - validate each one + const available: Provider[] = []; + for (const pref of prefs) { + if (pref === "auto") continue; // Already handled above - if (pref === "web") { - if (await webAuthExists()) { - available.push("web"); - } else { - skipped.push( - "web (not authenticated - run: warelay login --provider web)", - ); - } - } else if (pref === "telegram") { - if (await telegramAuthExists()) { - available.push("telegram"); - } else { - skipped.push( - "telegram (not authenticated - run: warelay login --provider telegram)", - ); - } - } else if (pref === "twilio") { - if (isTwilioConfigured()) { - available.push("twilio"); - } else { - skipped.push( - "twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)", - ); - } - } - } + if (pref === "web") { + if (await webAuthExists()) { + available.push("web"); + } else { + skipped.push( + "web (not authenticated - run: warelay login --provider web)", + ); + } + } else if (pref === "telegram") { + if (await telegramAuthExists()) { + available.push("telegram"); + } else { + skipped.push( + "telegram (not authenticated - run: warelay login --provider telegram)", + ); + } + } else if (pref === "twilio") { + if (isTwilioConfigured()) { + available.push("twilio"); + } else { + skipped.push( + "twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)", + ); + } + } + } - if (skipped.length > 0) { - console.log(`ā„¹ļø Skipped ${skipped.length} provider(s):`); - for (const skip of skipped) { - console.log(` • ${skip}`); - } - } + if (skipped.length > 0) { + console.log(`ā„¹ļø Skipped ${skipped.length} provider(s):`); + for (const skip of skipped) { + console.log(` • ${skip}`); + } + } - return available; + return available; }