mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 07:28:11 +00:00
feat(feishu): replace built-in SDK with community plugin
Replace the built-in Feishu SDK with the community-maintained clawdbot-feishu plugin by @m1heng. Changes: - Remove src/feishu/ directory (19 files) - Remove src/channels/plugins/outbound/feishu.ts - Remove src/channels/plugins/normalize/feishu.ts - Remove src/config/types.feishu.ts - Remove feishu exports from plugin-sdk/index.ts - Remove FeishuConfig from types.channels.ts New features in community plugin: - Document tools (read/create/edit Feishu docs) - Wiki tools (navigate/manage knowledge base) - Drive tools (folder/file management) - Bitable tools (read/write table records) - Permission tools (collaborator management) - Emoji reactions support - Typing indicators - Rich media support (bidirectional image/file transfer) - @mention handling - Skills for feishu-doc, feishu-wiki, feishu-drive, feishu-perm Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,124 +1,110 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
OpenClawConfig,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
function setFeishuDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
|
||||
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
||||
const allowFrom =
|
||||
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined;
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
dmPolicy: policy,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteFeishuSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).",
|
||||
"Copy the App ID and App Secret from the app credentials page.",
|
||||
'Lark (global): use open.larksuite.com and set domain="lark".',
|
||||
`Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`,
|
||||
].join("\n"),
|
||||
"Feishu setup",
|
||||
);
|
||||
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry.replace(/^(feishu|lark):/i, "").trim();
|
||||
}
|
||||
|
||||
function resolveDomainChoice(domain?: string | null): "feishu" | "lark" {
|
||||
const normalized = String(domain ?? "").toLowerCase();
|
||||
if (normalized.includes("lark") || normalized.includes("larksuite")) {
|
||||
return "lark";
|
||||
}
|
||||
return "feishu";
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string | null;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const existingAllowFrom = isDefault
|
||||
? (cfg.channels?.feishu?.allowFrom ?? [])
|
||||
: (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []);
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Feishu DMs by open_id or user_id.",
|
||||
"You can find user open_id in Feishu admin console or via API.",
|
||||
"Examples:",
|
||||
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
].join("\n"),
|
||||
"Feishu allowlist",
|
||||
);
|
||||
|
||||
const entry = await prompter.text({
|
||||
message: "Feishu allowFrom (open_id or union_id)",
|
||||
placeholder: "ou_xxx",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "Required";
|
||||
}
|
||||
const entries = raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((item) => normalizeAllowEntry(item))
|
||||
.filter(Boolean);
|
||||
const invalid = entries.filter((item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item));
|
||||
if (invalid.length > 0) {
|
||||
return `Invalid Feishu ids: ${invalid.join(", ")}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "Feishu allowFrom (user open_ids)",
|
||||
placeholder: "ou_xxxxx, ou_yyyyy",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = String(entry)
|
||||
.split(/[\n,;]+/g)
|
||||
.map((item) => normalizeAllowEntry(item))
|
||||
.filter(Boolean);
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))),
|
||||
...parsed,
|
||||
].filter(Boolean);
|
||||
const unique = Array.from(new Set(merged));
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]),
|
||||
];
|
||||
return setFeishuAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Go to Feishu Open Platform (open.feishu.cn)",
|
||||
"2) Create a self-built app",
|
||||
"3) Get App ID and App Secret from Credentials page",
|
||||
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
|
||||
"5) Publish the app or add it to a test group",
|
||||
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
|
||||
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
|
||||
].join("\n"),
|
||||
"Feishu credentials",
|
||||
);
|
||||
}
|
||||
|
||||
function setFeishuGroupPolicy(
|
||||
cfg: ClawdbotConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -126,15 +112,20 @@ async function promptFeishuAllowFrom(params: {
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.feishu?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.feishu?.accounts?.[accountId],
|
||||
enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groupAllowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -145,134 +136,221 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
channel,
|
||||
policyKey: "channels.feishu.dmPolicy",
|
||||
allowFromKey: "channels.feishu.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing",
|
||||
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
|
||||
function updateFeishuConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean },
|
||||
): OpenClawConfig {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const next = { ...cfg } as OpenClawConfig;
|
||||
const feishu = { ...next.channels?.feishu } as Record<string, unknown>;
|
||||
const accounts = feishu.accounts
|
||||
? { ...(feishu.accounts as Record<string, unknown>) }
|
||||
: undefined;
|
||||
|
||||
if (isDefault && !accounts) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...feishu,
|
||||
...updates,
|
||||
enabled: updates.enabled ?? true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedAccounts = accounts ?? {};
|
||||
const existing = (resolvedAccounts[accountId] as Record<string, unknown>) ?? {};
|
||||
resolvedAccounts[accountId] = {
|
||||
...existing,
|
||||
...updates,
|
||||
enabled: updates.enabled ?? true,
|
||||
};
|
||||
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...feishu,
|
||||
accounts: resolvedAccounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listFeishuAccountIds(cfg).some((id) => {
|
||||
const acc = resolveFeishuAccount({ cfg, accountId: id });
|
||||
return acc.tokenSource !== "none";
|
||||
});
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
|
||||
|
||||
// Try to probe if configured
|
||||
let probeResult = null;
|
||||
if (configured && feishuCfg) {
|
||||
try {
|
||||
probeResult = await probeFeishu(feishuCfg);
|
||||
} catch {
|
||||
// Ignore probe errors
|
||||
}
|
||||
}
|
||||
|
||||
const statusLines: string[] = [];
|
||||
if (!configured) {
|
||||
statusLines.push("Feishu: needs app credentials");
|
||||
} else if (probeResult?.ok) {
|
||||
statusLines.push(
|
||||
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
|
||||
);
|
||||
} else {
|
||||
statusLines.push("Feishu: configured (connection not verified)");
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Feishu: ${configured ? "configured" : "needs app credentials"}`],
|
||||
selectionHint: configured ? "configured" : "requires app credentials",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
statusLines,
|
||||
selectionHint: configured ? "configured" : "needs app creds",
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
let next = cfg;
|
||||
const override = accountOverrides.feishu?.trim();
|
||||
const defaultId = resolveDefaultFeishuAccountId(next);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultId;
|
||||
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Feishu",
|
||||
currentId: accountId,
|
||||
listAccountIds: listFeishuAccountIds,
|
||||
defaultAccountId: defaultId,
|
||||
});
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolved = resolveFeishuCredentials(feishuCfg);
|
||||
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
|
||||
);
|
||||
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appSecret: string | null = null;
|
||||
|
||||
if (!resolved) {
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
}
|
||||
|
||||
await noteFeishuSetup(prompter);
|
||||
|
||||
const resolved = resolveFeishuAccount({ cfg: next, accountId });
|
||||
const domainChoice = await prompter.select({
|
||||
message: "Feishu domain",
|
||||
options: [
|
||||
{ value: "feishu", label: "Feishu (China) — open.feishu.cn" },
|
||||
{ value: "lark", label: "Lark (global) — open.larksuite.com" },
|
||||
],
|
||||
initialValue: resolveDomainChoice(resolved.config.domain),
|
||||
});
|
||||
const domain = domainChoice === "lark" ? "lark" : "feishu";
|
||||
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envAppId = process.env.FEISHU_APP_ID?.trim();
|
||||
const envSecret = process.env.FEISHU_APP_SECRET?.trim();
|
||||
if (isDefault && envAppId && envSecret) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?",
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
next = updateFeishuConfig(next, accountId, { enabled: true, domain });
|
||||
return { cfg: next, accountId };
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: { ...next.channels?.feishu, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Feishu credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (appId && appSecret) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
enabled: true,
|
||||
appId,
|
||||
appSecret,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Test connection
|
||||
const testCfg = next.channels?.feishu as FeishuConfig;
|
||||
try {
|
||||
const probe = await probeFeishu(testCfg);
|
||||
if (probe.ok) {
|
||||
await prompter.note(
|
||||
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`Connection failed: ${probe.error ?? "unknown error"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
|
||||
}
|
||||
}
|
||||
const appId = String(
|
||||
await prompter.text({
|
||||
message: "Feishu App ID (cli_...)",
|
||||
initialValue: resolved.config.appId?.trim() || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Feishu App Secret",
|
||||
initialValue: resolved.config.appSecret?.trim() || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
// Domain selection
|
||||
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
|
||||
const domain = await prompter.select({
|
||||
message: "Which Feishu domain?",
|
||||
options: [
|
||||
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
|
||||
{ value: "lark", label: "Lark (larksuite.com) - International" },
|
||||
],
|
||||
initialValue: currentDomain,
|
||||
});
|
||||
if (domain) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
domain: domain as "feishu" | "lark",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true });
|
||||
// Group policy
|
||||
const groupPolicy = await prompter.select({
|
||||
message: "Group chat policy",
|
||||
options: [
|
||||
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
|
||||
{ value: "open", label: "Open - respond in all groups (requires mention)" },
|
||||
{ value: "disabled", label: "Disabled - don't respond in groups" },
|
||||
],
|
||||
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
|
||||
});
|
||||
if (groupPolicy) {
|
||||
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
// Group allowlist if needed
|
||||
if (groupPolicy === "allowlist") {
|
||||
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Group chat allowlist (chat_ids)",
|
||||
placeholder: "oc_xxxxx, oc_yyyyy",
|
||||
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
|
||||
});
|
||||
if (entry) {
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length > 0) {
|
||||
next = setFeishuGroupAllowFrom(next, parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
|
||||
dmPolicy,
|
||||
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: { ...cfg.channels?.feishu, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user