feat: multi-agent routing + multi-account providers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
|
||||
export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage";
|
||||
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
|
||||
|
||||
export type ProviderGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
@@ -17,21 +17,21 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
|
||||
|
||||
function resolveProviderGroups(
|
||||
cfg: ClawdbotConfig,
|
||||
surface: GroupPolicySurface,
|
||||
provider: GroupPolicyProvider,
|
||||
): ProviderGroups | undefined {
|
||||
if (surface === "whatsapp") return cfg.whatsapp?.groups;
|
||||
if (surface === "telegram") return cfg.telegram?.groups;
|
||||
if (surface === "imessage") return cfg.imessage?.groups;
|
||||
if (provider === "whatsapp") return cfg.whatsapp?.groups;
|
||||
if (provider === "telegram") return cfg.telegram?.groups;
|
||||
if (provider === "imessage") return cfg.imessage?.groups;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderGroupPolicy(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
surface: GroupPolicySurface;
|
||||
provider: GroupPolicyProvider;
|
||||
groupId?: string | null;
|
||||
}): ProviderGroupPolicy {
|
||||
const { cfg, surface } = params;
|
||||
const groups = resolveProviderGroups(cfg, surface);
|
||||
const { cfg, provider } = params;
|
||||
const groups = resolveProviderGroups(cfg, provider);
|
||||
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
||||
const normalizedId = params.groupId?.trim();
|
||||
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
||||
@@ -54,7 +54,7 @@ export function resolveProviderGroupPolicy(params: {
|
||||
|
||||
export function resolveProviderGroupRequireMention(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
surface: GroupPolicySurface;
|
||||
provider: GroupPolicyProvider;
|
||||
groupId?: string | null;
|
||||
requireMentionOverride?: boolean;
|
||||
overrideOrder?: "before-config" | "after-config";
|
||||
|
||||
@@ -33,30 +33,30 @@ describe("sessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes group keys with surface when available", () => {
|
||||
it("prefixes group keys with provider when available", () => {
|
||||
expect(
|
||||
deriveSessionKey("per-sender", {
|
||||
From: "12345-678@g.us",
|
||||
ChatType: "group",
|
||||
Surface: "whatsapp",
|
||||
Provider: "whatsapp",
|
||||
}),
|
||||
).toBe("whatsapp:group:12345-678@g.us");
|
||||
});
|
||||
|
||||
it("keeps explicit surface when provided in group key", () => {
|
||||
it("keeps explicit provider when provided in group key", () => {
|
||||
expect(
|
||||
resolveSessionKey(
|
||||
"per-sender",
|
||||
{ From: "group:discord:12345", ChatType: "group" },
|
||||
"main",
|
||||
),
|
||||
).toBe("discord:group:12345");
|
||||
).toBe("agent:main:discord:group:12345");
|
||||
});
|
||||
|
||||
it("builds discord display name with guild+channel slugs", () => {
|
||||
expect(
|
||||
buildGroupDisplayName({
|
||||
surface: "discord",
|
||||
provider: "discord",
|
||||
room: "#general",
|
||||
space: "friends-of-clawd",
|
||||
id: "123",
|
||||
@@ -66,22 +66,24 @@ describe("sessions", () => {
|
||||
});
|
||||
|
||||
it("collapses direct chats to main by default", () => {
|
||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe(
|
||||
"agent:main:main",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses direct chats to main even when sender missing", () => {
|
||||
expect(resolveSessionKey("per-sender", {})).toBe("main");
|
||||
expect(resolveSessionKey("per-sender", {})).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("maps direct chats to main key when provided", () => {
|
||||
expect(
|
||||
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
||||
).toBe("main");
|
||||
).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("uses custom main key when provided", () => {
|
||||
expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe(
|
||||
"primary",
|
||||
"agent:main:primary",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,17 +94,18 @@ describe("sessions", () => {
|
||||
it("leaves groups untouched even with main key", () => {
|
||||
expect(
|
||||
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
|
||||
).toBe("group:12345-678@g.us");
|
||||
).toBe("agent:main:group:12345-678@g.us");
|
||||
});
|
||||
|
||||
it("updateLastRoute persists channel and target", async () => {
|
||||
it("updateLastRoute persists provider and target", async () => {
|
||||
const mainSessionKey = "agent:main:main";
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
[mainSessionKey]: {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: 123,
|
||||
systemSent: true,
|
||||
@@ -117,16 +120,16 @@ describe("sessions", () => {
|
||||
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: "main",
|
||||
channel: "telegram",
|
||||
sessionKey: mainSessionKey,
|
||||
provider: "telegram",
|
||||
to: " 12345 ",
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store.main?.sessionId).toBe("sess-1");
|
||||
expect(store.main?.updatedAt).toBeGreaterThanOrEqual(123);
|
||||
expect(store.main?.lastChannel).toBe("telegram");
|
||||
expect(store.main?.lastTo).toBe("12345");
|
||||
expect(store[mainSessionKey]?.sessionId).toBe("sess-1");
|
||||
expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123);
|
||||
expect(store[mainSessionKey]?.lastProvider).toBe("telegram");
|
||||
expect(store[mainSessionKey]?.lastTo).toBe("12345");
|
||||
});
|
||||
|
||||
it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => {
|
||||
@@ -134,7 +137,7 @@ describe("sessions", () => {
|
||||
{ CLAWDBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
|
||||
() => "/home/ignored",
|
||||
);
|
||||
expect(dir).toBe("/custom/state/sessions");
|
||||
expect(dir).toBe("/custom/state/agents/main/sessions");
|
||||
});
|
||||
|
||||
it("falls back to CLAWDIS_STATE_DIR for session transcripts dir", () => {
|
||||
@@ -142,6 +145,6 @@ describe("sessions", () => {
|
||||
{ CLAWDIS_STATE_DIR: "/legacy/state" } as NodeJS.ProcessEnv,
|
||||
() => "/home/ignored",
|
||||
);
|
||||
expect(dir).toBe("/legacy/state/sessions");
|
||||
expect(dir).toBe("/legacy/state/agents/main/sessions");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,13 @@ import path from "node:path";
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import JSON5 from "json5";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveStateDir } from "./paths.js";
|
||||
|
||||
@@ -59,11 +66,11 @@ export type SessionEntry = {
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
lastChannel?:
|
||||
lastProvider?:
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
@@ -72,13 +79,14 @@ export type SessionEntry = {
|
||||
| "imessage"
|
||||
| "webchat";
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export type GroupKeyResolution = {
|
||||
key: string;
|
||||
legacyKey?: string;
|
||||
surface?: string;
|
||||
provider?: string;
|
||||
id?: string;
|
||||
chatType?: SessionChatType;
|
||||
};
|
||||
@@ -89,26 +97,45 @@ export type SessionSkillSnapshot = {
|
||||
resolvedSkills?: Skill[];
|
||||
};
|
||||
|
||||
function resolveAgentSessionsDir(
|
||||
agentId?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
const root = resolveStateDir(env, homedir);
|
||||
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
||||
return path.join(root, "agents", id, "sessions");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
return path.join(resolveStateDir(env, homedir), "sessions");
|
||||
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
||||
}
|
||||
|
||||
export function resolveDefaultSessionStorePath(): string {
|
||||
return path.join(resolveSessionTranscriptsDir(), "sessions.json");
|
||||
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
export function resolveSessionTranscriptPath(sessionId: string): string {
|
||||
return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`);
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string) {
|
||||
if (!store) return resolveDefaultSessionStorePath();
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
||||
if (!store) return resolveDefaultSessionStorePath(agentId);
|
||||
if (store.includes("{agentId}")) {
|
||||
return path.resolve(
|
||||
store.replaceAll("{agentId}", agentId).replace("~", os.homedir()),
|
||||
);
|
||||
}
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
@@ -116,9 +143,32 @@ export function resolveStorePath(store?: string) {
|
||||
|
||||
export function resolveMainSessionKey(cfg?: {
|
||||
session?: { scope?: SessionScope; mainKey?: string };
|
||||
routing?: { defaultAgentId?: string };
|
||||
}): string {
|
||||
if (cfg?.session?.scope === "global") return "global";
|
||||
return "main";
|
||||
const agentId = normalizeAgentId(
|
||||
cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
const mainKey =
|
||||
(cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
return buildAgentMainSessionKey({ agentId, mainKey });
|
||||
}
|
||||
|
||||
export function resolveAgentIdFromSessionKey(
|
||||
sessionKey?: string | null,
|
||||
): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function resolveAgentMainSessionKey(params: {
|
||||
cfg?: { session?: { mainKey?: string } };
|
||||
agentId: string;
|
||||
}): string {
|
||||
const mainKey =
|
||||
(params.cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() ||
|
||||
DEFAULT_MAIN_KEY;
|
||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
||||
}
|
||||
|
||||
function normalizeGroupLabel(raw?: string) {
|
||||
@@ -137,14 +187,14 @@ function shortenGroupId(value?: string) {
|
||||
}
|
||||
|
||||
export function buildGroupDisplayName(params: {
|
||||
surface?: string;
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
id?: string;
|
||||
key: string;
|
||||
}) {
|
||||
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim();
|
||||
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
|
||||
const room = params.room?.trim();
|
||||
const space = params.space?.trim();
|
||||
const subject = params.subject?.trim();
|
||||
@@ -169,7 +219,7 @@ export function buildGroupDisplayName(params: {
|
||||
) {
|
||||
token = `g-${token}`;
|
||||
}
|
||||
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
||||
return token ? `${providerKey}:${token}` : providerKey;
|
||||
}
|
||||
|
||||
export function resolveGroupSessionKey(
|
||||
@@ -186,13 +236,13 @@ export function resolveGroupSessionKey(
|
||||
from.includes(":channel:");
|
||||
if (!isGroup) return null;
|
||||
|
||||
const surfaceHint = ctx.Surface?.trim().toLowerCase();
|
||||
const providerHint = ctx.Provider?.trim().toLowerCase();
|
||||
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||
const raw = (
|
||||
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||
).trim();
|
||||
|
||||
let surface: string | undefined;
|
||||
let provider: string | undefined;
|
||||
let kind: "group" | "channel" | undefined;
|
||||
let id = "";
|
||||
|
||||
@@ -203,7 +253,7 @@ export function resolveGroupSessionKey(
|
||||
|
||||
const parseParts = (parts: string[]) => {
|
||||
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||
surface = parts[0];
|
||||
provider = parts[0];
|
||||
if (parts.length >= 3) {
|
||||
const kindCandidate = parts[1];
|
||||
if (["group", "channel"].includes(kindCandidate)) {
|
||||
@@ -239,8 +289,8 @@ export function resolveGroupSessionKey(
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSurface = surface ?? surfaceHint;
|
||||
if (!resolvedSurface) {
|
||||
const resolvedProvider = provider ?? providerHint;
|
||||
if (!resolvedProvider) {
|
||||
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||
return {
|
||||
key: legacy,
|
||||
@@ -251,7 +301,7 @@ export function resolveGroupSessionKey(
|
||||
}
|
||||
|
||||
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`;
|
||||
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
|
||||
let legacyKey: string | undefined;
|
||||
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||
legacyKey = `group:${id || raw || from}`;
|
||||
@@ -260,7 +310,7 @@ export function resolveGroupSessionKey(
|
||||
return {
|
||||
key,
|
||||
legacyKey,
|
||||
surface: resolvedSurface,
|
||||
provider: resolvedProvider,
|
||||
id: id || raw || from,
|
||||
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||
};
|
||||
@@ -323,10 +373,11 @@ export async function saveSessionStore(
|
||||
export async function updateLastRoute(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
channel: SessionEntry["lastChannel"];
|
||||
provider: SessionEntry["lastProvider"];
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const { storePath, sessionKey, channel, to } = params;
|
||||
const { storePath, sessionKey, provider, to, accountId } = params;
|
||||
const store = loadSessionStore(storePath);
|
||||
const existing = store[sessionKey];
|
||||
const now = Date.now();
|
||||
@@ -349,13 +400,16 @@ export async function updateLastRoute(params: {
|
||||
contextTokens: existing?.contextTokens,
|
||||
displayName: existing?.displayName,
|
||||
chatType: existing?.chatType,
|
||||
surface: existing?.surface,
|
||||
provider: existing?.provider,
|
||||
subject: existing?.subject,
|
||||
room: existing?.room,
|
||||
space: existing?.space,
|
||||
skillsSnapshot: existing?.skillsSnapshot,
|
||||
lastChannel: channel,
|
||||
lastProvider: provider,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
lastAccountId: accountId?.trim()
|
||||
? accountId.trim()
|
||||
: existing?.lastAccountId,
|
||||
};
|
||||
store[sessionKey] = next;
|
||||
await saveSessionStore(storePath, store);
|
||||
@@ -384,12 +438,16 @@ export function resolveSessionKey(
|
||||
if (explicit) return explicit;
|
||||
const raw = deriveSessionKey(scope, ctx);
|
||||
if (scope === "global") return raw;
|
||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||
const canonical = (mainKey ?? "main").trim() || "main";
|
||||
const canonicalMainKey =
|
||||
(mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
const canonical = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
mainKey: canonicalMainKey,
|
||||
});
|
||||
const isGroup =
|
||||
raw.startsWith("group:") ||
|
||||
raw.includes(":group:") ||
|
||||
raw.includes(":channel:");
|
||||
if (!isGroup) return canonical;
|
||||
return raw;
|
||||
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
|
||||
export type SessionSendPolicyAction = "allow" | "deny";
|
||||
export type SessionSendPolicyMatch = {
|
||||
surface?: string;
|
||||
provider?: string;
|
||||
chatType?: "direct" | "group" | "room";
|
||||
keyPrefix?: string;
|
||||
};
|
||||
@@ -178,7 +178,7 @@ export type HookMappingConfig = {
|
||||
messageTemplate?: string;
|
||||
textTemplate?: string;
|
||||
deliver?: boolean;
|
||||
channel?:
|
||||
provider?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
@@ -506,7 +506,7 @@ export type QueueMode =
|
||||
| "interrupt";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
|
||||
export type QueueModeBySurface = {
|
||||
export type QueueModeByProvider = {
|
||||
whatsapp?: QueueMode;
|
||||
telegram?: QueueMode;
|
||||
discord?: QueueMode;
|
||||
@@ -552,8 +552,8 @@ export type RoutingConfig = {
|
||||
bindings?: Array<{
|
||||
agentId: string;
|
||||
match: {
|
||||
surface: string;
|
||||
surfaceAccountId?: string;
|
||||
provider: string;
|
||||
accountId?: string;
|
||||
peer?: { kind: "dm" | "group" | "channel"; id: string };
|
||||
guildId?: string;
|
||||
teamId?: string;
|
||||
@@ -561,7 +561,7 @@ export type RoutingConfig = {
|
||||
}>;
|
||||
queue?: {
|
||||
mode?: QueueMode;
|
||||
bySurface?: QueueModeBySurface;
|
||||
byProvider?: QueueModeByProvider;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
@@ -902,7 +902,7 @@ export type ClawdbotConfig = {
|
||||
elevated?: {
|
||||
/** Enable or disable elevated mode (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Approved senders for /elevated (per-surface allowlists). */
|
||||
/** Approved senders for /elevated (per-provider allowlists). */
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
|
||||
@@ -130,7 +130,7 @@ const SessionSchema = z
|
||||
action: z.union([z.literal("allow"), z.literal("deny")]),
|
||||
match: z
|
||||
.object({
|
||||
surface: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
@@ -240,8 +240,8 @@ const RoutingSchema = z
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
match: z.object({
|
||||
surface: z.string(),
|
||||
surfaceAccountId: z.string().optional(),
|
||||
provider: z.string(),
|
||||
accountId: z.string().optional(),
|
||||
peer: z
|
||||
.object({
|
||||
kind: z.union([
|
||||
@@ -261,7 +261,7 @@ const RoutingSchema = z
|
||||
queue: z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
bySurface: QueueModeBySurfaceSchema,
|
||||
byProvider: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
@@ -288,7 +288,7 @@ const HookMappingSchema = z
|
||||
messageTemplate: z.string().optional(),
|
||||
textTemplate: z.string().optional(),
|
||||
deliver: z.boolean().optional(),
|
||||
channel: z
|
||||
provider: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
|
||||
Reference in New Issue
Block a user