chore: apply biome formatting

Applied formatting fixes to 21 files:
- Fixed import ordering
- Normalized indentation
- Wrapped long lines

No logic changes, formatting only.
This commit is contained in:
Arne Moor 2025-12-06 18:45:25 +01:00
parent 31246f0a34
commit 1f959a188f
32 changed files with 895 additions and 697 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ dist
pnpm-lock.yaml
coverage
.pnpm-store
.clawdis

View File

@ -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)

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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);

View File

@ -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})` : ""}`,
);

View File

@ -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 || "<unknown>";
logVerbose(
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
`Skipping auto-reply: sender ${displayFrom} not in allowFrom list`,
);
cleanupTyping();
return undefined;

View File

@ -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
});
});

View File

@ -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<void> {
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");
}

View File

@ -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>", "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 <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web | telegram", "twilio")
.option(
"--provider <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 <provider>", "auto | web | twilio | telegram", "auto")
.option("--providers <providers>", "Comma-separated list: web,telegram,twilio")
.option(
"--providers <providers>",
"Comma-separated list: web,telegram,twilio",
)
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
.option(
"-l, --lookback <minutes>",
@ -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 <id>")
.description("Unlink an identity mapping (providers will have separate sessions)")
.description(
"Unlink an identity mapping (providers will have separate sessions)",
)
.addHelpText(
"after",
`

View File

@ -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++) {

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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");
});
});

View File

@ -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<string> {
export async function deriveSessionKey(
scope: SessionScope,
ctx: MsgContext,
): Promise<string> {
if (scope === "global") return "global";
const from = ctx.From ? ctx.From : "";

View File

@ -10,6 +10,6 @@
* 3. Unlink if needed: `warelay identity unlink <shared-id>`
*/
export * from "./types.js";
export * from "./storage.js";
export * from "./normalize.js";
export * from "./storage.js";
export * from "./types.js";

View File

@ -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");

View File

@ -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 = {

View File

@ -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();
}

View File

@ -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<string> {
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<void> {
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;

View File

@ -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

View File

@ -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`;
}
/**

View File

@ -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");
});
});

View File

@ -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<typeof loadConfig>): string {
function formatTimestamp(
ts: number,
config?: ReturnType<typeof loadConfig>,
): 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) {

View File

@ -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"],
);
});

View File

@ -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<Entity> {
// 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)

View File

@ -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(

View File

@ -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;
})();

View File

@ -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);

View File

@ -236,101 +236,101 @@ export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
* 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<Provider[]> {
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;
}