mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* chore: update plugin inspector to 0.3.20 * feat: gate plugin compatibility emails on hard errors
636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
export const APPEALS_URL = "https://appeals.openclaw.ai/";
|
|
export const MODERATION_GUIDELINES_URL = "https://docs.openclaw.ai/clawhub/moderation";
|
|
export const CLAWHUB_DASHBOARD_URL = "https://clawhub.ai/dashboard";
|
|
export const OPENCLAW_DISCORD_URL = "https://discord.gg/clawd";
|
|
export const MALICIOUS_REJECTION_ACCOUNT_WARNING =
|
|
"Repeated malicious rejections may lead to account disablement.";
|
|
const MAX_EMAIL_FINDING_SUMMARY_LENGTH = 280;
|
|
export const ADMIN_ONE_OFF_TEMPLATE = "generic-one-off";
|
|
|
|
const PUBLISHER_ABUSE_FINDING_SUMMARY =
|
|
"Your account was identified by ClawHub's publisher abuse review workflow for activity that appears inconsistent with our Acceptable Usage policy.";
|
|
const PUBLISHER_ABUSE_POLICY_ITEMS = [
|
|
"Bulk or spam publishing of large numbers of low-effort, duplicative, placeholder, or machine-generated listings.",
|
|
"Publishing large catalogs with little or no usage, maintenance, source clarity, or meaningful differentiation.",
|
|
"Artificially inflating installs, downloads, stars, or other engagement metrics.",
|
|
"Abnormal download activity with little or no corresponding install activity.",
|
|
];
|
|
const PUBLISHER_ABUSE_WARNING_REASON_MESSAGES: Record<string, string> = {
|
|
extreme_volume_low_engagement: "Large catalog size combined with very low engagement.",
|
|
high_catalog_volume: "Unusually large number of published listings.",
|
|
low_installs_per_skill: "Very low installs per listing.",
|
|
low_downloads_per_skill: "Very low downloads per listing.",
|
|
low_stars_per_skill: "Very low stars per listing.",
|
|
temporal_download_spike_flat_installs:
|
|
"Download spikes with little corresponding install activity.",
|
|
temporal_sustained_downloads_flat_installs:
|
|
"Sustained download activity with little corresponding install activity.",
|
|
temporal_installs_track_downloads:
|
|
"Install and download patterns that are far outside normal conversion behavior.",
|
|
};
|
|
const PUBLISHER_ABUSE_WARNING_REASON_PRIORITY = [
|
|
"extreme_volume_low_engagement",
|
|
"high_catalog_volume",
|
|
"low_installs_per_skill",
|
|
"low_downloads_per_skill",
|
|
"low_stars_per_skill",
|
|
"temporal_download_spike_flat_installs",
|
|
"temporal_sustained_downloads_flat_installs",
|
|
"temporal_installs_track_downloads",
|
|
] as const;
|
|
const MAX_PUBLISHER_ABUSE_WARNING_REASONS = 3;
|
|
|
|
export type NotificationArtifact = {
|
|
kind: "skill" | "plugin";
|
|
name: string;
|
|
};
|
|
|
|
export type BanNotificationSource = "manual" | "autoban";
|
|
|
|
export type BanNotificationEmailArgs = {
|
|
handle?: string;
|
|
source: BanNotificationSource;
|
|
reason?: string;
|
|
trigger?: string;
|
|
artifact?: NotificationArtifact;
|
|
bannedAt?: number;
|
|
hiddenArtifacts?: number;
|
|
};
|
|
|
|
export type BanNotificationEmailContext = {
|
|
appealUrl: typeof APPEALS_URL;
|
|
artifact: NotificationArtifact | null;
|
|
scannerLabel: string | null;
|
|
findingSummary: string;
|
|
policyReasonItems: string[];
|
|
};
|
|
|
|
export type TransactionalEmail = {
|
|
subject: string;
|
|
context: BanNotificationEmailContext;
|
|
text: string;
|
|
html: string;
|
|
};
|
|
|
|
export type RestoredAccountEmailArgs = {
|
|
handle?: string;
|
|
restoredListings?: NotificationArtifact[];
|
|
restoredAt?: number;
|
|
skillsRestored?: number;
|
|
packagesRestored?: number;
|
|
};
|
|
|
|
export type MaliciousArtifactEmailArgs = {
|
|
handle?: string;
|
|
artifact: NotificationArtifact;
|
|
version?: string;
|
|
trigger?: string;
|
|
findingSummary?: string;
|
|
};
|
|
|
|
export type SecretBlockedPublishEmailArgs = {
|
|
handle?: string;
|
|
artifact: NotificationArtifact;
|
|
version?: string;
|
|
};
|
|
|
|
export type PackageInspectorFindingsEmailArgs = {
|
|
handle?: string;
|
|
packageName: string;
|
|
version: string;
|
|
validationUrl: string;
|
|
};
|
|
|
|
export type PublisherAbuseWarningScore = {
|
|
modelVersion: string;
|
|
publishedSkills: number;
|
|
totalInstalls: number;
|
|
totalStars: number;
|
|
totalDownloads: number;
|
|
installsPerSkill: number;
|
|
starsPerSkill: number;
|
|
downloadsPerSkill: number;
|
|
zScore: number;
|
|
reasonCodes: string[];
|
|
};
|
|
|
|
export type PublisherAbuseWarningEmailArgs = {
|
|
handle?: string;
|
|
publisherHandle: string;
|
|
warningSentAt?: number;
|
|
deadlineAt: number;
|
|
score: PublisherAbuseWarningScore;
|
|
};
|
|
|
|
export type AdminOneOffEmailArgs = {
|
|
recipientHandle?: string;
|
|
subject: string;
|
|
title?: string;
|
|
body: string;
|
|
primaryActionLabel?: string;
|
|
primaryActionUrl?: string;
|
|
};
|
|
|
|
type BanReasonSummary = {
|
|
scannerLabel: string | null;
|
|
findingSummary: string;
|
|
policyReasonItems?: string[];
|
|
};
|
|
|
|
function normalizeReasonInput(args: Pick<BanNotificationEmailArgs, "reason" | "trigger">) {
|
|
return `${args.reason ?? ""} ${args.trigger ?? ""}`.trim().toLowerCase();
|
|
}
|
|
|
|
function summarizeBanReason(args: BanNotificationEmailArgs): BanReasonSummary {
|
|
const normalized = normalizeReasonInput(args);
|
|
|
|
if (/\bpublisher[_\-\s]?abuse\b/.test(normalized)) {
|
|
return {
|
|
scannerLabel: null,
|
|
findingSummary: PUBLISHER_ABUSE_FINDING_SUMMARY,
|
|
policyReasonItems: PUBLISHER_ABUSE_POLICY_ITEMS,
|
|
};
|
|
}
|
|
|
|
if (args.source === "autoban") {
|
|
if (normalized.includes("virustotal") || normalized.includes("virus_total")) {
|
|
return {
|
|
scannerLabel: "VirusTotal",
|
|
findingSummary: "VirusTotal telemetry contributed to a malicious upload finding.",
|
|
};
|
|
}
|
|
if (normalized.includes("static")) {
|
|
return {
|
|
scannerLabel: "Static analysis",
|
|
findingSummary: "Static analysis flagged malicious upload patterns.",
|
|
};
|
|
}
|
|
if (
|
|
normalized.includes("clawscan") ||
|
|
normalized.includes("llm") ||
|
|
normalized.includes("malicious")
|
|
) {
|
|
return {
|
|
scannerLabel: "ClawScan",
|
|
findingSummary: "ClawScan classified the uploaded skill as malicious.",
|
|
};
|
|
}
|
|
return {
|
|
scannerLabel: "ClawHub security checks",
|
|
findingSummary: "ClawHub security checks classified the uploaded skill as malicious.",
|
|
};
|
|
}
|
|
|
|
if (/rate[-\s]?limit|publishing automation|automated(?: cli)? publishing/.test(normalized)) {
|
|
return {
|
|
scannerLabel: null,
|
|
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
scannerLabel: null,
|
|
findingSummary: "ClawHub staff disabled the account after a security review.",
|
|
};
|
|
}
|
|
|
|
function artifactLabel(artifact: NotificationArtifact) {
|
|
return `${artifact.kind === "skill" ? "Skill" : "Plugin"}: ${artifact.name}`;
|
|
}
|
|
|
|
function greeting(handle: string | undefined) {
|
|
return handle?.trim() ? `Hi ${handle.trim()},` : "Hi,";
|
|
}
|
|
|
|
function handleLabel(handle: string | undefined) {
|
|
const normalized = handle?.trim().replace(/^@+/, "");
|
|
return normalized ? `@${normalized}` : "your account";
|
|
}
|
|
|
|
function formatUtcTimestamp(value: number | undefined, fallback: string) {
|
|
if (!Number.isFinite(value)) return fallback;
|
|
return new Date(value as number)
|
|
.toISOString()
|
|
.replace("T", " ")
|
|
.replace(/\.\d{3}Z$/, " UTC");
|
|
}
|
|
|
|
function publisherAbuseWarningReasonLines(reasonCodes: string[]) {
|
|
const uniqueReasonCodes = new Set(reasonCodes);
|
|
const priorityReasonCodes = new Set<string>(PUBLISHER_ABUSE_WARNING_REASON_PRIORITY);
|
|
const sortedReasonCodes = [
|
|
...PUBLISHER_ABUSE_WARNING_REASON_PRIORITY.filter((code) => uniqueReasonCodes.has(code)),
|
|
...reasonCodes.filter((code) => !priorityReasonCodes.has(code)),
|
|
];
|
|
const lines = sortedReasonCodes
|
|
.map((code) => PUBLISHER_ABUSE_WARNING_REASON_MESSAGES[code])
|
|
.filter((message): message is string => Boolean(message));
|
|
return Array.from(new Set(lines)).slice(0, MAX_PUBLISHER_ABUSE_WARNING_REASONS);
|
|
}
|
|
|
|
async function renderAccountSuspendedTemplate(args: {
|
|
handle?: string;
|
|
suspendedAt?: number;
|
|
hiddenArtifacts?: number;
|
|
findingSummary: string;
|
|
policyReasonItems: string[];
|
|
preheader: string;
|
|
}) {
|
|
const { renderAccountSuspendedEmail } = await import("./emailRendering");
|
|
const hiddenArtifacts =
|
|
typeof args.hiddenArtifacts === "number" && Number.isFinite(args.hiddenArtifacts)
|
|
? Math.max(0, Math.trunc(args.hiddenArtifacts))
|
|
: undefined;
|
|
const rendered = await renderAccountSuspendedEmail({
|
|
handle: handleLabel(args.handle),
|
|
suspendedAt: formatUtcTimestamp(args.suspendedAt, "moderation review"),
|
|
...(hiddenArtifacts === undefined ? {} : { hiddenArtifacts }),
|
|
findingSummary: args.findingSummary,
|
|
policyReasonItems: args.policyReasonItems,
|
|
preheader: args.preheader,
|
|
});
|
|
return rendered.html;
|
|
}
|
|
|
|
async function renderAccountReinstatedTemplate(args: {
|
|
handle?: string;
|
|
restoredAt?: number;
|
|
skillsRestored?: number;
|
|
packagesRestored?: number;
|
|
}) {
|
|
const { renderAccountReinstatedEmail } = await import("./emailRendering");
|
|
const hasRestoredCounts =
|
|
typeof args.skillsRestored === "number" && typeof args.packagesRestored === "number";
|
|
const preheader = hasRestoredCounts
|
|
? `Your account is active again - ${args.skillsRestored} skills and ${args.packagesRestored} packages restored. Note: previous API tokens remain revoked.`
|
|
: "Your account is active again. Note: previous API tokens remain revoked.";
|
|
const rendered = await renderAccountReinstatedEmail({
|
|
handle: handleLabel(args.handle),
|
|
restoredAt: formatUtcTimestamp(args.restoredAt, "account review"),
|
|
...(hasRestoredCounts
|
|
? { skillsRestored: args.skillsRestored, packagesRestored: args.packagesRestored }
|
|
: {}),
|
|
preheader,
|
|
});
|
|
return rendered.html;
|
|
}
|
|
|
|
async function renderGenericOneOffTemplate(args: AdminOneOffEmailArgs) {
|
|
const { renderAdminOneOffEmail } = await import("./emailRendering");
|
|
const subject = args.subject.trim();
|
|
const title = args.title?.trim() || subject;
|
|
const actionLabel = args.primaryActionLabel?.trim();
|
|
const actionUrl = args.primaryActionUrl?.trim();
|
|
const rendered = await renderAdminOneOffEmail({
|
|
...(args.recipientHandle?.trim() ? { recipientHandle: args.recipientHandle.trim() } : {}),
|
|
subject,
|
|
title,
|
|
body: args.body.trim(),
|
|
...(actionLabel && actionUrl ? { primaryAction: { label: actionLabel, url: actionUrl } } : {}),
|
|
});
|
|
return rendered.html;
|
|
}
|
|
|
|
function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) {
|
|
const version = args.version?.trim() || "<version>";
|
|
const kindFlag = args.artifact.kind === "plugin" ? " --kind plugin" : "";
|
|
return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`;
|
|
}
|
|
|
|
async function renderSecretBlockedPublishTemplate(args: {
|
|
artifactKind: "skill" | "plugin";
|
|
artifactName: string;
|
|
version: string;
|
|
preheader: string;
|
|
}) {
|
|
const { renderSecretBlockedPublishEmail } = await import("./emailRendering");
|
|
const rendered = await renderSecretBlockedPublishEmail(args);
|
|
return rendered.html;
|
|
}
|
|
|
|
function normalizeEmailFindingSummary(value: string | undefined) {
|
|
const normalized = value?.replace(/\s+/g, " ").trim();
|
|
if (!normalized) return undefined;
|
|
if (normalized.length <= MAX_EMAIL_FINDING_SUMMARY_LENGTH) return normalized;
|
|
return `${normalized.slice(0, MAX_EMAIL_FINDING_SUMMARY_LENGTH - 3).trimEnd()}...`;
|
|
}
|
|
|
|
export async function buildBanNotificationEmail(
|
|
args: BanNotificationEmailArgs,
|
|
): Promise<TransactionalEmail> {
|
|
const summary = summarizeBanReason(args);
|
|
const artifact = args.artifact ?? null;
|
|
const policyReasonItems = summary.policyReasonItems ?? [];
|
|
const hiddenArtifacts =
|
|
typeof args.hiddenArtifacts === "number" && Number.isFinite(args.hiddenArtifacts)
|
|
? Math.max(0, Math.trunc(args.hiddenArtifacts))
|
|
: artifact
|
|
? 1
|
|
: undefined;
|
|
const context: BanNotificationEmailContext = {
|
|
appealUrl: APPEALS_URL,
|
|
artifact,
|
|
scannerLabel: summary.scannerLabel,
|
|
findingSummary: summary.findingSummary,
|
|
policyReasonItems,
|
|
};
|
|
|
|
const lines = [
|
|
greeting(args.handle),
|
|
"",
|
|
"Your ClawHub account has been suspended.",
|
|
`Reason: ${context.findingSummary}`,
|
|
];
|
|
if (artifact) lines.push(artifactLabel(artifact));
|
|
if (typeof hiddenArtifacts === "number") lines.push(`Artifacts hidden: ${hiddenArtifacts}`);
|
|
if (policyReasonItems.length > 0) {
|
|
lines.push("", "Policy signals:", ...policyReasonItems.map((item) => `- ${item}`));
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"What changed:",
|
|
"- Your ClawHub account cannot sign in.",
|
|
"- Existing API tokens for the account have been revoked.",
|
|
"- Published listings owned by the account may be hidden from public view.",
|
|
);
|
|
lines.push("", `Appeal: ${APPEALS_URL}`);
|
|
|
|
lines.push("", "ClawHub Security");
|
|
|
|
const impactItems = [
|
|
"Your ClawHub account cannot sign in.",
|
|
"Existing API tokens for the account have been revoked.",
|
|
"Published listings owned by the account may be hidden from public view.",
|
|
];
|
|
const detailLines = [
|
|
context.findingSummary,
|
|
...policyReasonItems,
|
|
...(artifact ? [artifact.name] : []),
|
|
...impactItems,
|
|
];
|
|
const html = await renderAccountSuspendedTemplate({
|
|
handle: args.handle,
|
|
suspendedAt: args.bannedAt,
|
|
hiddenArtifacts,
|
|
findingSummary: context.findingSummary,
|
|
policyReasonItems,
|
|
preheader: detailLines.join(" "),
|
|
});
|
|
|
|
return {
|
|
subject: "Your ClawHub account has been suspended",
|
|
context,
|
|
text: lines.join("\n"),
|
|
html,
|
|
};
|
|
}
|
|
|
|
export async function buildRestoredAccountEmail(args: RestoredAccountEmailArgs) {
|
|
const restoredListings = args.restoredListings ?? [];
|
|
const listingLines = restoredListings.map(artifactLabel);
|
|
const lines = [
|
|
greeting(args.handle),
|
|
"",
|
|
"Your ClawHub account can sign in again.",
|
|
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
|
|
];
|
|
if (listingLines.length > 0) {
|
|
lines.push("", "Restored listings:", ...listingLines);
|
|
}
|
|
lines.push("", "ClawHub Security");
|
|
|
|
const skillsRestored = Object.hasOwn(args, "skillsRestored")
|
|
? args.skillsRestored
|
|
: restoredListings.filter((listing) => listing.kind === "skill").length;
|
|
const packagesRestored = Object.hasOwn(args, "packagesRestored")
|
|
? args.packagesRestored
|
|
: restoredListings.filter((listing) => listing.kind === "plugin").length;
|
|
const html = await renderAccountReinstatedTemplate({
|
|
handle: args.handle,
|
|
restoredAt: args.restoredAt,
|
|
skillsRestored,
|
|
packagesRestored,
|
|
});
|
|
|
|
return {
|
|
subject: "Your ClawHub account has been reinstated",
|
|
text: lines.join("\n"),
|
|
html,
|
|
};
|
|
}
|
|
|
|
export async function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailArgs) {
|
|
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
|
|
const artifactLabelText = artifactLabel(args.artifact);
|
|
const scanDownloadCommand = buildScanDownloadCommand(args);
|
|
const findingSummary =
|
|
normalizeEmailFindingSummary(args.findingSummary) ??
|
|
(args.trigger?.includes("static") === true
|
|
? "Static analysis flagged malicious upload patterns."
|
|
: args.trigger?.includes("virustotal") === true || args.trigger?.includes("vt_") === true
|
|
? "VirusTotal telemetry contributed to a malicious upload finding."
|
|
: "ClawScan classified the uploaded artifact as malicious.");
|
|
const subject = `ClawHub blocked a ${artifactKind} version`;
|
|
|
|
const lines = [
|
|
greeting(args.handle),
|
|
"",
|
|
`ClawHub blocked a ${artifactKind} version after a security scan.`,
|
|
`Reason: ${findingSummary}`,
|
|
artifactLabelText,
|
|
];
|
|
if (args.version?.trim()) lines.push(`Version: ${args.version.trim()}`);
|
|
lines.push(
|
|
"",
|
|
"What changed:",
|
|
"- This version was not made public.",
|
|
"- Your account can still sign in.",
|
|
`- You can upload a fixed version of this ${artifactKind}.`,
|
|
`- ${MALICIOUS_REJECTION_ACCOUNT_WARNING}`,
|
|
"",
|
|
"Download the scan results for the blocked submitted version:",
|
|
scanDownloadCommand,
|
|
`Docs: ${MODERATION_GUIDELINES_URL}`,
|
|
`Increment the version number before uploading the fixed ${artifactKind}.`,
|
|
"",
|
|
"ClawHub Security",
|
|
);
|
|
|
|
const { renderBlockedVersionEmail } = await import("./emailRendering");
|
|
const rendered = await renderBlockedVersionEmail({
|
|
artifactKind,
|
|
artifactName: args.artifact.name,
|
|
version: args.version?.trim() || "<version>",
|
|
findingSummary,
|
|
validateCommand: scanDownloadCommand,
|
|
docsUrl: MODERATION_GUIDELINES_URL,
|
|
preheader: `${artifactLabelText} was blocked by ClawHub security scans.`,
|
|
});
|
|
|
|
return {
|
|
subject,
|
|
text: lines.join("\n"),
|
|
html: rendered.html,
|
|
};
|
|
}
|
|
|
|
export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishEmailArgs) {
|
|
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
|
|
const artifactLabelText = artifactLabel(args.artifact);
|
|
const version = args.version?.trim() || "<version>";
|
|
const subject = `ClawHub blocked a ${artifactKind} publish`;
|
|
const lines = [
|
|
greeting(args.handle),
|
|
"",
|
|
`ClawHub blocked a ${artifactKind} publish because TruffleHog found a secret-looking value in the uploaded files.`,
|
|
artifactLabelText,
|
|
`Version: ${version}`,
|
|
"",
|
|
"What changed:",
|
|
"- This version was not made public.",
|
|
"- Uploaded files for this attempt were deleted from ClawHub storage.",
|
|
"- Your account can still sign in.",
|
|
"",
|
|
"What to do next:",
|
|
"- Rotate the secret if it was real.",
|
|
`- Remove it from the ${artifactKind}.`,
|
|
"- Upload a new version.",
|
|
"",
|
|
"ClawHub Security",
|
|
];
|
|
const html = await renderSecretBlockedPublishTemplate({
|
|
artifactKind,
|
|
artifactName: args.artifact.name,
|
|
version,
|
|
preheader: `${args.artifact.name}@${version} was blocked before public listing because a secret was found.`,
|
|
});
|
|
|
|
return {
|
|
subject,
|
|
text: lines.join("\n"),
|
|
html,
|
|
};
|
|
}
|
|
|
|
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
|
|
const subject = `Update required: ${args.packageName} will break in an upcoming OpenClaw release`;
|
|
const intro = `ClawHub validated ${args.packageName}@${args.version} against the upcoming OpenClaw release.`;
|
|
const lines = [
|
|
greeting(args.handle),
|
|
"",
|
|
intro,
|
|
"",
|
|
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
|
|
"",
|
|
`Review the validation errors: ${args.validationUrl}`,
|
|
"",
|
|
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
|
|
"",
|
|
"Please update the plugin and publish a new version before the next OpenClaw release.",
|
|
"",
|
|
"—ClawHub",
|
|
];
|
|
|
|
const { renderPluginInspectorFindingsEmail } = await import("./emailRendering");
|
|
const rendered = await renderPluginInspectorFindingsEmail({
|
|
owner: args.handle?.trim() || "there",
|
|
packageName: args.packageName,
|
|
version: args.version,
|
|
validationUrl: args.validationUrl,
|
|
preheader: intro,
|
|
});
|
|
|
|
return {
|
|
subject,
|
|
text: lines.join("\n"),
|
|
html: rendered.html,
|
|
};
|
|
}
|
|
|
|
export function buildPackageInspectorValidationUrl(packageName: string) {
|
|
const normalized = packageName.trim();
|
|
if (normalized.startsWith("@")) {
|
|
const slashIndex = normalized.indexOf("/");
|
|
if (slashIndex > 1 && slashIndex < normalized.length - 1) {
|
|
const owner = normalized.slice(1, slashIndex);
|
|
const name = normalized.slice(slashIndex + 1);
|
|
return `https://clawhub.ai/${encodeURIComponent(owner)}/plugins/${encodeURIComponent(name)}#validation`;
|
|
}
|
|
}
|
|
return `https://clawhub.ai/plugins/${encodeURIComponent(normalized)}#validation`;
|
|
}
|
|
|
|
export async function buildPublisherAbuseWarningEmail(args: PublisherAbuseWarningEmailArgs) {
|
|
const publisherHandle = args.publisherHandle.trim().replace(/^@+/, "");
|
|
const publisherLabel = publisherHandle ? `@${publisherHandle}` : "your publisher";
|
|
const deadline = formatUtcTimestamp(args.deadlineAt, "the warning deadline");
|
|
const warningSentAt = formatUtcTimestamp(args.warningSentAt, "this warning");
|
|
const reasonLines = publisherAbuseWarningReasonLines(args.score.reasonCodes);
|
|
const signalLines = reasonLines.length
|
|
? reasonLines.map((line) => `- ${line}`)
|
|
: ["- Catalog size and engagement patterns were far outside normal publisher behavior."];
|
|
const body = [
|
|
`ClawHub's publisher abuse detection flagged the publisher profile ${publisherLabel}.`,
|
|
"",
|
|
"This profile is well outside normal ClawHub publishing patterns for scanned publishers.",
|
|
"",
|
|
"The biggest signals were:",
|
|
...signalLines,
|
|
"",
|
|
"If this is not resolved before the deadline below, the account linked to this publisher may be suspended the next time the daily abuse scan confirms the same issue.",
|
|
"",
|
|
`Warning sent: ${warningSentAt}`,
|
|
`Deadline: ${deadline}`,
|
|
"",
|
|
"What to fix:",
|
|
"- Delete low-quality, duplicate, placeholder, or machine-generated listings.",
|
|
"- Consolidate near-identical skills or plugins.",
|
|
"- Keep only listings that are useful, maintained, and meaningfully different.",
|
|
"- Do not inflate installs, downloads, stars, or other engagement metrics.",
|
|
"",
|
|
`For more information, join the OpenClaw Discord and tag one of the maintainers: ${OPENCLAW_DISCORD_URL}`,
|
|
].join("\n");
|
|
const subject = "Action required: ClawHub publisher abuse warning";
|
|
|
|
const html = await renderGenericOneOffTemplate({
|
|
recipientHandle: handleLabel(args.handle),
|
|
subject,
|
|
title: "Action required: publisher abuse warning",
|
|
body,
|
|
primaryActionLabel: "Open ClawHub dashboard",
|
|
primaryActionUrl: CLAWHUB_DASHBOARD_URL,
|
|
});
|
|
|
|
return {
|
|
subject,
|
|
text: [
|
|
greeting(args.handle),
|
|
"",
|
|
body,
|
|
"",
|
|
`Open ClawHub dashboard: ${CLAWHUB_DASHBOARD_URL}`,
|
|
"",
|
|
"ClawHub Security",
|
|
].join("\n"),
|
|
html,
|
|
};
|
|
}
|
|
|
|
export async function buildAdminOneOffEmail(args: AdminOneOffEmailArgs) {
|
|
const title = args.title?.trim() || args.subject.trim();
|
|
const lines = [title, "", args.body.trim()];
|
|
if (args.primaryActionLabel?.trim() && args.primaryActionUrl?.trim()) {
|
|
lines.push("", `${args.primaryActionLabel.trim()}: ${args.primaryActionUrl.trim()}`);
|
|
}
|
|
lines.push("", "ClawHub Team");
|
|
|
|
const html = await renderGenericOneOffTemplate(args);
|
|
|
|
return {
|
|
subject: args.subject.trim(),
|
|
text: lines.join("\n"),
|
|
html,
|
|
};
|
|
}
|