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:
parent
31246f0a34
commit
1f959a188f
@ -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
1
.gitignore
vendored
@ -4,3 +4,4 @@ dist
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
.clawdis
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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})` : ""}`,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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",
|
||||
`
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 : "";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
49
src/utils.ts
49
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;
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user