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
|
TWILIO_AUTH_TOKEN=your_auth_token_here
|
||||||
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
|
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
|
||||||
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
|
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
|
pnpm-lock.yaml
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
.clawdis
|
||||||
|
|||||||
@ -55,7 +55,8 @@ export const claudeSpec: AgentSpec = {
|
|||||||
beforeBody.push("-p");
|
beforeBody.push("-p");
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
const shouldPrependIdentity =
|
||||||
|
ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
const bodyWithIdentity = shouldPrependIdentity
|
const bodyWithIdentity = shouldPrependIdentity
|
||||||
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
||||||
.filter(Boolean)
|
.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 =
|
const bodyWithIdentity =
|
||||||
shouldPrependIdentity && body
|
shouldPrependIdentity && body
|
||||||
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
|
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
|
||||||
|
|||||||
@ -41,7 +41,8 @@ export const opencodeSpec: AgentSpec = {
|
|||||||
// Identity prefix
|
// Identity prefix
|
||||||
// Opencode streams text tokens; we still seed an identity so the agent
|
// Opencode streams text tokens; we still seed an identity so the agent
|
||||||
// keeps context on first turn.
|
// keeps context on first turn.
|
||||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
const shouldPrependIdentity =
|
||||||
|
ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
const bodyWithIdentity =
|
const bodyWithIdentity =
|
||||||
shouldPrependIdentity && body
|
shouldPrependIdentity && body
|
||||||
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
||||||
|
|||||||
@ -158,7 +158,8 @@ export const piSpec: AgentSpec = {
|
|||||||
}
|
}
|
||||||
// Session defaults
|
// Session defaults
|
||||||
// Identity prefix optional; Pi usually doesn't need it, but allow injection
|
// 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]) {
|
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
|
||||||
const existingBody = argv[ctx.bodyIndex];
|
const existingBody = argv[ctx.bodyIndex];
|
||||||
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js";
|
||||||
import type { ReplyPayload } from "./types.js";
|
import type { ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
@ -92,6 +94,43 @@ describe("runCommandReply", () => {
|
|||||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
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 () => {
|
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
|
||||||
const captures: ReplyPayload[] = [];
|
const captures: ReplyPayload[] = [];
|
||||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { splitMediaFromOutput } from "../media/parse.js";
|
|||||||
import { enqueueCommand } from "../process/command-queue.js";
|
import { enqueueCommand } from "../process/command-queue.js";
|
||||||
import type { runCommandWithTimeout } from "../process/exec.js";
|
import type { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { runPiRpc } from "../process/tau-rpc.js";
|
import { runPiRpc } from "../process/tau-rpc.js";
|
||||||
|
import { CLAUDE_IDENTITY_PREFIX } from "./claude.js";
|
||||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||||
import {
|
import {
|
||||||
formatToolAggregate,
|
formatToolAggregate,
|
||||||
@ -399,6 +400,21 @@ export async function runCommandReply(
|
|||||||
})
|
})
|
||||||
: argv;
|
: 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(
|
logVerbose(
|
||||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
`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 { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { TwilioRequester } from "../twilio/types.js";
|
import type { TwilioRequester } from "../twilio/types.js";
|
||||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||||
|
import {
|
||||||
|
normalizeAllowFromEntry,
|
||||||
|
normalizeE164,
|
||||||
|
TELEGRAM_PREFIX,
|
||||||
|
} from "../utils.js";
|
||||||
import { chunkText } from "./chunk.js";
|
import { chunkText } from "./chunk.js";
|
||||||
import { runCommandReply } from "./command-reply.js";
|
import { runCommandReply } from "./command-reply.js";
|
||||||
import {
|
import {
|
||||||
@ -409,10 +414,26 @@ export async function getReplyFromConfig(
|
|||||||
return { text: ack };
|
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 allowFrom = cfg.inbound?.allowFrom;
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const rawFrom = ctx.From ?? "";
|
||||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
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 isSamePhone = from && to && from === to;
|
||||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||||
const rawBodyNormalized = (sessionCtx.BodyStripped ?? sessionCtx.Body ?? "")
|
const rawBodyNormalized = (sessionCtx.BodyStripped ?? sessionCtx.Body ?? "")
|
||||||
@ -428,9 +449,11 @@ export async function getReplyFromConfig(
|
|||||||
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
||||||
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
// Support "*" as wildcard to allow all senders
|
// 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(
|
logVerbose(
|
||||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
`Skipping auto-reply: sender ${displayFrom} not in allowFrom list`,
|
||||||
);
|
);
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@ -1,418 +1,407 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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 { WarelayConfig } from "../config/config.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import type { Provider } from "../utils.js";
|
||||||
import type { CliDeps } from "./deps.js";
|
import type { CliDeps } from "./deps.js";
|
||||||
import { runMultiProviderRelay } from "./multi-relay.js";
|
import { runMultiProviderRelay } from "./multi-relay.js";
|
||||||
|
|
||||||
// Mock the monitor modules
|
// Mock the monitor modules
|
||||||
vi.mock("../telegram/monitor.js", () => ({
|
vi.mock("../telegram/monitor.js", () => ({
|
||||||
monitorTelegramProvider: vi.fn(),
|
monitorTelegramProvider: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../web/auto-reply.js", () => ({
|
vi.mock("../web/auto-reply.js", () => ({
|
||||||
monitorWebProvider: vi.fn(),
|
monitorWebProvider: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../twilio/monitor.js", () => ({
|
vi.mock("../twilio/monitor.js", () => ({
|
||||||
monitorTwilio: vi.fn(),
|
monitorTwilio: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("runMultiProviderRelay", () => {
|
describe("runMultiProviderRelay", () => {
|
||||||
let mockRuntime: RuntimeEnv;
|
let mockRuntime: RuntimeEnv;
|
||||||
let mockConfig: WarelayConfig;
|
let mockConfig: WarelayConfig;
|
||||||
let mockDeps: CliDeps;
|
let mockDeps: CliDeps;
|
||||||
let originalProcessOn: typeof process.on;
|
let originalProcessOn: typeof process.on;
|
||||||
let originalProcessOff: typeof process.off;
|
let originalProcessOff: typeof process.off;
|
||||||
let sigintHandler: ((...args: unknown[]) => void) | undefined;
|
let sigintHandler: ((...args: unknown[]) => void) | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRuntime = {
|
mockRuntime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(),
|
exit: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
telegram: { allowFrom: [] },
|
telegram: { allowFrom: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
mockDeps = {} as CliDeps;
|
mockDeps = {} as CliDeps;
|
||||||
|
|
||||||
// Capture SIGINT handler
|
// Capture SIGINT handler
|
||||||
originalProcessOn = process.on;
|
originalProcessOn = process.on;
|
||||||
originalProcessOff = process.off;
|
originalProcessOff = process.off;
|
||||||
process.on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
process.on = vi.fn(
|
||||||
if (event === "SIGINT") {
|
(event: string, handler: (...args: unknown[]) => void) => {
|
||||||
sigintHandler = handler;
|
if (event === "SIGINT") {
|
||||||
}
|
sigintHandler = handler;
|
||||||
return process;
|
}
|
||||||
}) as typeof process.on;
|
return process;
|
||||||
process.off = vi.fn() as typeof process.off;
|
},
|
||||||
|
) as typeof process.on;
|
||||||
vi.clearAllMocks();
|
process.off = vi.fn() as typeof process.off;
|
||||||
});
|
|
||||||
|
vi.clearAllMocks();
|
||||||
afterEach(() => {
|
});
|
||||||
process.on = originalProcessOn;
|
|
||||||
process.off = originalProcessOff;
|
afterEach(() => {
|
||||||
sigintHandler = undefined;
|
process.on = originalProcessOn;
|
||||||
});
|
process.off = originalProcessOff;
|
||||||
|
sigintHandler = undefined;
|
||||||
test("starts telegram provider and logs startup message", async () => {
|
});
|
||||||
const { monitorTelegramProvider } = await import(
|
|
||||||
"../telegram/monitor.js"
|
test("starts telegram provider and logs startup message", async () => {
|
||||||
);
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const providers: Provider[] = ["telegram"];
|
const providers: Provider[] = ["telegram"];
|
||||||
|
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
verbose: true,
|
verbose: true,
|
||||||
runtime: mockRuntime,
|
runtime: mockRuntime,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for startup message
|
// Wait for startup message
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Abort the relay
|
// Abort the relay
|
||||||
if (sigintHandler) {
|
if (sigintHandler) {
|
||||||
sigintHandler();
|
sigintHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
"📡 Starting 1 provider(s): telegram",
|
"📡 Starting 1 provider(s): telegram",
|
||||||
);
|
);
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
||||||
true,
|
true,
|
||||||
mockRuntime,
|
mockRuntime,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("starts web provider with verbose and webTuning options", async () => {
|
test("starts web provider with verbose and webTuning options", async () => {
|
||||||
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
||||||
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const providers: Provider[] = ["web"];
|
const providers: Provider[] = ["web"];
|
||||||
const webTuning = { heartbeatSeconds: 60 };
|
const webTuning = { heartbeatSeconds: 60 };
|
||||||
|
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
verbose: true,
|
verbose: true,
|
||||||
webTuning,
|
webTuning,
|
||||||
runtime: mockRuntime,
|
runtime: mockRuntime,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for startup message
|
// Wait for startup message
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Abort the relay
|
// Abort the relay
|
||||||
if (sigintHandler) {
|
if (sigintHandler) {
|
||||||
sigintHandler();
|
sigintHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
"📡 Starting 1 provider(s): web",
|
"📡 Starting 1 provider(s): web",
|
||||||
);
|
);
|
||||||
expect(monitorWebProvider).toHaveBeenCalledWith(
|
expect(monitorWebProvider).toHaveBeenCalledWith(
|
||||||
true,
|
true,
|
||||||
undefined,
|
undefined,
|
||||||
true,
|
true,
|
||||||
undefined,
|
undefined,
|
||||||
mockRuntime,
|
mockRuntime,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
{ ...webTuning, suppressStartMessage: true },
|
{ ...webTuning, suppressStartMessage: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("starts twilio provider with custom interval and lookback", async () => {
|
test("starts twilio provider with custom interval and lookback", async () => {
|
||||||
const { monitorTwilio } = await import("../twilio/monitor.js");
|
const { monitorTwilio } = await import("../twilio/monitor.js");
|
||||||
vi.mocked(monitorTwilio).mockResolvedValue(undefined);
|
vi.mocked(monitorTwilio).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const providers: Provider[] = ["twilio"];
|
const providers: Provider[] = ["twilio"];
|
||||||
|
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
twilioInterval: 30,
|
twilioInterval: 30,
|
||||||
twilioLookback: 10,
|
twilioLookback: 10,
|
||||||
runtime: mockRuntime,
|
runtime: mockRuntime,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for startup message
|
// Wait for startup message
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Abort the relay
|
// Abort the relay
|
||||||
if (sigintHandler) {
|
if (sigintHandler) {
|
||||||
sigintHandler();
|
sigintHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
"📡 Starting 1 provider(s): twilio",
|
"📡 Starting 1 provider(s): twilio",
|
||||||
);
|
);
|
||||||
expect(monitorTwilio).toHaveBeenCalledWith(30, 10);
|
expect(monitorTwilio).toHaveBeenCalledWith(30, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("starts multiple providers concurrently", async () => {
|
test("starts multiple providers concurrently", async () => {
|
||||||
const { monitorTelegramProvider } = await import(
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
"../telegram/monitor.js"
|
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
||||||
);
|
|
||||||
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
||||||
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
const providers: Provider[] = ["telegram", "web"];
|
||||||
|
|
||||||
const providers: Provider[] = ["telegram", "web"];
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
|
verbose: true,
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
runtime: mockRuntime,
|
||||||
verbose: true,
|
});
|
||||||
runtime: mockRuntime,
|
|
||||||
});
|
// Wait for startup message
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
// Wait for startup message
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
// Abort the relay
|
||||||
|
if (sigintHandler) {
|
||||||
// Abort the relay
|
sigintHandler();
|
||||||
if (sigintHandler) {
|
}
|
||||||
sigintHandler();
|
|
||||||
}
|
await promise;
|
||||||
|
|
||||||
await promise;
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
|
"📡 Starting 2 provider(s): telegram, web",
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
);
|
||||||
"📡 Starting 2 provider(s): telegram, web",
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||||
);
|
expect(monitorWebProvider).toHaveBeenCalled();
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalled();
|
});
|
||||||
expect(monitorWebProvider).toHaveBeenCalled();
|
|
||||||
});
|
test("shows startup complete message after 1.5s", async () => {
|
||||||
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
test("shows startup complete message after 1.5s", async () => {
|
vi.mocked(monitorTelegramProvider).mockImplementation(
|
||||||
const { monitorTelegramProvider } = await import(
|
() => new Promise(() => {}), // Never resolves
|
||||||
"../telegram/monitor.js"
|
);
|
||||||
);
|
|
||||||
vi.mocked(monitorTelegramProvider).mockImplementation(
|
const providers: Provider[] = ["telegram"];
|
||||||
() => new Promise(() => {}), // Never resolves
|
|
||||||
);
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
|
verbose: true,
|
||||||
const providers: Provider[] = ["telegram"];
|
runtime: mockRuntime,
|
||||||
|
});
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
|
||||||
verbose: true,
|
// Wait for startup complete message (1.5s + buffer)
|
||||||
runtime: mockRuntime,
|
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||||
});
|
|
||||||
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
// Wait for startup complete message (1.5s + buffer)
|
"✅ All 1 provider(s) active. Listening for messages... (Ctrl+C to stop)",
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
);
|
||||||
|
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
// Abort the relay
|
||||||
"✅ All 1 provider(s) active. Listening for messages... (Ctrl+C to stop)",
|
if (sigintHandler) {
|
||||||
);
|
sigintHandler();
|
||||||
|
}
|
||||||
// Abort the relay
|
|
||||||
if (sigintHandler) {
|
// Wait for abort to complete
|
||||||
sigintHandler();
|
await Promise.race([
|
||||||
}
|
promise,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 500)),
|
||||||
// Wait for abort to complete
|
]);
|
||||||
await Promise.race([promise, new Promise((resolve) => setTimeout(resolve, 500))]);
|
});
|
||||||
});
|
|
||||||
|
test("handles SIGINT gracefully", async () => {
|
||||||
test("handles SIGINT gracefully", async () => {
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
const { monitorTelegramProvider } = await import(
|
let abortSignal: AbortSignal | undefined;
|
||||||
"../telegram/monitor.js"
|
vi.mocked(monitorTelegramProvider).mockImplementation(
|
||||||
);
|
async (_verbose, _runtime, signal) => {
|
||||||
let abortSignal: AbortSignal | undefined;
|
abortSignal = signal;
|
||||||
vi.mocked(monitorTelegramProvider).mockImplementation(
|
// Simulate waiting for abort
|
||||||
async (_verbose, _runtime, signal) => {
|
return new Promise((resolve) => {
|
||||||
abortSignal = signal;
|
signal?.addEventListener("abort", () => resolve(undefined));
|
||||||
// Simulate waiting for abort
|
});
|
||||||
return new Promise((resolve) => {
|
},
|
||||||
signal?.addEventListener("abort", () => resolve(undefined));
|
);
|
||||||
});
|
|
||||||
},
|
const providers: Provider[] = ["telegram"];
|
||||||
);
|
|
||||||
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
const providers: Provider[] = ["telegram"];
|
verbose: true,
|
||||||
|
runtime: mockRuntime,
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
});
|
||||||
verbose: true,
|
|
||||||
runtime: mockRuntime,
|
// Wait for monitor to start
|
||||||
});
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Wait for monitor to start
|
// Trigger SIGINT
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
if (sigintHandler) {
|
||||||
|
sigintHandler();
|
||||||
// Trigger SIGINT
|
}
|
||||||
if (sigintHandler) {
|
|
||||||
sigintHandler();
|
await promise;
|
||||||
}
|
|
||||||
|
expect(mockRuntime.log).toHaveBeenCalledWith(
|
||||||
await promise;
|
"\n⏹ Stopping all providers...",
|
||||||
|
);
|
||||||
expect(mockRuntime.log).toHaveBeenCalledWith(
|
expect(mockRuntime.log).toHaveBeenCalledWith("✅ All providers stopped");
|
||||||
"\n⏹ Stopping all providers...",
|
expect(abortSignal?.aborted).toBe(true);
|
||||||
);
|
});
|
||||||
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");
|
||||||
test("handles provider errors without crashing other providers", async () => {
|
|
||||||
const { monitorTelegramProvider } = await import(
|
vi.mocked(monitorTelegramProvider).mockRejectedValue(
|
||||||
"../telegram/monitor.js"
|
new Error("Telegram connection failed"),
|
||||||
);
|
);
|
||||||
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mocked(monitorTelegramProvider).mockRejectedValue(
|
const providers: Provider[] = ["telegram", "web"];
|
||||||
new Error("Telegram connection failed"),
|
|
||||||
);
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
vi.mocked(monitorWebProvider).mockResolvedValue(undefined);
|
verbose: true,
|
||||||
|
runtime: mockRuntime,
|
||||||
const providers: Provider[] = ["telegram", "web"];
|
});
|
||||||
|
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
// Wait for startup and error handling
|
||||||
verbose: true,
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
runtime: mockRuntime,
|
|
||||||
});
|
// Abort the relay
|
||||||
|
if (sigintHandler) {
|
||||||
// Wait for startup and error handling
|
sigintHandler();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
}
|
||||||
|
|
||||||
// Abort the relay
|
await promise;
|
||||||
if (sigintHandler) {
|
|
||||||
sigintHandler();
|
expect(mockRuntime.error).toHaveBeenCalledWith(
|
||||||
}
|
"❌ telegram error: Error: Telegram connection failed",
|
||||||
|
);
|
||||||
await promise;
|
// Web provider should still have been started
|
||||||
|
expect(monitorWebProvider).toHaveBeenCalled();
|
||||||
expect(mockRuntime.error).toHaveBeenCalledWith(
|
});
|
||||||
"❌ telegram error: Error: Telegram connection failed",
|
|
||||||
);
|
test("removes SIGINT handler after completion", async () => {
|
||||||
// Web provider should still have been started
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
expect(monitorWebProvider).toHaveBeenCalled();
|
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
||||||
});
|
|
||||||
|
const providers: Provider[] = ["telegram"];
|
||||||
test("removes SIGINT handler after completion", async () => {
|
|
||||||
const { monitorTelegramProvider } = await import(
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
"../telegram/monitor.js"
|
verbose: true,
|
||||||
);
|
runtime: mockRuntime,
|
||||||
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
});
|
||||||
|
|
||||||
const providers: Provider[] = ["telegram"];
|
// Wait for startup
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
|
||||||
verbose: true,
|
// Abort the relay
|
||||||
runtime: mockRuntime,
|
if (sigintHandler) {
|
||||||
});
|
sigintHandler();
|
||||||
|
}
|
||||||
// Wait for startup
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await promise;
|
||||||
|
|
||||||
// Abort the relay
|
expect(process.off).toHaveBeenCalledWith("SIGINT", sigintHandler);
|
||||||
if (sigintHandler) {
|
});
|
||||||
sigintHandler();
|
|
||||||
}
|
test("passes suppressStartMessage=true to telegram monitor", async () => {
|
||||||
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
await promise;
|
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
||||||
|
|
||||||
expect(process.off).toHaveBeenCalledWith("SIGINT", sigintHandler);
|
const providers: Provider[] = ["telegram"];
|
||||||
});
|
|
||||||
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
test("passes suppressStartMessage=true to telegram monitor", async () => {
|
verbose: true,
|
||||||
const { monitorTelegramProvider } = await import(
|
runtime: mockRuntime,
|
||||||
"../telegram/monitor.js"
|
});
|
||||||
);
|
|
||||||
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
|
// Wait for startup
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
const providers: Provider[] = ["telegram"];
|
|
||||||
|
// Abort the relay
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
if (sigintHandler) {
|
||||||
verbose: true,
|
sigintHandler();
|
||||||
runtime: mockRuntime,
|
}
|
||||||
});
|
|
||||||
|
await promise;
|
||||||
// Wait for startup
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
// Abort the relay
|
mockRuntime,
|
||||||
if (sigintHandler) {
|
expect.any(AbortSignal),
|
||||||
sigintHandler();
|
true, // suppressStartMessage
|
||||||
}
|
);
|
||||||
|
});
|
||||||
await promise;
|
|
||||||
|
test("does not log startup complete if aborted before timeout", async () => {
|
||||||
expect(monitorTelegramProvider).toHaveBeenCalledWith(
|
const { monitorTelegramProvider } = await import("../telegram/monitor.js");
|
||||||
true,
|
vi.mocked(monitorTelegramProvider).mockImplementation(
|
||||||
mockRuntime,
|
async (_verbose, _runtime, signal) => {
|
||||||
expect.any(AbortSignal),
|
return new Promise((resolve) => {
|
||||||
true, // suppressStartMessage
|
signal?.addEventListener("abort", () => resolve(undefined));
|
||||||
);
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
test("does not log startup complete if aborted before timeout", async () => {
|
|
||||||
const { monitorTelegramProvider } = await import(
|
const providers: Provider[] = ["telegram"];
|
||||||
"../telegram/monitor.js"
|
|
||||||
);
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
vi.mocked(monitorTelegramProvider).mockImplementation(
|
verbose: true,
|
||||||
async (_verbose, _runtime, signal) => {
|
runtime: mockRuntime,
|
||||||
return new Promise((resolve) => {
|
});
|
||||||
signal?.addEventListener("abort", () => resolve(undefined));
|
|
||||||
});
|
// Wait briefly, then abort before 1.5s timeout
|
||||||
},
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
);
|
|
||||||
|
// Abort the relay
|
||||||
const providers: Provider[] = ["telegram"];
|
if (sigintHandler) {
|
||||||
|
sigintHandler();
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
}
|
||||||
verbose: true,
|
|
||||||
runtime: mockRuntime,
|
await promise;
|
||||||
});
|
|
||||||
|
// Should not have logged startup complete message
|
||||||
// Wait briefly, then abort before 1.5s timeout
|
const startupCompleteCalls = vi
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
.mocked(mockRuntime.log)
|
||||||
|
.mock.calls.filter((call) =>
|
||||||
// Abort the relay
|
String(call[0]).includes("All 1 provider(s) active"),
|
||||||
if (sigintHandler) {
|
);
|
||||||
sigintHandler();
|
expect(startupCompleteCalls.length).toBe(0);
|
||||||
}
|
});
|
||||||
|
|
||||||
await promise;
|
test("uses default values for twilio interval and lookback", async () => {
|
||||||
|
const { monitorTwilio } = await import("../twilio/monitor.js");
|
||||||
// Should not have logged startup complete message
|
vi.mocked(monitorTwilio).mockResolvedValue(undefined);
|
||||||
const startupCompleteCalls = vi
|
|
||||||
.mocked(mockRuntime.log)
|
const providers: Provider[] = ["twilio"];
|
||||||
.mock.calls.filter((call) =>
|
|
||||||
String(call[0]).includes("All 1 provider(s) active"),
|
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
||||||
);
|
verbose: false,
|
||||||
expect(startupCompleteCalls.length).toBe(0);
|
runtime: mockRuntime,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses default values for twilio interval and lookback", async () => {
|
// Wait for startup
|
||||||
const { monitorTwilio } = await import("../twilio/monitor.js");
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
vi.mocked(monitorTwilio).mockResolvedValue(undefined);
|
|
||||||
|
// Abort the relay
|
||||||
const providers: Provider[] = ["twilio"];
|
if (sigintHandler) {
|
||||||
|
sigintHandler();
|
||||||
const promise = runMultiProviderRelay(providers, mockConfig, mockDeps, {
|
}
|
||||||
verbose: false,
|
|
||||||
runtime: mockRuntime,
|
await promise;
|
||||||
});
|
|
||||||
|
expect(monitorTwilio).toHaveBeenCalledWith(10, 5); // defaults
|
||||||
// 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 type { WarelayConfig } from "../config/config.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
import type { WebMonitorTuning } from "../web/auto-reply.js";
|
import type { WebMonitorTuning } from "../web/auto-reply.js";
|
||||||
import type { CliDeps } from "./deps.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.
|
* Handles graceful shutdown and per-provider error recovery.
|
||||||
*/
|
*/
|
||||||
export async function runMultiProviderRelay(
|
export async function runMultiProviderRelay(
|
||||||
providers: Provider[],
|
providers: Provider[],
|
||||||
config: WarelayConfig,
|
config: WarelayConfig,
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
opts: {
|
opts: {
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
webTuning?: WebMonitorTuning;
|
webTuning?: WebMonitorTuning;
|
||||||
twilioInterval?: number;
|
twilioInterval?: number;
|
||||||
twilioLookback?: number;
|
twilioLookback?: number;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const { signal } = abortController;
|
const { signal } = abortController;
|
||||||
|
|
||||||
// Setup Ctrl+C handler
|
// Setup Ctrl+C handler
|
||||||
const sigintHandler = () => {
|
const sigintHandler = () => {
|
||||||
runtime.log("\n⏹ Stopping all providers...");
|
runtime.log("\n⏹ Stopping all providers...");
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
process.on("SIGINT", sigintHandler);
|
process.on("SIGINT", sigintHandler);
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`📡 Starting ${providers.length} provider(s): ${providers.join(", ")}`,
|
`📡 Starting ${providers.length} provider(s): ${providers.join(", ")}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let startupComplete = false;
|
let startupComplete = false;
|
||||||
|
|
||||||
// Spawn monitors concurrently
|
// Spawn monitors concurrently
|
||||||
const monitorPromises = providers.map(async (provider) => {
|
const monitorPromises = providers.map(async (provider) => {
|
||||||
try {
|
try {
|
||||||
if (provider === "telegram") {
|
if (provider === "telegram") {
|
||||||
const { monitorTelegramProvider } = await import(
|
const { monitorTelegramProvider } = await import(
|
||||||
"../telegram/monitor.js"
|
"../telegram/monitor.js"
|
||||||
);
|
);
|
||||||
await monitorTelegramProvider(
|
await monitorTelegramProvider(
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
signal,
|
signal,
|
||||||
true, // suppressStartMessage
|
true, // suppressStartMessage
|
||||||
);
|
);
|
||||||
} else if (provider === "web") {
|
} else if (provider === "web") {
|
||||||
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
const { monitorWebProvider } = await import("../web/auto-reply.js");
|
||||||
await monitorWebProvider(
|
await monitorWebProvider(
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
undefined,
|
undefined,
|
||||||
true,
|
true,
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
signal,
|
signal,
|
||||||
{ ...opts.webTuning, suppressStartMessage: true },
|
{ ...opts.webTuning, suppressStartMessage: true },
|
||||||
);
|
);
|
||||||
} else if (provider === "twilio") {
|
} else if (provider === "twilio") {
|
||||||
const { monitorTwilio } = await import("../twilio/monitor.js");
|
const { monitorTwilio } = await import("../twilio/monitor.js");
|
||||||
const intervalSeconds = opts.twilioInterval ?? 10;
|
const intervalSeconds = opts.twilioInterval ?? 10;
|
||||||
const lookbackMinutes = opts.twilioLookback ?? 5;
|
const lookbackMinutes = opts.twilioLookback ?? 5;
|
||||||
|
|
||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (signal.aborted) return; // Graceful shutdown
|
if (signal.aborted) return; // Graceful shutdown
|
||||||
runtime.error(`❌ ${provider} error: ${String(err)}`);
|
runtime.error(`❌ ${provider} error: ${String(err)}`);
|
||||||
// Continue - don't crash other providers
|
// Continue - don't crash other providers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a brief moment for all providers to initialize, then show summary
|
// Wait a brief moment for all providers to initialize, then show summary
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!startupComplete && !signal.aborted) {
|
if (!startupComplete && !signal.aborted) {
|
||||||
startupComplete = true;
|
startupComplete = true;
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`✅ All ${providers.length} provider(s) active. Listening for messages... (Ctrl+C to stop)`,
|
`✅ All ${providers.length} provider(s) active. Listening for messages... (Ctrl+C to stop)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
// Wait for all monitors (or abort)
|
// Wait for all monitors (or abort)
|
||||||
await Promise.allSettled(monitorPromises);
|
await Promise.allSettled(monitorPromises);
|
||||||
|
|
||||||
// Remove SIGINT handler
|
// Remove SIGINT handler
|
||||||
process.off("SIGINT", sigintHandler);
|
process.off("SIGINT", sigintHandler);
|
||||||
|
|
||||||
runtime.log("✅ All providers stopped");
|
runtime.log("✅ All providers stopped");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,7 +124,9 @@ export function buildProgram() {
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("login")
|
.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("--provider <provider>", "Provider: web | telegram", "web")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
@ -141,7 +143,9 @@ export function buildProgram() {
|
|||||||
}
|
}
|
||||||
await loginWeb(Boolean(opts.verbose));
|
await loginWeb(Boolean(opts.verbose));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`${provider} login failed: ${String(err)}`));
|
defaultRuntime.error(
|
||||||
|
danger(`${provider} login failed: ${String(err)}`),
|
||||||
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -192,7 +196,11 @@ export function buildProgram() {
|
|||||||
"20",
|
"20",
|
||||||
)
|
)
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
.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("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
@ -383,7 +391,10 @@ Examples:
|
|||||||
.command("relay")
|
.command("relay")
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||||
.option("--provider <provider>", "auto | web | twilio | telegram", "auto")
|
.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("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||||
.option(
|
.option(
|
||||||
"-l, --lookback <minutes>",
|
"-l, --lookback <minutes>",
|
||||||
@ -538,7 +549,9 @@ Examples:
|
|||||||
// Single-provider relay logic
|
// Single-provider relay logic
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
if (!["auto", "web", "twilio", "telegram"].includes(providerPref)) {
|
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);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -825,7 +838,9 @@ Examples:
|
|||||||
// Identity management commands
|
// Identity management commands
|
||||||
const identity = program
|
const identity = program
|
||||||
.command("identity")
|
.command("identity")
|
||||||
.description("Manage cross-provider identity mappings for shared Claude sessions");
|
.description(
|
||||||
|
"Manage cross-provider identity mappings for shared Claude sessions",
|
||||||
|
);
|
||||||
|
|
||||||
identity
|
identity
|
||||||
.command("link")
|
.command("link")
|
||||||
@ -893,7 +908,9 @@ Examples:
|
|||||||
|
|
||||||
identity
|
identity
|
||||||
.command("unlink <id>")
|
.command("unlink <id>")
|
||||||
.description("Unlink an identity mapping (providers will have separate sessions)")
|
.description(
|
||||||
|
"Unlink an identity mapping (providers will have separate sessions)",
|
||||||
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
|
|||||||
@ -22,12 +22,14 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { readEnv } from "../env.js";
|
import { ensureTwilioEnv, readEnv } from "../env.js";
|
||||||
import { ensureTwilioEnv } from "../env.js";
|
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { pickProvider } from "../provider-web.js";
|
import { pickProvider } from "../provider-web.js";
|
||||||
|
import type {
|
||||||
|
ProviderMedia,
|
||||||
|
TelegramProviderConfig,
|
||||||
|
} from "../providers/base/types.js";
|
||||||
import { createInitializedProvider } from "../providers/factory.js";
|
import { createInitializedProvider } from "../providers/factory.js";
|
||||||
import type { TelegramProviderConfig, ProviderMedia } from "../providers/base/types.js";
|
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
import { sendViaIpc } from "../web/ipc.js";
|
import { sendViaIpc } from "../web/ipc.js";
|
||||||
@ -442,8 +444,10 @@ export async function agentCommand(
|
|||||||
sessionDir: undefined,
|
sessionDir: undefined,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
};
|
};
|
||||||
const telegramProvider =
|
const telegramProvider = await createInitializedProvider(
|
||||||
await createInitializedProvider("telegram", telegramConfig);
|
"telegram",
|
||||||
|
telegramConfig,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chunks = chunkText(text, 4096);
|
const chunks = chunkText(text, 4096);
|
||||||
@ -451,7 +455,9 @@ export async function agentCommand(
|
|||||||
const firstChunk = chunks.length > 0 ? chunks[0] : "";
|
const firstChunk = chunks.length > 0 ? chunks[0] : "";
|
||||||
const firstMedia = media[0];
|
const firstMedia = media[0];
|
||||||
await telegramProvider.send(opts.to, firstChunk, {
|
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++) {
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import { danger, info, success, warn } from "../globals.js";
|
||||||
import {
|
import {
|
||||||
deleteMapping,
|
deleteMapping,
|
||||||
getMapping,
|
getMapping,
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
setMapping,
|
setMapping,
|
||||||
} from "../identity/storage.js";
|
} from "../identity/storage.js";
|
||||||
import type { IdentityMapping } from "../identity/types.js";
|
import type { IdentityMapping } from "../identity/types.js";
|
||||||
import { danger, info, success, warn } from "../globals.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type IdentityLinkOpts = {
|
type IdentityLinkOpts = {
|
||||||
whatsapp?: string;
|
whatsapp?: string;
|
||||||
@ -122,7 +122,9 @@ export async function identityLinkCommand(
|
|||||||
try {
|
try {
|
||||||
await setMapping(mapping);
|
await setMapping(mapping);
|
||||||
runtime.log(
|
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("");
|
||||||
runtime.log(info("Linked identities:"));
|
runtime.log(info("Linked identities:"));
|
||||||
@ -179,9 +181,7 @@ export async function identityListCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (mapping.identities.telegram) {
|
if (mapping.identities.telegram) {
|
||||||
runtime.log(
|
runtime.log(` Telegram: ${chalk.blue(mapping.identities.telegram)}`);
|
||||||
` Telegram: ${chalk.blue(mapping.identities.telegram)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (mapping.identities.twilio) {
|
if (mapping.identities.twilio) {
|
||||||
runtime.log(` Twilio: ${chalk.yellow(mapping.identities.twilio)}`);
|
runtime.log(` Twilio: ${chalk.yellow(mapping.identities.twilio)}`);
|
||||||
@ -248,9 +248,7 @@ export async function identityShowCommand(
|
|||||||
}
|
}
|
||||||
runtime.log("");
|
runtime.log("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(danger(`Failed to show identity mapping: ${String(err)}`));
|
||||||
danger(`Failed to show identity mapping: ${String(err)}`),
|
|
||||||
);
|
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -304,9 +302,7 @@ export async function identityUnlinkCommand(
|
|||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(danger(`Failed to unlink identity mapping: ${String(err)}`));
|
||||||
danger(`Failed to unlink identity mapping: ${String(err)}`),
|
|
||||||
);
|
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,7 +188,9 @@ export async function sendCommand(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(`✅ Sent to ${opts.to} via Telegram (id ${result.messageId})`),
|
success(
|
||||||
|
`✅ Sent to ${opts.to} via Telegram (id ${result.messageId})`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import { deriveSessionKey } from "./sessions.js";
|
|||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
it("returns normalized per-sender key", async () => {
|
it("returns normalized per-sender key", async () => {
|
||||||
expect(await deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
expect(
|
||||||
"+1555",
|
await deriveSessionKey("per-sender", { From: "whatsapp:+1555" }),
|
||||||
);
|
).toBe("+1555");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to unknown when sender missing", async () => {
|
it("falls back to unknown when sender missing", async () => {
|
||||||
@ -18,8 +18,8 @@ describe("sessions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps group chats distinct", async () => {
|
it("keeps group chats distinct", async () => {
|
||||||
expect(await deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe(
|
expect(
|
||||||
"group:12345-678@g.us",
|
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 JSON5 from "json5";
|
||||||
import type { MsgContext } from "../auto-reply/templating.js";
|
import type { MsgContext } from "../auto-reply/templating.js";
|
||||||
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
|
||||||
import { normalizeSessionId } from "../identity/normalize.js";
|
import { normalizeSessionId } from "../identity/normalize.js";
|
||||||
|
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
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.
|
* 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 (provider === "telegram") {
|
||||||
if (from.startsWith("telegram:")) {
|
if (from.startsWith("telegram:")) {
|
||||||
return from.slice("telegram:".length);
|
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).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
// Now supports identity mapping for cross-provider session sharing.
|
// 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";
|
if (scope === "global") return "global";
|
||||||
const from = ctx.From ? ctx.From : "";
|
const from = ctx.From ? ctx.From : "";
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,6 @@
|
|||||||
* 3. Unlink if needed: `warelay identity unlink <shared-id>`
|
* 3. Unlink if needed: `warelay identity unlink <shared-id>`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./types.js";
|
|
||||||
export * from "./storage.js";
|
|
||||||
export * from "./normalize.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 { 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";
|
import * as storage from "./storage.js";
|
||||||
|
|
||||||
vi.mock("./storage.js");
|
vi.mock("./storage.js");
|
||||||
@ -78,19 +78,12 @@ describe("normalizeSessionId", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// First call for Telegram
|
// First call for Telegram
|
||||||
vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(
|
vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(mockMapping);
|
||||||
mockMapping,
|
|
||||||
);
|
|
||||||
const telegramResult = await normalizeSessionId("telegram", "987654321");
|
const telegramResult = await normalizeSessionId("telegram", "987654321");
|
||||||
|
|
||||||
// Second call for WhatsApp
|
// Second call for WhatsApp
|
||||||
vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(
|
vi.mocked(storage.findMappingByIdentity).mockResolvedValueOnce(mockMapping);
|
||||||
mockMapping,
|
const whatsappResult = await normalizeSessionId("whatsapp", "+9876543210");
|
||||||
);
|
|
||||||
const whatsappResult = await normalizeSessionId(
|
|
||||||
"whatsapp",
|
|
||||||
"+9876543210",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(telegramResult).toBe("shared-xyz-456");
|
expect(telegramResult).toBe("shared-xyz-456");
|
||||||
expect(whatsappResult).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 { 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", () => {
|
describe("telegram client", () => {
|
||||||
const mockRuntime: RuntimeEnv = {
|
const mockRuntime: RuntimeEnv = {
|
||||||
|
|||||||
@ -6,7 +6,15 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
import {
|
import {
|
||||||
cleanOrphanedTempFiles,
|
cleanOrphanedTempFiles,
|
||||||
ensureTempDir,
|
ensureTempDir,
|
||||||
@ -23,6 +31,14 @@ describe("download", () => {
|
|||||||
`warelay-test-${process.pid}-download`,
|
`warelay-test-${process.pid}-download`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.TELEGRAM_TEMP_DIR = testTempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.TELEGRAM_TEMP_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clean test directory before each test
|
// Clean test directory before each test
|
||||||
await fs.rm(testTempDir, { recursive: true, force: true }).catch(() => {});
|
await fs.rm(testTempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
@ -32,11 +48,7 @@ describe("download", () => {
|
|||||||
describe("getTelegramTempDir", () => {
|
describe("getTelegramTempDir", () => {
|
||||||
it("returns correct path", () => {
|
it("returns correct path", () => {
|
||||||
const dir = getTelegramTempDir();
|
const dir = getTelegramTempDir();
|
||||||
// Should contain either .clawdis or .warelay (depending on which exists)
|
expect(dir).toBe(testTempDir);
|
||||||
const hasCorrectDir =
|
|
||||||
dir.includes(".clawdis") || dir.includes(".warelay");
|
|
||||||
expect(hasCorrectDir).toBe(true);
|
|
||||||
expect(dir).toContain("telegram-temp");
|
|
||||||
expect(path.isAbsolute(dir)).toBe(true);
|
expect(path.isAbsolute(dir)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -110,8 +122,8 @@ describe("download", () => {
|
|||||||
expect(result.contentType).toBe("text/plain");
|
expect(result.contentType).toBe("text/plain");
|
||||||
|
|
||||||
// Verify path is in temp directory
|
// Verify path is in temp directory
|
||||||
expect(result.tempPath).toContain("telegram-temp");
|
expect(result.tempPath.startsWith(testTempDir)).toBe(true);
|
||||||
expect(result.tempPath).toMatch(/telegram-dl-.*\.tmp$/);
|
expect(path.basename(result.tempPath)).toMatch(/telegram-dl-.*\.tmp$/);
|
||||||
} finally {
|
} finally {
|
||||||
await result.cleanup();
|
await result.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,43 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { createWriteStream } from "node:fs";
|
import fsSync, { createWriteStream } from "node:fs";
|
||||||
import fsSync from "node:fs";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Transform } from "node:stream";
|
import { Transform } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { canUseDir } from "../utils.js";
|
||||||
|
|
||||||
// Prefer ~/.clawdis/telegram-temp, but fall back to ~/.warelay for compatibility
|
// 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_CLAWDIS = path.join(os.homedir(), ".clawdis", "telegram-temp");
|
||||||
const TEMP_DIR_LEGACY = path.join(os.homedir(), ".warelay", "telegram-temp");
|
const TEMP_DIR_LEGACY = path.join(os.homedir(), ".warelay", "telegram-temp");
|
||||||
|
|
||||||
function resolveTempDir(): string {
|
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
|
// Use CLAWDIS path if the main config directory exists, otherwise legacy
|
||||||
const clawdisConfigExists = fsSync.existsSync(
|
const clawdisConfigExists = fsSync.existsSync(
|
||||||
path.join(os.homedir(), ".clawdis"),
|
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
|
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.
|
* Uses ~/.clawdis/telegram-temp for consistency with media store.
|
||||||
*/
|
*/
|
||||||
export function getTelegramTempDir(): string {
|
export function getTelegramTempDir(): string {
|
||||||
return TEMP_DIR;
|
return getTempDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure temp directory exists.
|
* Ensure temp directory exists.
|
||||||
*/
|
*/
|
||||||
export async function ensureTempDir(): Promise<string> {
|
export async function ensureTempDir(): Promise<string> {
|
||||||
await fs.mkdir(TEMP_DIR, { recursive: true });
|
const dir = getTempDir();
|
||||||
return TEMP_DIR;
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,7 +103,7 @@ export async function streamDownloadToTemp(
|
|||||||
|
|
||||||
// Generate unique temp file name
|
// Generate unique temp file name
|
||||||
const filename = `telegram-dl-${crypto.randomUUID()}.tmp`;
|
const filename = `telegram-dl-${crypto.randomUUID()}.tmp`;
|
||||||
const tempPath = path.join(TEMP_DIR, filename);
|
const tempPath = path.join(getTempDir(), filename);
|
||||||
|
|
||||||
// Fetch response
|
// Fetch response
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@ -153,12 +172,13 @@ export async function cleanOrphanedTempFiles(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureTempDir();
|
await ensureTempDir();
|
||||||
const entries = await fs.readdir(TEMP_DIR).catch(() => []);
|
const tempDir = getTempDir();
|
||||||
|
const entries = await fs.readdir(tempDir).catch(() => []);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(async (file) => {
|
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);
|
const stat = await fs.stat(fullPath).catch(() => null);
|
||||||
if (!stat) return;
|
if (!stat) return;
|
||||||
|
|
||||||
|
|||||||
@ -57,7 +57,7 @@ describe("convertTelegramMessage", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: "999",
|
id: "999",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello, world!",
|
body: "Hello, world!",
|
||||||
timestamp: 1234567890000,
|
timestamp: 1234567890000,
|
||||||
@ -121,7 +121,7 @@ describe("convertTelegramMessage", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
|
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 () => {
|
it("uses ID when neither username nor phone available", async () => {
|
||||||
@ -145,7 +145,7 @@ describe("convertTelegramMessage", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
|
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 () => {
|
it("uses Unknown when no identifiable info available", async () => {
|
||||||
@ -166,7 +166,7 @@ describe("convertTelegramMessage", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
|
const result = await convertTelegramMessage(mockEvent as NewMessageEvent);
|
||||||
expect(result?.from).toBe("unknown");
|
expect(result?.from).toBe("telegram:unknown");
|
||||||
expect(result?.displayName).toBe("Unknown");
|
expect(result?.displayName).toBe("Unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -501,7 +501,7 @@ describe("isAllowedSender", () => {
|
|||||||
it("allows all senders when no whitelist provided", () => {
|
it("allows all senders when no whitelist provided", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@ -515,83 +515,102 @@ describe("isAllowedSender", () => {
|
|||||||
it("allows sender with matching username", () => {
|
it("allows sender with matching username", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, ["@testuser"])).toBe(true);
|
expect(isAllowedSender(message, ["telegram:@testuser"])).toBe(true);
|
||||||
expect(isAllowedSender(message, ["testuser"])).toBe(true); // Without @
|
expect(isAllowedSender(message, ["telegram:testuser"])).toBe(true); // Without @
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows sender with matching phone", () => {
|
it("allows sender with matching phone", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "+1234567890",
|
from: "telegram:+1234567890",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, ["+1234567890"])).toBe(true);
|
expect(isAllowedSender(message, ["telegram:+1234567890"])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects sender not in whitelist", () => {
|
it("rejects sender not in whitelist", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@unauthorized",
|
from: "telegram:@unauthorized",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, ["@testuser", "+1234567890"])).toBe(false);
|
expect(
|
||||||
|
isAllowedSender(message, ["telegram:@testuser", "telegram:+1234567890"]),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case insensitive", () => {
|
it("is case insensitive", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@TestUser",
|
from: "telegram:@TestUser",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, ["@testuser"])).toBe(true);
|
expect(isAllowedSender(message, ["telegram:@testuser"])).toBe(true);
|
||||||
expect(isAllowedSender(message, ["TESTUSER"])).toBe(true);
|
expect(isAllowedSender(message, ["telegram:TESTUSER"])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims whitespace from whitelist entries", () => {
|
it("trims whitespace from whitelist entries", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, [" @testuser "])).toBe(true);
|
expect(isAllowedSender(message, [" telegram:@testuser "])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows sender in multi-entry whitelist", () => {
|
it("allows sender in multi-entry whitelist", () => {
|
||||||
const message: ProviderMessage = {
|
const message: ProviderMessage = {
|
||||||
id: "1",
|
id: "1",
|
||||||
from: "@user2",
|
from: "telegram:@user2",
|
||||||
to: "me",
|
to: "me",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(isAllowedSender(message, ["@user1", "@user2", "+1234567890"])).toBe(
|
expect(
|
||||||
true,
|
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(mockHandler).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "999",
|
id: "999",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
body: "Hello!",
|
body: "Hello!",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -715,7 +734,7 @@ describe("startMessageListener", () => {
|
|||||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
await startMessageListener(mockClient as TelegramClient, mockHandler, [
|
await startMessageListener(mockClient as TelegramClient, mockHandler, [
|
||||||
"@allowed",
|
"telegram:@allowed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Simulate message from non-whitelisted user
|
// Simulate message from non-whitelisted user
|
||||||
@ -743,7 +762,7 @@ describe("startMessageListener", () => {
|
|||||||
|
|
||||||
expect(mockHandler).not.toHaveBeenCalled();
|
expect(mockHandler).not.toHaveBeenCalled();
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
"Ignored message from @unauthorized (not in allowFrom list)",
|
"Ignored message from telegram:@unauthorized (not in allowFrom list)",
|
||||||
);
|
);
|
||||||
|
|
||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
@ -758,7 +777,7 @@ describe("startMessageListener", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await startMessageListener(mockClient as TelegramClient, mockHandler, [
|
await startMessageListener(mockClient as TelegramClient, mockHandler, [
|
||||||
"@testuser",
|
"telegram:@testuser",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Simulate message from whitelisted user
|
// Simulate message from whitelisted user
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type {
|
|||||||
ProviderMedia,
|
ProviderMedia,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
} from "../providers/base/types.js";
|
} from "../providers/base/types.js";
|
||||||
import { normalizeAllowFromEntry } from "../utils.js";
|
import { normalizeAllowFromEntry, TELEGRAM_PREFIX } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Telegram message to ProviderMessage format.
|
* Convert Telegram message to ProviderMessage format.
|
||||||
@ -67,17 +67,19 @@ export async function convertTelegramMessage(
|
|||||||
function extractSenderIdentifier(sender: Api.User | Api.Chat): string {
|
function extractSenderIdentifier(sender: Api.User | Api.Chat): string {
|
||||||
if ("username" in sender && sender.username) {
|
if ("username" in sender && sender.username) {
|
||||||
// Lowercase username for case-insensitive matching (Telegram usernames are case-insensitive)
|
// 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) {
|
if ("phone" in sender && sender.phone) {
|
||||||
// Ensure phone has + prefix for E.164 format to match normalized allowFrom entries
|
// Ensure phone has + prefix for E.164 format to match normalized allowFrom entries
|
||||||
const phone = sender.phone.startsWith("+") ? sender.phone : `+${sender.phone}`;
|
const phone = sender.phone.startsWith("+")
|
||||||
return `telegram:${phone}`;
|
? sender.phone
|
||||||
|
: `+${sender.phone}`;
|
||||||
|
return `${TELEGRAM_PREFIX}${phone}`;
|
||||||
}
|
}
|
||||||
if ("id" in sender && sender.id) {
|
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", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: vi.fn(() => ({
|
loadConfig: vi.fn(() => ({
|
||||||
inbound: {
|
inbound: {
|
||||||
allowFrom: ["@testuser"],
|
allowFrom: ["telegram:@testuser"],
|
||||||
|
timestampPrefix: false,
|
||||||
reply: {
|
reply: {
|
||||||
mode: "text",
|
mode: "text",
|
||||||
text: "Auto-reply test",
|
text: "Auto-reply test",
|
||||||
@ -141,7 +142,7 @@ describe("monitorTelegramProvider", () => {
|
|||||||
apiId: 12345,
|
apiId: 12345,
|
||||||
apiHash: "test-hash",
|
apiHash: "test-hash",
|
||||||
sessionDir: undefined,
|
sessionDir: undefined,
|
||||||
allowFrom: ["@testuser"],
|
allowFrom: ["telegram:@testuser"],
|
||||||
verbose: true,
|
verbose: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -160,8 +161,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
if (messageHandler) {
|
if (messageHandler) {
|
||||||
const testMessage: ProviderMessage = {
|
const testMessage: ProviderMessage = {
|
||||||
id: "test-msg-123",
|
id: "test-msg-123",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "@botuser",
|
to: "telegram:@botuser",
|
||||||
body: "Hello bot",
|
body: "Hello bot",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
@ -180,8 +181,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
expect(getReplyFromConfig).toHaveBeenCalledWith(
|
expect(getReplyFromConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
Body: "Hello bot",
|
Body: "Hello bot",
|
||||||
From: "@testuser",
|
From: "telegram:@testuser",
|
||||||
To: "@botuser",
|
To: "telegram:@botuser",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
onReplyStart: expect.any(Function),
|
onReplyStart: expect.any(Function),
|
||||||
@ -191,7 +192,7 @@ describe("monitorTelegramProvider", () => {
|
|||||||
|
|
||||||
// Verify reply was sent
|
// Verify reply was sent
|
||||||
expect(mockProvider.send).toHaveBeenCalledWith(
|
expect(mockProvider.send).toHaveBeenCalledWith(
|
||||||
"@testuser",
|
"telegram:@testuser",
|
||||||
"Test reply",
|
"Test reply",
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
@ -206,7 +207,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
// Set allowFrom filter
|
// Set allowFrom filter
|
||||||
(loadConfig as Mock).mockReturnValueOnce({
|
(loadConfig as Mock).mockReturnValueOnce({
|
||||||
inbound: {
|
inbound: {
|
||||||
allowFrom: ["@alloweduser"],
|
allowFrom: ["telegram:@alloweduser"],
|
||||||
|
timestampPrefix: false,
|
||||||
reply: {
|
reply: {
|
||||||
mode: "text",
|
mode: "text",
|
||||||
text: "Auto-reply test",
|
text: "Auto-reply test",
|
||||||
@ -219,8 +221,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
if (messageHandler) {
|
if (messageHandler) {
|
||||||
const testMessage: ProviderMessage = {
|
const testMessage: ProviderMessage = {
|
||||||
id: "test-msg-123",
|
id: "test-msg-123",
|
||||||
from: "@notallowed",
|
from: "telegram:@notallowed",
|
||||||
to: "@botuser",
|
to: "telegram:@botuser",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
@ -254,8 +256,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
if (messageHandler) {
|
if (messageHandler) {
|
||||||
const testMessage: ProviderMessage = {
|
const testMessage: ProviderMessage = {
|
||||||
id: "test-msg-456",
|
id: "test-msg-456",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "@botuser",
|
to: "telegram:@botuser",
|
||||||
body: "Send me a picture",
|
body: "Send me a picture",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
@ -279,7 +281,7 @@ describe("monitorTelegramProvider", () => {
|
|||||||
|
|
||||||
// Verify reply with media was sent
|
// Verify reply with media was sent
|
||||||
expect(mockProvider.send).toHaveBeenCalledWith(
|
expect(mockProvider.send).toHaveBeenCalledWith(
|
||||||
"@testuser",
|
"telegram:@testuser",
|
||||||
"Here's an image",
|
"Here's an image",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
media: expect.arrayContaining([
|
media: expect.arrayContaining([
|
||||||
@ -307,8 +309,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
if (messageHandler) {
|
if (messageHandler) {
|
||||||
const testMessage: ProviderMessage = {
|
const testMessage: ProviderMessage = {
|
||||||
id: "test-msg-789",
|
id: "test-msg-789",
|
||||||
from: "@testuser",
|
from: "telegram:@testuser",
|
||||||
to: "@botuser",
|
to: "telegram:@botuser",
|
||||||
body: "Hello",
|
body: "Hello",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
@ -329,6 +331,6 @@ describe("monitorTelegramProvider", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify typing indicator was sent
|
// 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.
|
* Format timestamp in the same format as WhatsApp relay.
|
||||||
* Example: [Dec 5 22:41]
|
* 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 tsCfg = config?.inbound?.timestampPrefix;
|
||||||
const tsEnabled = tsCfg !== false; // default true
|
const tsEnabled = tsCfg !== false; // default true
|
||||||
if (!tsEnabled) return "";
|
if (!tsEnabled) return "";
|
||||||
@ -52,7 +55,7 @@ async function providerMessageToContext(
|
|||||||
: message.body;
|
: message.body;
|
||||||
|
|
||||||
// Handle media: if buffer exists but no URL, save the buffer and create a file path
|
// 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;
|
let mediaPath = message.media?.[0]?.fileName;
|
||||||
|
|
||||||
if (!mediaUrl && message.media?.[0]?.buffer) {
|
if (!mediaUrl && message.media?.[0]?.buffer) {
|
||||||
|
|||||||
@ -345,7 +345,7 @@ describe("TelegramProvider", () => {
|
|||||||
kind: "telegram",
|
kind: "telegram",
|
||||||
apiId: 12345,
|
apiId: 12345,
|
||||||
apiHash: "test-hash",
|
apiHash: "test-hash",
|
||||||
allowFrom: ["@testuser", "+1234567890"],
|
allowFrom: ["telegram:@testuser", "telegram:+1234567890"],
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
|
vi.mocked(sessionModule.loadSession).mockResolvedValue({} as any);
|
||||||
@ -369,7 +369,7 @@ describe("TelegramProvider", () => {
|
|||||||
expect(inboundModule.startMessageListener).toHaveBeenCalledWith(
|
expect(inboundModule.startMessageListener).toHaveBeenCalledWith(
|
||||||
mockClient,
|
mockClient,
|
||||||
handler,
|
handler,
|
||||||
["@testuser", "+1234567890"],
|
["telegram:@testuser", "telegram:+1234567890"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Api, TelegramClient } from "telegram";
|
import type { Api, TelegramClient } from "telegram";
|
||||||
|
import { TELEGRAM_PREFIX } from "../utils.js";
|
||||||
|
|
||||||
// Entity type - can be User, Chat, Channel, or their empty variants
|
// Entity type - can be User, Chat, Channel, or their empty variants
|
||||||
type Entity = Api.User | Api.Chat | Api.Channel;
|
type Entity = Api.User | Api.Chat | Api.Channel;
|
||||||
@ -14,8 +15,8 @@ export async function resolveEntity(
|
|||||||
): Promise<Entity> {
|
): Promise<Entity> {
|
||||||
// Clean identifier and strip telegram: prefix if present
|
// Clean identifier and strip telegram: prefix if present
|
||||||
let clean = identifier.trim();
|
let clean = identifier.trim();
|
||||||
if (clean.startsWith("telegram:")) {
|
if (clean.startsWith(TELEGRAM_PREFIX)) {
|
||||||
clean = clean.slice("telegram:".length);
|
clean = clean.slice(TELEGRAM_PREFIX.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try as-is first (handles @username, phone, user ID)
|
// Try as-is first (handles @username, phone, user ID)
|
||||||
|
|||||||
@ -152,7 +152,9 @@ export async function startWebhook(
|
|||||||
|
|
||||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||||
if (!env.auth || !env.accountSid) {
|
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) {
|
if ("authToken" in env.auth) {
|
||||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
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 });
|
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 type Provider = "twilio" | "web" | "telegram";
|
||||||
|
|
||||||
export function assertProvider(input: string): asserts input is Provider {
|
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 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(
|
export function normalizeAllowFromEntry(
|
||||||
entry: string,
|
entry: string,
|
||||||
provider: AllowFromProvider,
|
provider: AllowFromProvider,
|
||||||
@ -26,18 +47,18 @@ export function normalizeAllowFromEntry(
|
|||||||
|
|
||||||
if (provider === "telegram") {
|
if (provider === "telegram") {
|
||||||
// Strip telegram: prefix if present (allowFrom entries may or may not have it)
|
// Strip telegram: prefix if present (allowFrom entries may or may not have it)
|
||||||
const withoutPrefix = trimmed.startsWith("telegram:")
|
const withoutPrefix = trimmed.startsWith(TELEGRAM_PREFIX)
|
||||||
? trimmed.slice("telegram:".length)
|
? trimmed.slice(TELEGRAM_PREFIX.length)
|
||||||
: trimmed;
|
: trimmed;
|
||||||
|
|
||||||
// Ensure @username format, then add telegram: prefix
|
// Ensure @username format, then add telegram: prefix
|
||||||
const username = withoutPrefix.startsWith("@")
|
const username = withoutPrefix.startsWith("@")
|
||||||
? withoutPrefix
|
? withoutPrefix
|
||||||
: withoutPrefix.match(/^\d+$/)
|
: withoutPrefix.match(/^\d+$/)
|
||||||
? withoutPrefix // numeric ID, keep as-is
|
? withoutPrefix // numeric ID, keep as-is
|
||||||
: `@${withoutPrefix}`;
|
: `@${withoutPrefix}`;
|
||||||
|
|
||||||
return `telegram:${username}`;
|
return `${TELEGRAM_PREFIX}${username}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhatsApp (both web and twilio) use E.164 phone numbers
|
// 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.
|
// Prefer new branding directory; fall back to legacy for compatibility.
|
||||||
export const CONFIG_DIR = (() => {
|
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 clawdis = path.join(os.homedir(), ".clawdis");
|
||||||
const legacy = path.join(os.homedir(), ".warelay");
|
const legacy = path.join(os.homedir(), ".warelay");
|
||||||
if (fs.existsSync(clawdis)) return clawdis;
|
if (canUseDir(clawdis)) return clawdis;
|
||||||
return legacy;
|
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 () => {
|
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.mkdirSync(credsDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
|
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, "{}");
|
fs.writeFileSync(sessionsPath, "{}");
|
||||||
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
|
|
||||||
|
|
||||||
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
|
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
|
||||||
const result = await logoutWeb(runtime as never);
|
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.
|
* Check if Twilio is configured via environment variables.
|
||||||
*/
|
*/
|
||||||
function isTwilioConfigured(): boolean {
|
function isTwilioConfigured(): boolean {
|
||||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||||
const hasRequired = required.every((k) => Boolean(process.env[k]));
|
const hasRequired = required.every((k) => Boolean(process.env[k]));
|
||||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||||
const hasKey = Boolean(
|
const hasKey = Boolean(
|
||||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||||
);
|
);
|
||||||
return hasRequired && (hasToken || hasKey);
|
return hasRequired && (hasToken || hasKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectProviders(
|
export async function selectProviders(
|
||||||
prefs: (Provider | "auto")[],
|
prefs: (Provider | "auto")[],
|
||||||
): Promise<Provider[]> {
|
): Promise<Provider[]> {
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
|
|
||||||
// If 'auto' in list, expand to all authenticated providers
|
// If 'auto' in list, expand to all authenticated providers
|
||||||
if (prefs.includes("auto")) {
|
if (prefs.includes("auto")) {
|
||||||
const available: Provider[] = [];
|
const available: Provider[] = [];
|
||||||
|
|
||||||
// Check web
|
// Check web
|
||||||
if (await webAuthExists()) {
|
if (await webAuthExists()) {
|
||||||
available.push("web");
|
available.push("web");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"web (not authenticated - run: warelay login --provider web)",
|
"web (not authenticated - run: warelay login --provider web)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check telegram
|
// Check telegram
|
||||||
if (await telegramAuthExists()) {
|
if (await telegramAuthExists()) {
|
||||||
available.push("telegram");
|
available.push("telegram");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"telegram (not authenticated - run: warelay login --provider telegram)",
|
"telegram (not authenticated - run: warelay login --provider telegram)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check twilio
|
// Check twilio
|
||||||
if (isTwilioConfigured()) {
|
if (isTwilioConfigured()) {
|
||||||
available.push("twilio");
|
available.push("twilio");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)",
|
"twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (available.length > 0 && skipped.length > 0) {
|
if (available.length > 0 && skipped.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`ℹ️ Auto-selected ${available.length} provider(s), skipped ${skipped.length}:`,
|
`ℹ️ Auto-selected ${available.length} provider(s), skipped ${skipped.length}:`,
|
||||||
);
|
);
|
||||||
for (const skip of skipped) {
|
for (const skip of skipped) {
|
||||||
console.log(` • ${skip}`);
|
console.log(` • ${skip}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit provider list - validate each one
|
// Explicit provider list - validate each one
|
||||||
const available: Provider[] = [];
|
const available: Provider[] = [];
|
||||||
for (const pref of prefs) {
|
for (const pref of prefs) {
|
||||||
if (pref === "auto") continue; // Already handled above
|
if (pref === "auto") continue; // Already handled above
|
||||||
|
|
||||||
if (pref === "web") {
|
if (pref === "web") {
|
||||||
if (await webAuthExists()) {
|
if (await webAuthExists()) {
|
||||||
available.push("web");
|
available.push("web");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"web (not authenticated - run: warelay login --provider web)",
|
"web (not authenticated - run: warelay login --provider web)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (pref === "telegram") {
|
} else if (pref === "telegram") {
|
||||||
if (await telegramAuthExists()) {
|
if (await telegramAuthExists()) {
|
||||||
available.push("telegram");
|
available.push("telegram");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"telegram (not authenticated - run: warelay login --provider telegram)",
|
"telegram (not authenticated - run: warelay login --provider telegram)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (pref === "twilio") {
|
} else if (pref === "twilio") {
|
||||||
if (isTwilioConfigured()) {
|
if (isTwilioConfigured()) {
|
||||||
available.push("twilio");
|
available.push("twilio");
|
||||||
} else {
|
} else {
|
||||||
skipped.push(
|
skipped.push(
|
||||||
"twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)",
|
"twilio (not configured - set TWILIO_ACCOUNT_SID, TWILIO_WHATSAPP_FROM, and auth credentials in .env)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
if (skipped.length > 0) {
|
||||||
console.log(`ℹ️ Skipped ${skipped.length} provider(s):`);
|
console.log(`ℹ️ Skipped ${skipped.length} provider(s):`);
|
||||||
for (const skip of skipped) {
|
for (const skip of skipped) {
|
||||||
console.log(` • ${skip}`);
|
console.log(` • ${skip}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return available;
|
return available;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user