文件预览

wordpress-api.ts

查看 youmind-wordpress-article 技能包中的文件内容。

文件内容

toolkit/src/wordpress-api.ts

/**
 * WordPress client via YouMind OpenAPI (aggregated publishing endpoints).
 *
 * 后端统一在 /openapi/v1/publishing/<op>,platform=wordpress 通过 discriminated union 区分。
 * 所有响应统一为 { platform, data },本层自动解嵌套返回 data,并把 UnifiedPost / UnifiedTaxonomy /
 * UnifiedEngagement 映射回 toolkit 原本的 WP 类型形状(保持 cli/publisher 调用稳定)。
 *
 * 端点契约(apps/youapi spec 016 v2):
 *   POST /openapi/v1/publishing/{op}    body: { platform: 'wordpress', ...payload }
 */

import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadYouMindConfig, YOUMIND_CONFIG_ERROR_HINT } from './config.js';

// ---------------------------------------------------------------------------
// Public types — stable contract. Fields use camelCase (server normalizes
// raw WP shape `title.rendered` etc into flat strings).
// ---------------------------------------------------------------------------

export type WPPostStatus = 'publish' | 'draft' | 'pending' | 'private' | 'future';
export type WPListStatus = WPPostStatus | 'any';
export type WPViewContext = 'view' | 'edit' | 'embed';

export interface WordPressConfig {
  apiKey: string;
  baseUrl: string;
}

export interface WPPost {
  id: number;
  title: string;
  content: string;
  excerpt?: string;
  status: WPPostStatus;
  slug: string;
  link: string;
  author: number;
  featuredMedia: number;
  categories: number[];
  tags: number[];
  date: string;
  modified: string;
  format?: string;
  adminUrl?: string | null;
}

export interface WPPostListResponse {
  posts: WPPost[];
  total: number;
  totalPages: number;
  page: number;
  perPage: number;
}

export interface WPMedia {
  id: number;
  sourceUrl: string;
  title: string;
  mimeType: string;
  mediaType: string;
  slug: string;
  altText?: string;
  caption?: string;
  /** Markdown image snippet ready to paste into a post body */
  markdown: string;
}

export interface WPCategory {
  id: number;
  name: string;
  slug: string;
  parent: number;
  count: number;
  description: string;
}

export interface WPCategoryListResponse {
  categories: WPCategory[];
  total: number;
  totalPages: number;
}

export interface WPTag {
  id: number;
  name: string;
  slug: string;
  count: number;
  description: string;
}

export interface WPTagListResponse {
  tags: WPTag[];
  total: number;
  totalPages: number;
}

export interface WPConnectionResult {
  ok: boolean;
  message: string;
  accountId?: number;
  accountName?: string;
  accountUsername?: string;
  siteUrl?: string;
}

export interface WPDeleteResult {
  ok: boolean;
  id: number;
  deletedPermanently: boolean;
}

export interface CreatePostOptions {
  title: string;
  content: string;
  excerpt?: string;
  status?: WPPostStatus;
  /** Tag names (server resolves to IDs and auto-creates missing ones) */
  tags?: string[];
  /** Category names (server resolves to IDs; errors if missing) */
  categories?: string[];
  featuredMedia?: number;
  slug?: string;
  date?: string;
  format?: string;
}

export type UpdatePostOptions = Partial<CreatePostOptions>;

export interface UploadMediaInput {
  /** Local file path to upload */
  filePath: string;
  filename?: string;
  contentType?: string;
  altText?: string;
  caption?: string;
}

// Comment / category types
export type WPCommentStatus = 'approved' | 'hold' | 'spam' | 'trash';
export type WPCommentListStatus = 'approve' | 'hold' | 'spam' | 'trash' | 'any';

export interface WPComment {
  id: number;
  post: number;
  parent: number;
  author: number;
  authorName: string;
  authorEmail?: string;
  authorUrl?: string;
  content: string;
  status: string;
  date: string;
  link: string;
}

export interface WPCommentListResponse {
  comments: WPComment[];
  total: number;
  totalPages: number;
  page: number;
  perPage: number;
}

export interface WPCommentDeleteResult {
  ok: boolean;
  id: number;
  deletedPermanently: boolean;
}

export interface WPCategoryDeleteResult {
  ok: boolean;
  id: number;
  deletedPermanently: boolean;
}

export interface CreateCategoryInput {
  name: string;
  description?: string;
  slug?: string;
  parent?: number;
}

export interface CreateCommentInput {
  postId: number;
  content: string;
  parent?: number;
  authorName?: string;
  authorEmail?: string;
  authorUrl?: string;
}

export interface UpdateCommentInput {
  content?: string;
  status?: WPCommentStatus;
}

// ---------------------------------------------------------------------------
// Config loading — YouMind apikey only; WP creds live in YouMind backend.
// ---------------------------------------------------------------------------

const DEFAULT_YOUMIND_OPENAPI_BASE_URL = 'https://youmind.com/openapi/v1';
const PLATFORM = 'wordpress' as const;

interface OpenApiErrorDetail {
  connectUrl?: string;
  upgradeUrl?: string;
  hint?: string;
  upstreamMessage?: string;
  retryAfter?: string | null;
  status?: number | null;
  categoryName?: string;
}

interface OpenApiErrorResponse {
  message?: string;
  code?: string;
  detail?: OpenApiErrorDetail;
}

function normalizeBaseUrl(value: string | undefined): string {
  if (!value) return '';
  const trimmed = value.replace(/\/+$/, '');
  if (trimmed.endsWith('/openapi/v1')) return trimmed;
  if (trimmed.endsWith('/openapi')) return `${trimmed}/v1`;
  return `${trimmed}/openapi/v1`;
}

export function loadWordPressConfig(): WordPressConfig {
  const { apiKey, baseUrl } = loadYouMindConfig();
  return {
    apiKey,
    baseUrl: normalizeBaseUrl(baseUrl) || DEFAULT_YOUMIND_OPENAPI_BASE_URL,
  };
}

// ---------------------------------------------------------------------------
// HTTP transport
// ---------------------------------------------------------------------------

async function postJson<T = unknown>(
  endpoint: string,
  body: Record<string, unknown> = {},
  config?: WordPressConfig,
): Promise<T> {
  const cfg = config ?? loadWordPressConfig();
  if (!cfg.apiKey) {
    throw new Error(`YouMind API key not configured. ${YOUMIND_CONFIG_ERROR_HINT}`);
  }

  const response = await fetch(`${cfg.baseUrl}${endpoint}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': cfg.apiKey,
      'x-use-camel-case': 'true',
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(120_000),
  });

  if (!response.ok) {
    const text = await response.text().catch(() => '');
    const parsed = parseOpenApiError(text);
    throw new Error(
      `YouMind WordPress API ${endpoint} failed (${response.status})` +
        `: ${formatOpenApiError(parsed, text)}`,
    );
  }

  return response.json() as Promise<T>;
}

// 聚合层调用:包一层自动从 { platform, data } 解出 data,对外保持旧接口形状
async function callPublishing<T = unknown>(
  op: string,
  payload: Record<string, unknown>,
  config?: WordPressConfig,
): Promise<T> {
  const wrapped = await postJson<{ platform: string; data: T }>(
    `/publishing/${op}`,
    { platform: PLATFORM, ...payload },
    config,
  );
  return wrapped.data;
}

function parseOpenApiError(text: string): OpenApiErrorResponse | null {
  try {
    return JSON.parse(text) as OpenApiErrorResponse;
  } catch {
    return null;
  }
}

function formatOpenApiError(parsed: OpenApiErrorResponse | null, rawText: string): string {
  if (!parsed) return rawText.slice(0, 300);
  const parts = [parsed.message, parsed.code, parsed.detail?.hint].filter(
    (v): v is string => typeof v === 'string' && v.length > 0,
  );
  if (parsed.detail?.connectUrl) parts.push(`Connect WordPress: ${parsed.detail.connectUrl}`);
  if (parsed.detail?.upgradeUrl) parts.push(`Upgrade plan: ${parsed.detail.upgradeUrl}`);
  if (parsed.detail?.upstreamMessage) parts.push(`WP said: ${parsed.detail.upstreamMessage}`);
  if (parsed.detail?.categoryName) parts.push(`Missing category: ${parsed.detail.categoryName}`);
  if (parsed.detail?.retryAfter) parts.push(`Retry-After: ${parsed.detail.retryAfter}`);
  return parts.join(' | ') || rawText.slice(0, 300);
}

// ---------------------------------------------------------------------------
// Unified ↔ WP shape mapping
// ---------------------------------------------------------------------------

// WP 原生 status → toolkit 暴露的旧字符串;服务端把 PostState 映射回 WP native,
// 所以这里只做兜底(直接返回值通常已经是 'publish'/'draft'/...)。
function toWPStatus(raw: unknown): WPPostStatus {
  const s = String(raw ?? 'draft');
  // unified enum 名 → WP native(adapter 已经会用 toWordPressStatus 转回 native,
  // 但万一上游直接回了 unified state 也兜住)
  switch (s) {
    case 'published':
      return 'publish';
    case 'scheduled':
      return 'future';
    case 'trashed':
      return 'trash' as WPPostStatus;
    default:
      return s as WPPostStatus;
  }
}

// toolkit 调用方传入的 WP status → PostState enum 值
function wpStatusToPostState(s: WPPostStatus | undefined): string | undefined {
  if (!s) return undefined;
  switch (s) {
    case 'publish':
      return 'published';
    case 'future':
      return 'scheduled';
    case 'private':
      return 'private';
    case 'pending':
    case 'draft':
      return 'draft';
    default:
      return undefined;
  }
}

function normalizePost(post: Record<string, unknown>): WPPost {
  return {
    id: Number(post.id ?? 0),
    title: String(post.title ?? ''),
    content: String(post.content ?? ''),
    excerpt: (post.excerpt as string | undefined) ?? undefined,
    status: toWPStatus(post.status),
    slug: String(post.slug ?? ''),
    link: String(post.link ?? ''),
    author: Number(post.author ?? 0),
    featuredMedia: Number(post.featuredMedia ?? 0),
    categories: Array.isArray(post.categories)
      ? post.categories
          .map((n) => Number(n))
          .filter((n) => Number.isFinite(n))
      : [],
    tags: Array.isArray(post.tags)
      ? post.tags.map((n) => Number(n)).filter((n) => Number.isFinite(n))
      : [],
    date: String(post.date ?? ''),
    modified: String(post.modified ?? ''),
    format: typeof post.format === 'string' ? post.format : undefined,
    adminUrl: (post.adminUrl as string | null | undefined) ?? null,
  };
}

function normalizeMedia(media: Record<string, unknown>): WPMedia {
  return {
    id: Number(media.id ?? 0),
    sourceUrl: String(media.sourceUrl ?? ''),
    title: String(media.title ?? ''),
    mimeType: String(media.mimeType ?? ''),
    mediaType: String(media.mediaType ?? ''),
    slug: String(media.slug ?? ''),
    altText: (media.altText as string | undefined) ?? undefined,
    caption: (media.caption as string | undefined) ?? undefined,
    markdown: String(media.markdown ?? ''),
  };
}

function normalizeCategory(c: Record<string, unknown>): WPCategory {
  return {
    id: Number(c.id ?? 0),
    name: String(c.name ?? ''),
    slug: String(c.slug ?? ''),
    parent: Number(c.parent ?? 0),
    count: Number(c.count ?? 0),
    description: String(c.description ?? ''),
  };
}

function normalizeTag(t: Record<string, unknown>): WPTag {
  return {
    id: Number(t.id ?? 0),
    name: String(t.name ?? ''),
    slug: String(t.slug ?? ''),
    count: Number(t.count ?? 0),
    description: String(t.description ?? ''),
  };
}

function normalizeComment(c: Record<string, unknown>): WPComment {
  return {
    id: Number(c.id ?? 0),
    post: Number(c.post ?? 0),
    parent: Number(c.parent ?? 0),
    author: Number(c.author ?? 0),
    authorName: String(c.authorName ?? ''),
    authorEmail: (c.authorEmail as string | undefined) ?? undefined,
    authorUrl: (c.authorUrl as string | undefined) ?? undefined,
    content: String(c.content ?? ''),
    status: String(c.status ?? ''),
    date: String(c.date ?? ''),
    link: String(c.link ?? ''),
  };
}

// CreatePostOptions / UpdatePostOptions → UnifiedPost-shaped payload
function buildUnifiedPost(options: UpdatePostOptions): Record<string, unknown> {
  const post: Record<string, unknown> = {};
  if (options.title !== undefined) post.title = options.title;
  if (options.content !== undefined) {
    post.content = { format: 'html', body: options.content };
  }
  if (options.excerpt !== undefined) post.excerpt = options.excerpt;
  const state = wpStatusToPostState(options.status);
  if (state !== undefined) post.state = state;
  if (options.tags !== undefined) post.tags = options.tags;
  if (options.categories !== undefined) post.categories = options.categories;
  if (options.featuredMedia !== undefined) {
    // featuredMedia 是 WP native 字段,下放到 extras 让 adapter 透传给 WP API
    post.extras = { ...(post.extras as Record<string, unknown> | undefined), featuredMedia: options.featuredMedia };
  }
  if (options.slug !== undefined) post.slug = options.slug;
  if (options.date !== undefined) post.scheduledAt = options.date;
  if (options.format !== undefined) {
    post.extras = { ...(post.extras as Record<string, unknown> | undefined), format: options.format };
  }
  return post;
}

// ---------------------------------------------------------------------------
// Public API — Posts
// ---------------------------------------------------------------------------

export async function validateConnection(config?: WordPressConfig): Promise<WPConnectionResult> {
  const r = await callPublishing<Record<string, unknown>>('validateConnection', {}, config);
  return {
    ok: Boolean(r.ok),
    message: String(r.message ?? ''),
    accountId: typeof r.accountId === 'number' ? r.accountId : undefined,
    accountName: (r.accountName as string | undefined) ?? undefined,
    accountUsername: (r.accountUsername as string | undefined) ?? undefined,
    siteUrl: (r.siteUrl as string | undefined) ?? undefined,
  };
}

export async function createPost(
  config: WordPressConfig | undefined,
  options: CreatePostOptions,
): Promise<WPPost> {
  const post = await callPublishing<Record<string, unknown>>(
    'createPost',
    { post: buildUnifiedPost(options) },
    config,
  );
  return normalizePost(post);
}

export async function updatePost(
  config: WordPressConfig | undefined,
  postId: number,
  options: UpdatePostOptions,
): Promise<WPPost> {
  const post = await callPublishing<Record<string, unknown>>(
    'updatePost',
    { post: { postId: String(postId), ...buildUnifiedPost(options) } },
    config,
  );
  return normalizePost(post);
}

export async function getPost(
  config: WordPressConfig | undefined,
  postId: number,
  context: WPViewContext = 'view',
): Promise<WPPost> {
  const post = await callPublishing<Record<string, unknown>>(
    'getPost',
    { postId: String(postId), context },
    config,
  );
  return normalizePost(post);
}

export async function deletePost(
  config: WordPressConfig | undefined,
  postId: number,
  force = false,
): Promise<WPDeleteResult> {
  const r = await callPublishing<Record<string, unknown>>(
    'deletePost',
    { postId: String(postId), force },
    config,
  );
  return {
    ok: Boolean(r.ok ?? true),
    id: Number(r.id ?? r.postId ?? postId),
    deletedPermanently: Boolean(r.deletedPermanently ?? true),
  };
}

export async function publishPost(
  config: WordPressConfig | undefined,
  postId: number,
): Promise<WPPost> {
  const post = await callPublishing<Record<string, unknown>>(
    'transitionPostState',
    { postId: String(postId), toState: 'published' },
    config,
  );
  return normalizePost(post);
}

export async function unpublishPost(
  config: WordPressConfig | undefined,
  postId: number,
): Promise<WPPost> {
  const post = await callPublishing<Record<string, unknown>>(
    'transitionPostState',
    { postId: String(postId), toState: 'draft' },
    config,
  );
  return normalizePost(post);
}

export async function listPosts(
  config: WordPressConfig | undefined,
  page = 1,
  perPage = 15,
  status?: WPListStatus,
): Promise<WPPostListResponse> {
  const state =
    !status || status === 'any'
      ? 'all'
      : status === 'publish'
        ? 'published'
        : status === 'future'
          ? 'scheduled'
          : status; // 'draft' / 'pending' / 'private' 直接透传
  const r = await callPublishing<Record<string, unknown>>(
    'listPosts',
    { filter: { state, paging: { page, perPage } } },
    config,
  );
  return {
    posts: Array.isArray(r.posts)
      ? r.posts.map((p) => normalizePost(p as Record<string, unknown>))
      : [],
    total: Number(r.total ?? 0),
    totalPages: Number(r.totalPages ?? 0),
    page: Number(r.page ?? page),
    perPage: Number(r.perPage ?? perPage),
  };
}

export async function listDraftPosts(
  config: WordPressConfig | undefined,
  page = 1,
  perPage = 15,
): Promise<WPPostListResponse> {
  return listPosts(config, page, perPage, 'draft');
}

export async function listPublishedPosts(
  config: WordPressConfig | undefined,
  page = 1,
  perPage = 15,
): Promise<WPPostListResponse> {
  return listPosts(config, page, perPage, 'publish');
}

// ---------------------------------------------------------------------------
// Public API — Media
// ---------------------------------------------------------------------------

export async function uploadMedia(
  config: WordPressConfig | undefined,
  input: UploadMediaInput,
): Promise<WPMedia> {
  const filePath = resolve(input.filePath);
  if (!existsSync(filePath)) {
    throw new Error(`Media file not found: ${filePath}`);
  }
  const filename = input.filename || filePath.split('/').pop() || 'upload.bin';
  const contentBase64 = readFileSync(filePath).toString('base64');

  const media: Record<string, unknown> = {
    kind: 'image',
    filename,
    source: { base64: contentBase64 },
  };
  if (input.contentType) media.contentType = input.contentType;
  // altText / caption 是 WP-only 字段,UnifiedMedia 没暴露——adapter 也没读,
  // 所以这里就不再下发了(旧 OpenAPI 才直接接收)。

  const result = await callPublishing<Record<string, unknown>>(
    'uploadMedia',
    { media },
    config,
  );
  return normalizeMedia(result);
}

// ---------------------------------------------------------------------------
// Public API — Taxonomy
// ---------------------------------------------------------------------------

export async function listCategories(
  config: WordPressConfig | undefined,
  page = 1,
  perPage = 50,
  search?: string,
): Promise<WPCategoryListResponse> {
  const filter: Record<string, unknown> = {
    kind: 'category',
    paging: { page, perPage },
  };
  if (search) filter.query = search;
  const r = await callPublishing<Record<string, unknown>>(
    'listTaxonomy',
    { filter },
    config,
  );
  return {
    categories: Array.isArray(r.categories)
      ? r.categories.map((c) => normalizeCategory(c as Record<string, unknown>))
      : [],
    total: Number(r.total ?? 0),
    totalPages: Number(r.totalPages ?? 0),
  };
}

export async function listTags(
  config: WordPressConfig | undefined,
  page = 1,
  perPage = 50,
  search?: string,
): Promise<WPTagListResponse> {
  const filter: Record<string, unknown> = {
    kind: 'tag',
    paging: { page, perPage },
  };
  if (search) filter.query = search;
  const r = await callPublishing<Record<string, unknown>>(
    'listTaxonomy',
    { filter },
    config,
  );
  return {
    tags: Array.isArray(r.tags) ? r.tags.map((t) => normalizeTag(t as Record<string, unknown>)) : [],
    total: Number(r.total ?? 0),
    totalPages: Number(r.totalPages ?? 0),
  };
}

// ---------------------------------------------------------------------------
// Public API — Categories CRUD
// ---------------------------------------------------------------------------

export async function createCategory(
  config: WordPressConfig | undefined,
  input: CreateCategoryInput,
): Promise<WPCategory> {
  const taxonomy: Record<string, unknown> = { kind: 'category', name: input.name };
  if (input.slug !== undefined) taxonomy.slug = input.slug;
  if (input.description !== undefined) taxonomy.description = input.description;
  if (input.parent !== undefined) taxonomy.parentId = String(input.parent);
  const cat = await callPublishing<Record<string, unknown>>(
    'upsertTaxonomy',
    { taxonomy },
    config,
  );
  return normalizeCategory(cat);
}

export async function updateCategory(
  config: WordPressConfig | undefined,
  id: number,
  input: Partial<CreateCategoryInput>,
): Promise<WPCategory> {
  const taxonomy: Record<string, unknown> = { kind: 'category', id: String(id) };
  if (input.name !== undefined) taxonomy.name = input.name;
  if (input.slug !== undefined) taxonomy.slug = input.slug;
  if (input.description !== undefined) taxonomy.description = input.description;
  if (input.parent !== undefined) taxonomy.parentId = String(input.parent);
  const cat = await callPublishing<Record<string, unknown>>(
    'upsertTaxonomy',
    { taxonomy },
    config,
  );
  return normalizeCategory(cat);
}

export async function deleteCategory(
  config: WordPressConfig | undefined,
  id: number,
): Promise<WPCategoryDeleteResult> {
  const r = await callPublishing<Record<string, unknown>>(
    'deleteTaxonomy',
    { kind: 'category', taxonomyId: String(id) },
    config,
  );
  return {
    ok: Boolean(r.ok ?? true),
    id: Number(r.id ?? r.taxonomyId ?? id),
    deletedPermanently: Boolean(r.deletedPermanently ?? true),
  };
}

// ---------------------------------------------------------------------------
// Public API — Comments (走聚合 engagement)
// ---------------------------------------------------------------------------

export async function listComments(
  config: WordPressConfig | undefined,
  opts: { postId?: number; status?: WPCommentListStatus; page?: number; perPage?: number } = {},
): Promise<WPCommentListResponse> {
  const filter: Record<string, unknown> = { kind: 'comment' };
  if (opts.postId !== undefined) filter.postId = String(opts.postId);
  // status 是 WP-only 过滤项;UnifiedEngagement 的 filter 没显式暴露——透传,
  // 服务端 adapter 会无视未识别 key。
  if (opts.status !== undefined) filter.status = opts.status;
  const paging: Record<string, unknown> = {};
  if (opts.page !== undefined) paging.page = opts.page;
  if (opts.perPage !== undefined) paging.perPage = opts.perPage;
  if (Object.keys(paging).length > 0) filter.paging = paging;

  const r = await callPublishing<Record<string, unknown>>(
    'listEngagement',
    { filter },
    config,
  );
  return {
    comments: Array.isArray(r.comments)
      ? r.comments.map((c) => normalizeComment(c as Record<string, unknown>))
      : [],
    total: Number(r.total ?? 0),
    totalPages: Number(r.totalPages ?? 0),
    page: Number(r.page ?? opts.page ?? 1),
    perPage: Number(r.perPage ?? opts.perPage ?? 30),
  };
}

export async function getComment(
  config: WordPressConfig | undefined,
  id: number,
): Promise<WPComment> {
  // 聚合层用 listEngagement + filter.commentId 来读单条;adapter 内部走 getComment。
  const r = await callPublishing<Record<string, unknown>>(
    'listEngagement',
    { filter: { commentId: String(id) } },
    config,
  );
  // adapter 直接回单 comment 对象(不包数组)。万一某个实现包了 comments[],做下兜底。
  if (Array.isArray(r.comments) && r.comments[0]) {
    return normalizeComment(r.comments[0] as Record<string, unknown>);
  }
  return normalizeComment(r);
}

export async function createComment(
  config: WordPressConfig | undefined,
  input: CreateCommentInput,
): Promise<WPComment> {
  const engagement: Record<string, unknown> = {
    kind: 'comment',
    postId: String(input.postId),
    content: input.content,
  };
  if (input.parent !== undefined) engagement.parentId = String(input.parent);
  if (input.authorName !== undefined) engagement.authorName = input.authorName;
  if (input.authorEmail !== undefined) engagement.authorEmail = input.authorEmail;
  if (input.authorUrl !== undefined) engagement.authorUrl = input.authorUrl;
  const c = await callPublishing<Record<string, unknown>>(
    'upsertEngagement',
    { engagement },
    config,
  );
  return normalizeComment(c);
}

export async function updateComment(
  config: WordPressConfig | undefined,
  id: number,
  input: UpdateCommentInput,
): Promise<WPComment> {
  const engagement: Record<string, unknown> = { commentId: String(id) };
  if (input.content !== undefined) engagement.content = input.content;
  // status 字段 unified 没暴露——只有 content 可改。
  const c = await callPublishing<Record<string, unknown>>(
    'upsertEngagement',
    { engagement },
    config,
  );
  return normalizeComment(c);
}

export async function deleteComment(
  config: WordPressConfig | undefined,
  id: number,
  force = false,
): Promise<WPCommentDeleteResult> {
  const r = await callPublishing<Record<string, unknown>>(
    'deleteEngagement',
    { commentId: String(id), force },
    config,
  );
  return {
    ok: Boolean(r.ok ?? true),
    id: Number(r.id ?? r.commentId ?? id),
    deletedPermanently: Boolean(r.deletedPermanently ?? true),
  };
}