chore: merge origin/main
This commit is contained in:
@@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
if (auth.mode === "token" && !auth.token) {
|
||||
throw new Error(
|
||||
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
|
||||
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const config = loadConfig();
|
||||
const remote =
|
||||
config.gateway?.mode === "remote" ? config.gateway.remote : undefined;
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway.remote : undefined;
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
@@ -39,9 +39,15 @@ export async function callGateway<T = unknown>(
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined);
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof config.gateway?.auth?.token === "string" &&
|
||||
config.gateway.auth.token.trim().length > 0
|
||||
? config.gateway.auth.token.trim()
|
||||
: undefined));
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
|
||||
@@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
let testHooksConfig: Record<string, unknown> | undefined;
|
||||
let testCanvasHostPort: number | undefined;
|
||||
let testLegacyIssues: Array<{ path: string; message: string }> = [];
|
||||
let testLegacyParsed: Record<string, unknown> = {};
|
||||
let testMigrationConfig: Record<string, unknown> | null = null;
|
||||
let testMigrationChanges: string[] = [];
|
||||
let testIsNixMode = false;
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
|
||||
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||
|
||||
const readConfigFileSnapshot = async () => {
|
||||
if (testLegacyIssues.length > 0) {
|
||||
return {
|
||||
path: resolveConfigPath(),
|
||||
exists: true,
|
||||
raw: JSON.stringify(testLegacyParsed ?? {}),
|
||||
parsed: testLegacyParsed ?? {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: testLegacyIssues.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
})),
|
||||
legacyIssues: testLegacyIssues,
|
||||
};
|
||||
}
|
||||
const configPath = resolveConfigPath();
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
@@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
@@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
|
||||
valid: true,
|
||||
config: parsed,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -186,27 +208,32 @@ vi.mock("../config/config.js", () => {
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const writeConfigFile = async (cfg: Record<string, unknown>) => {
|
||||
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
|
||||
const configPath = resolveConfigPath();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await fs.writeFile(configPath, raw, "utf-8");
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||
isNixMode: false,
|
||||
isNixMode: testIsNixMode,
|
||||
migrateLegacyConfig: (raw: unknown) => ({
|
||||
config: testMigrationConfig ?? (raw as Record<string, unknown>),
|
||||
changes: testMigrationChanges,
|
||||
}),
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: testAllowFrom,
|
||||
},
|
||||
session: { mainKey: "main", store: testSessionStorePath },
|
||||
@@ -279,6 +306,11 @@ beforeEach(async () => {
|
||||
testGatewayAuth = undefined;
|
||||
testHooksConfig = undefined;
|
||||
testCanvasHostPort = undefined;
|
||||
testLegacyIssues = [];
|
||||
testLegacyParsed = {};
|
||||
testMigrationConfig = null;
|
||||
testMigrationChanges = [];
|
||||
testIsNixMode = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
resetAgentRunContextForTest();
|
||||
@@ -516,6 +548,40 @@ describe("gateway server", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("auto-migrates legacy config on startup", async () => {
|
||||
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
|
||||
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("fails in Nix mode when legacy config is present", async () => {
|
||||
testLegacyIssues = [
|
||||
{
|
||||
path: "routing.allowFrom",
|
||||
message: "legacy",
|
||||
},
|
||||
];
|
||||
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||
testIsNixMode = true;
|
||||
|
||||
const port = await getFreePort();
|
||||
await expect(startGatewayServer(port)).rejects.toThrow(
|
||||
"Legacy config entries detected while running in Nix mode",
|
||||
);
|
||||
});
|
||||
|
||||
test("models.list returns model catalog", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [
|
||||
@@ -3865,7 +3931,7 @@ describe("gateway server", () => {
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
},
|
||||
"group:dev": {
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
@@ -3977,7 +4043,7 @@ describe("gateway server", () => {
|
||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||
ws,
|
||||
"sessions.delete",
|
||||
{ key: "group:dev" },
|
||||
{ key: "discord:group:dev" },
|
||||
);
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
@@ -3986,7 +4052,9 @@ describe("gateway server", () => {
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(listAfterDelete.ok).toBe(true);
|
||||
expect(
|
||||
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
|
||||
listAfterDelete.payload?.sessions.some(
|
||||
(s) => s.key === "discord:group:dev",
|
||||
),
|
||||
).toBe(false);
|
||||
const filesAfterDelete = await fs.readdir(dir);
|
||||
expect(
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
migrateLegacyConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
STATE_DIR_CLAWDIS,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
@@ -455,6 +457,11 @@ type GatewaySessionsDefaults = {
|
||||
type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
@@ -653,7 +660,6 @@ type DedupeEntry = {
|
||||
error?: ErrorShape;
|
||||
};
|
||||
|
||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||
|
||||
function formatForLog(value: unknown): string {
|
||||
try {
|
||||
@@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
|
||||
return { cfg, storePath, store, entry };
|
||||
}
|
||||
|
||||
function classifySessionKey(key: string): GatewaySessionRow["kind"] {
|
||||
function classifySessionKey(
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): GatewaySessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key.startsWith("group:")) return "group";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
}
|
||||
|
||||
function parseGroupKey(
|
||||
key: string,
|
||||
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||
if (key.startsWith("group:")) {
|
||||
const raw = key.slice("group:".length);
|
||||
return raw ? { id: raw } : null;
|
||||
}
|
||||
const parts = key.split(":").filter(Boolean);
|
||||
if (parts.length >= 3) {
|
||||
const [surface, kind, ...rest] = parts;
|
||||
if (kind === "group" || kind === "channel") {
|
||||
const id = rest.join(":");
|
||||
return { surface, kind, id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
@@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
const output = entry?.outputTokens ?? 0;
|
||||
const total = entry?.totalTokens ?? input + output;
|
||||
const parsed = parseGroupKey(key);
|
||||
const surface = entry?.surface ?? parsed?.surface;
|
||||
const subject = entry?.subject;
|
||||
const room = entry?.room;
|
||||
const space = entry?.space;
|
||||
const id = parsed?.id;
|
||||
const displayName =
|
||||
entry?.displayName ??
|
||||
(surface
|
||||
? buildGroupDisplayName({
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
id,
|
||||
key,
|
||||
})
|
||||
: undefined);
|
||||
return {
|
||||
key,
|
||||
kind: classifySessionKey(key),
|
||||
kind: classifySessionKey(key, entry),
|
||||
displayName,
|
||||
surface,
|
||||
subject,
|
||||
room,
|
||||
space,
|
||||
updatedAt,
|
||||
sessionId: entry?.sessionId,
|
||||
systemSent: entry?.systemSent,
|
||||
@@ -1265,6 +1322,31 @@ export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
if (configSnapshot.legacyIssues.length > 0) {
|
||||
if (isNixMode) {
|
||||
throw new Error(
|
||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
||||
);
|
||||
}
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
configSnapshot.parsed,
|
||||
);
|
||||
if (!migrated) {
|
||||
throw new Error(
|
||||
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
|
||||
);
|
||||
}
|
||||
await writeConfigFile(migrated);
|
||||
if (changes.length > 0) {
|
||||
log.info(
|
||||
`gateway: migrated legacy config entries:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cfgAtStart = loadConfig();
|
||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||
@@ -1288,7 +1370,8 @@ export async function startGatewayServer(
|
||||
...tailscaleOverrides,
|
||||
};
|
||||
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||
const token = getGatewayToken();
|
||||
const token =
|
||||
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
|
||||
const authMode: ResolvedGatewayAuth["mode"] =
|
||||
@@ -2017,6 +2100,15 @@ export async function startGatewayServer(
|
||||
const startTelegramProvider = async () => {
|
||||
if (telegramTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.telegram) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logTelegram.info("skipping provider start (telegram not configured)");
|
||||
return;
|
||||
}
|
||||
if (cfg.telegram?.enabled === false) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
@@ -2111,6 +2203,15 @@ export async function startGatewayServer(
|
||||
const startDiscordProvider = async () => {
|
||||
if (discordTask) return;
|
||||
const cfg = loadConfig();
|
||||
if (!cfg.discord) {
|
||||
discordRuntime = {
|
||||
...discordRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logDiscord.info("skipping provider start (discord not configured)");
|
||||
return;
|
||||
}
|
||||
if (cfg.discord?.enabled === false) {
|
||||
discordRuntime = {
|
||||
...discordRuntime,
|
||||
@@ -2153,9 +2254,7 @@ export async function startGatewayServer(
|
||||
token: discordToken.trim(),
|
||||
runtime: discordRuntimeEnv,
|
||||
abortSignal: discordAbort.signal,
|
||||
allowFrom: cfg.discord?.allowFrom,
|
||||
guildAllowFrom: cfg.discord?.guildAllowFrom,
|
||||
requireMention: cfg.discord?.requireMention,
|
||||
slashCommand: cfg.discord?.slashCommand,
|
||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||
historyLimit: cfg.discord?.historyLimit,
|
||||
})
|
||||
@@ -2216,6 +2315,26 @@ export async function startGatewayServer(
|
||||
logSignal.info("skipping provider start (signal.enabled=false)");
|
||||
return;
|
||||
}
|
||||
const signalCfg = cfg.signal;
|
||||
const signalMeaningfullyConfigured = Boolean(
|
||||
signalCfg.account?.trim() ||
|
||||
signalCfg.httpUrl?.trim() ||
|
||||
signalCfg.cliPath?.trim() ||
|
||||
signalCfg.httpHost?.trim() ||
|
||||
typeof signalCfg.httpPort === "number" ||
|
||||
typeof signalCfg.autoStart === "boolean",
|
||||
);
|
||||
if (!signalMeaningfullyConfigured) {
|
||||
signalRuntime = {
|
||||
...signalRuntime,
|
||||
running: false,
|
||||
lastError: "not configured",
|
||||
};
|
||||
logSignal.info(
|
||||
"skipping provider start (signal config present but missing required fields)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
|
||||
const port = cfg.signal?.httpPort ?? 8080;
|
||||
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
@@ -2881,6 +3000,12 @@ export async function startGatewayServer(
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
displayName: entry?.displayName,
|
||||
chatType: entry?.chatType,
|
||||
surface: entry?.surface,
|
||||
subject: entry?.subject,
|
||||
room: entry?.room,
|
||||
space: entry?.space,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
@@ -4285,21 +4410,33 @@ export async function startGatewayServer(
|
||||
? Math.max(1000, timeoutMsRaw)
|
||||
: 10_000;
|
||||
const cfg = loadConfig();
|
||||
const telegramCfg = cfg.telegram;
|
||||
const telegramEnabled =
|
||||
Boolean(telegramCfg) && telegramCfg?.enabled !== false;
|
||||
const { token: telegramToken, source: tokenSource } =
|
||||
resolveTelegramToken(cfg);
|
||||
telegramEnabled
|
||||
? resolveTelegramToken(cfg)
|
||||
: { token: "", source: "none" as const };
|
||||
let telegramProbe: TelegramProbe | undefined;
|
||||
let lastProbeAt: number | null = null;
|
||||
if (probe && telegramToken) {
|
||||
if (probe && telegramToken && telegramEnabled) {
|
||||
telegramProbe = await probeTelegram(
|
||||
telegramToken,
|
||||
timeoutMs,
|
||||
cfg.telegram?.proxy,
|
||||
telegramCfg?.proxy,
|
||||
);
|
||||
lastProbeAt = Date.now();
|
||||
}
|
||||
|
||||
const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
const discordConfigToken = cfg.discord?.token?.trim();
|
||||
const discordCfg = cfg.discord;
|
||||
const discordEnabled =
|
||||
Boolean(discordCfg) && discordCfg?.enabled !== false;
|
||||
const discordEnvToken = discordEnabled
|
||||
? process.env.DISCORD_BOT_TOKEN?.trim()
|
||||
: "";
|
||||
const discordConfigToken = discordEnabled
|
||||
? discordCfg?.token?.trim()
|
||||
: "";
|
||||
const discordToken = discordEnvToken || discordConfigToken || "";
|
||||
const discordTokenSource = discordEnvToken
|
||||
? "env"
|
||||
@@ -4308,7 +4445,7 @@ export async function startGatewayServer(
|
||||
: "none";
|
||||
let discordProbe: DiscordProbe | undefined;
|
||||
let discordLastProbeAt: number | null = null;
|
||||
if (probe && discordToken) {
|
||||
if (probe && discordToken && discordEnabled) {
|
||||
discordProbe = await probeDiscord(discordToken, timeoutMs);
|
||||
discordLastProbeAt = Date.now();
|
||||
}
|
||||
@@ -4320,7 +4457,17 @@ export async function startGatewayServer(
|
||||
const signalBaseUrl =
|
||||
signalCfg?.httpUrl?.trim() ||
|
||||
`http://${signalHost}:${signalPort}`;
|
||||
const signalConfigured = Boolean(signalCfg) && signalEnabled;
|
||||
const signalConfigured =
|
||||
Boolean(signalCfg) &&
|
||||
signalEnabled &&
|
||||
Boolean(
|
||||
signalCfg?.account?.trim() ||
|
||||
signalCfg?.httpUrl?.trim() ||
|
||||
signalCfg?.cliPath?.trim() ||
|
||||
signalCfg?.httpHost?.trim() ||
|
||||
typeof signalCfg?.httpPort === "number" ||
|
||||
typeof signalCfg?.autoStart === "boolean",
|
||||
);
|
||||
let signalProbe: SignalProbe | undefined;
|
||||
let signalLastProbeAt: number | null = null;
|
||||
if (probe && signalConfigured) {
|
||||
@@ -4362,7 +4509,7 @@ export async function startGatewayServer(
|
||||
lastError: whatsappRuntime.lastError ?? null,
|
||||
},
|
||||
telegram: {
|
||||
configured: Boolean(telegramToken),
|
||||
configured: telegramEnabled && Boolean(telegramToken),
|
||||
tokenSource,
|
||||
running: telegramRuntime.running,
|
||||
mode: telegramRuntime.mode ?? null,
|
||||
@@ -4373,7 +4520,7 @@ export async function startGatewayServer(
|
||||
lastProbeAt,
|
||||
},
|
||||
discord: {
|
||||
configured: Boolean(discordToken),
|
||||
configured: discordEnabled && Boolean(discordToken),
|
||||
tokenSource: discordTokenSource,
|
||||
running: discordRuntime.running,
|
||||
lastStartAt: discordRuntime.lastStartAt ?? null,
|
||||
@@ -6521,7 +6668,7 @@ export async function startGatewayServer(
|
||||
if (explicit) return resolvedTo;
|
||||
|
||||
const cfg = cfgForAgent ?? loadConfig();
|
||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
||||
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||
if (rawAllow.includes("*")) return resolvedTo;
|
||||
const allowFrom = rawAllow
|
||||
.map((val) => normalizeE164(val))
|
||||
|
||||
Reference in New Issue
Block a user