chore: apply biome formatting

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

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

View File

@ -3,3 +3,10 @@ TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here 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
View File

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

View File

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

View File

@ -40,7 +40,8 @@ export const geminiSpec: AgentSpec = {
} }
} }
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent); const shouldPrependIdentity =
ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
const bodyWithIdentity = const bodyWithIdentity =
shouldPrependIdentity && body shouldPrependIdentity && body
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body] ? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
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";
@ -42,12 +42,14 @@ describe("runMultiProviderRelay", () => {
// 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(
(event: string, handler: (...args: unknown[]) => void) => {
if (event === "SIGINT") { if (event === "SIGINT") {
sigintHandler = handler; sigintHandler = handler;
} }
return process; return process;
}) as typeof process.on; },
) as typeof process.on;
process.off = vi.fn() as typeof process.off; process.off = vi.fn() as typeof process.off;
vi.clearAllMocks(); vi.clearAllMocks();
@ -60,9 +62,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("starts telegram provider and logs startup message", async () => { test("starts telegram provider and logs startup message", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
const providers: Provider[] = ["telegram"]; const providers: Provider[] = ["telegram"];
@ -160,9 +160,7 @@ describe("runMultiProviderRelay", () => {
}); });
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(monitorTelegramProvider).mockResolvedValue(undefined);
@ -193,9 +191,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("shows startup complete message after 1.5s", async () => { test("shows startup complete message after 1.5s", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
vi.mocked(monitorTelegramProvider).mockImplementation( vi.mocked(monitorTelegramProvider).mockImplementation(
() => new Promise(() => {}), // Never resolves () => new Promise(() => {}), // Never resolves
); );
@ -220,13 +216,14 @@ describe("runMultiProviderRelay", () => {
} }
// Wait for abort to complete // Wait for abort to complete
await Promise.race([promise, new Promise((resolve) => setTimeout(resolve, 500))]); await Promise.race([
promise,
new Promise((resolve) => setTimeout(resolve, 500)),
]);
}); });
test("handles SIGINT gracefully", async () => { test("handles SIGINT gracefully", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
let abortSignal: AbortSignal | undefined; let abortSignal: AbortSignal | undefined;
vi.mocked(monitorTelegramProvider).mockImplementation( vi.mocked(monitorTelegramProvider).mockImplementation(
async (_verbose, _runtime, signal) => { async (_verbose, _runtime, signal) => {
@ -263,9 +260,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("handles provider errors without crashing other providers", async () => { test("handles provider errors without crashing other providers", 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).mockRejectedValue( vi.mocked(monitorTelegramProvider).mockRejectedValue(
@ -298,9 +293,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("removes SIGINT handler after completion", async () => { test("removes SIGINT handler after completion", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
const providers: Provider[] = ["telegram"]; const providers: Provider[] = ["telegram"];
@ -324,9 +317,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("passes suppressStartMessage=true to telegram monitor", async () => { test("passes suppressStartMessage=true to telegram monitor", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined); vi.mocked(monitorTelegramProvider).mockResolvedValue(undefined);
const providers: Provider[] = ["telegram"]; const providers: Provider[] = ["telegram"];
@ -355,9 +346,7 @@ describe("runMultiProviderRelay", () => {
}); });
test("does not log startup complete if aborted before timeout", async () => { test("does not log startup complete if aborted before timeout", async () => {
const { monitorTelegramProvider } = await import( const { monitorTelegramProvider } = await import("../telegram/monitor.js");
"../telegram/monitor.js"
);
vi.mocked(monitorTelegramProvider).mockImplementation( vi.mocked(monitorTelegramProvider).mockImplementation(
async (_verbose, _runtime, signal) => { async (_verbose, _runtime, signal) => {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

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

View File

@ -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",
` `

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,10 @@ const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit
* Format timestamp in the same format as WhatsApp relay. * 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) {

View File

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

View File

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

View File

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

View File

@ -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,8 +47,8 @@ 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
@ -37,7 +58,7 @@ export function normalizeAllowFromEntry(
? 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;
})(); })();

View File

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