📄 channel.ts

import {
  buildChannelConfigSchema,
  DEFAULT_ACCOUNT_ID,
  formatPairingApproveHint,
  loadWebMedia,
  missingTargetError,
  type ChannelPlugin,
  type ChannelStatusIssue,
  type ChannelAccountSnapshot,
  type OpenClawConfig,
} from "openclaw/plugin-sdk";
import path from "path";
import { getDingTalkRuntime } from "./runtime.js";
import {
  listDingTalkAccountIds,
  normalizeAccountId,
  resolveDefaultDingTalkAccountId,
  resolveDingTalkAccount,
} from "./accounts.js";
import { DingTalkConfigSchema, type DingTalkConfig, type ResolvedDingTalkAccount, type DingTalkGroupConfig } from "./types.js";
import { sendTextMessage, sendImageMessage, sendFileMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
import { logger } from "./logger.js";
import { monitorDingTalkProvider } from "./monitor.js";
import { dingtalkOnboardingAdapter } from "./onboarding.js";
import { PLUGIN_ID } from "./constants.js";

// ======================= Target Normalization =======================

/**
 * 标准化钉钉发送目标
 * 支持格式:
 * - 原始用户 ID
 * - ddingtalk:user:<userId>  → <userId>
 * - ddingtalk:chat:<groupId> → chat:<groupId>(保留 chat: 前缀用于群聊路由)
 * - ddingtalk:<id>
 * - chat:<groupId>(直接群聊格式)
 * - user:<userId>
 */
function normalizeDingTalkTarget(target: string): string | undefined {
  const trimmed = target.trim();
  if (!trimmed) {
    return undefined;
  }

  // 处理 ddingtalk:chat:<groupId> → chat:<groupId>
  const chatPrefixPattern = new RegExp(`^${PLUGIN_ID}:chat:`, "i");
  if (chatPrefixPattern.test(trimmed)) {
    const groupId = trimmed.replace(chatPrefixPattern, "");
    return groupId ? `chat:${groupId}` : undefined;
  }

  // 处理 chat:<groupId>(直接保留)
  if (trimmed.startsWith("chat:")) {
    return trimmed.slice(5) ? trimmed : undefined;
  }

  // 去除 ddingtalk:user: 或 ddingtalk: 前缀
  const prefixPattern = new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i");
  const withoutPrefix = trimmed.replace(prefixPattern, "");

  // 去除 user: 前缀
  const userId = withoutPrefix.replace(/^user:/, "");

  if (!userId) {
    return undefined;
  }

  // 验证格式:钉钉 ID 一般是字母数字组合
  if (/^[a-zA-Z0-9_$+-]+$/i.test(userId)) {
    return userId;
  }

  return undefined;
}

// DingTalk channel metadata
const meta = {
  id: PLUGIN_ID,
  label: "DingTalk",
  selectionLabel: "DingTalk (钉钉 Stream)",
  detailLabel: "钉钉机器人",
  docsPath: `/channels/${PLUGIN_ID}`,
  docsLabel: PLUGIN_ID,
  blurb: "DingTalk enterprise robot with Stream mode for Chinese market.",
  systemImage: "message.fill",
  aliases: ["dingding", "钉钉"],
};

export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
  id: PLUGIN_ID,
  meta,
  onboarding: dingtalkOnboardingAdapter,
  capabilities: {
    chatTypes: ["direct", "group"],
    reactions: false,
    threads: false,
    media: true,
    nativeCommands: false,
    blockStreaming: true, // 钉钉不支持流式消息
  },
  commands: {
    enforceOwnerForCommands: true,
  },
  groups: {
    resolveToolPolicy: ({ cfg, groupId }) => {
      if (!groupId) return undefined;
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
      const groups = dingtalkConfig.groups;
      if (!groups) return undefined;
      const key = Object.keys(groups).find(
        (k) => k === groupId || k.toLowerCase() === groupId.toLowerCase()
      );
      return key ? groups[key]?.tools : undefined;
    },
  },
  reload: { configPrefixes: [`channels.${PLUGIN_ID}`] },
  configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
  config: {
    listAccountIds: (cfg) => listDingTalkAccountIds(cfg),
    resolveAccount: (cfg, _accountId) => resolveDingTalkAccount({ cfg }),
    defaultAccountId: (_cfg) => resolveDefaultDingTalkAccountId(_cfg),
    setAccountEnabled: ({ cfg, enabled }) => {
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
      return {
        ...cfg,
        channels: {
          ...cfg.channels,
          [PLUGIN_ID]: {
            ...dingtalkConfig,
            enabled,
          },
        },
      };
    },
    deleteAccount: ({ cfg }) => {
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
      const { clientId, clientSecret, ...rest } = dingtalkConfig;
      return {
        ...cfg,
        channels: {
          ...cfg.channels,
          [PLUGIN_ID]: rest,
        },
      };
    },
    isConfigured: (account) => Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
    describeAccount: (account) => ({
      accountId: account.accountId,
      name: account.name,
      enabled: account.enabled,
      configured: Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
      tokenSource: account.tokenSource,
    }),
    resolveAllowFrom: ({ cfg }) =>
      resolveDingTalkAccount({ cfg }).allowFrom.map((entry) => String(entry)),
    formatAllowFrom: ({ allowFrom }) =>
      allowFrom
        .map((entry) => String(entry).trim())
        .filter(Boolean)
        .map((entry) => entry.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), "")),
  },
  security: {
    resolveDmPolicy: ({ cfg }) => {
      const account = resolveDingTalkAccount({ cfg });
      return {
        policy: "allowlist",
        allowFrom: account.allowFrom,
        policyPath: `channels.${PLUGIN_ID}.allowFrom`,
        allowFromPath: `channels.${PLUGIN_ID}.`,
        approveHint: formatPairingApproveHint(PLUGIN_ID),
        normalizeEntry: (raw) => raw.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), ""),
      };
    },
  },
  messaging: {
    normalizeTarget: (target) => {
      const trimmed = target.trim();
      if (!trimmed) {
        return undefined;
      }
      return normalizeDingTalkTarget(trimmed);
    },
    targetResolver: {
      looksLikeId: (id) => {
        const trimmed = id?.trim();
        if (!trimmed) {
          return false;
        }
        // 钉钉用户 ID 或群聊 ID
        const prefixPattern = new RegExp(`^${PLUGIN_ID}:`, "i");
        return /^[a-zA-Z0-9_-]+$/i.test(trimmed)
          || prefixPattern.test(trimmed)
          || trimmed.startsWith("chat:")
          || trimmed.startsWith("user:");
      },
      hint: "<userId> or chat:<openConversationId>",
    },
  },

  setup: {
    resolveAccountId: () => normalizeAccountId(),
    applyAccountName: ({ cfg, name }) => {
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
      return {
        ...cfg,
        channels: {
          ...cfg.channels,
          [PLUGIN_ID]: {
            ...dingtalkConfig,
            name,
          },
        },
      };
    },
    validateInput: ({ input }) => {
      const typedInput = input as {
        clientId?: string;
        clientSecret?: string;
      };
      if (!typedInput.clientId) {
        return "DingTalk requires clientId.";
      }
      if (!typedInput.clientSecret) {
        return "DingTalk requires clientSecret.";
      }
      return null;
    },
    applyAccountConfig: ({ cfg, input }) => {
      const typedInput = input as {
        name?: string;
        clientId?: string;
        clientSecret?: string;
      };
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;

      return {
        ...cfg,
        channels: {
          ...cfg.channels,
          [PLUGIN_ID]: {
            ...dingtalkConfig,
            enabled: true,
            ...(typedInput.name ? { name: typedInput.name } : {}),
            ...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
            ...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
          },
        },
      };
    },
  },
  outbound: {
    deliveryMode: "direct",
    chunker: (text, limit) => getDingTalkRuntime().channel.text.chunkMarkdownText(text, limit),
    textChunkLimit: 4000, // 钉钉文本消息长度限制
    /**
     * 解析发送目标
     * 支持以下格式:
     * - 用户 ID:直接是用户的 staffId
     * - 带前缀格式:ddingtalk:user:<userId>
     * - 群聊格式:chat:<openConversationId> 或 ddingtalk:chat:<openConversationId>
     */
    resolveTarget: ({ to, allowFrom, mode }) => {
      const trimmed = to?.trim() ?? "";

      // 如果目标是群聊格式,直接使用(群聊回复时 To 已经是 chat:xxx 格式)
      if (trimmed.startsWith("chat:") || trimmed.startsWith(`${PLUGIN_ID}:chat:`)) {
        const normalized = normalizeDingTalkTarget(trimmed);
        if (normalized) {
          return { ok: true, to: normalized };
        }
      }

      const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
      const hasWildcard = allowListRaw.includes("*");
      const allowList = allowListRaw
        .filter((entry) => entry !== "*")
        .map((entry) => normalizeDingTalkTarget(entry))
        .filter((entry): entry is string => Boolean(entry));

      // 有指定目标
      if (trimmed) {
        const normalizedTo = normalizeDingTalkTarget(trimmed);

        if (!normalizedTo) {
          if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
            return { ok: true, to: allowList[0] };
          }
          return {
            ok: false,
            error: missingTargetError(
              "DingTalk",
              `<userId>, chat:<groupId> 或 channels.${PLUGIN_ID}.allowFrom[0]`,
            ),
          };
        }

        if (mode === "explicit") {
          return { ok: true, to: normalizedTo };
        }

        if (mode === "implicit" || mode === "heartbeat") {
          if (hasWildcard || allowList.length === 0) {
            return { ok: true, to: normalizedTo };
          }
          if (allowList.includes(normalizedTo)) {
            return { ok: true, to: normalizedTo };
          }
          return { ok: true, to: allowList[0] };
        }

        return { ok: true, to: normalizedTo };
      }

      // 没有指定目标
      if (allowList.length > 0) {
        return { ok: true, to: allowList[0] };
      }

      return {
        ok: false,
        error: missingTargetError(
          "DingTalk",
          `<userId>, chat:<groupId> 或 channels.${PLUGIN_ID}.allowFrom[0]`,
        ),
      };
    },
    sendText: async ({ to, text, cfg }) => {
      const account = resolveDingTalkAccount({ cfg });
      const result = await sendTextMessage(to, text, { account });
      return { channel: PLUGIN_ID, ...result };
    },
    sendMedia: async ({ to, text, mediaUrl, cfg }) => {
      // 没有媒体 URL,提前返回
      if (!mediaUrl) {
        logger.warn("[sendMedia] 没有 mediaUrl,跳过");
        return { channel: PLUGIN_ID, messageId: "", chatId: to };
      }

      const account = resolveDingTalkAccount({ cfg });

      try {
        logger.log(`准备发送媒体: ${mediaUrl}`);

        // 使用 OpenClaw 的 loadWebMedia 加载媒体(支持 URL、本地路径、file://、~ 等)
        const media = await loadWebMedia(mediaUrl);
        const mimeType = media.contentType ?? "application/octet-stream";
        const mediaType = inferMediaType(mimeType);

        logger.log(`加载媒体成功 | type: ${mediaType} | mimeType: ${mimeType} | size: ${(media.buffer.length / 1024).toFixed(2)} KB`);

        // 上传到钉钉
        const fileName = media.fileName || path.basename(mediaUrl) || `file_${Date.now()}`;
        const uploadResult = await uploadMedia(media.buffer, fileName, account, {
          mimeType,
          type: mediaType,
        });

        // 统一使用文件发送(语音/视频因格式限制和参数要求,也降级为文件)
        const ext = path.extname(fileName).slice(1) || "file";
        let sendResult: { messageId: string; chatId: string };

        if (mediaType === "image") {
          // 图片使用 photoURL
          sendResult = await sendImageMessage(to, uploadResult.url, { account });
        } else {
          // 语音、视频、文件统一使用文件发送
          sendResult = await sendFileMessage(to, uploadResult.mediaId, fileName, ext, { account });
        }

        logger.log(`发送${mediaType}消息成功(${mediaType !== "image" ? "文件形式" : "图片形式"})`);

        // 如果有文本,再发送文本消息
        if (text?.trim()) {
          await sendTextMessage(to, text, { account });
        }

        return { channel: PLUGIN_ID, ...sendResult };
      } catch (err) {
        logger.error("发送媒体失败:", err);
        // 降级:发送文本消息附带链接
        const fallbackText = text ? `${text}\n\n📎 附件: ${mediaUrl}` : `📎 附件: ${mediaUrl}`;
        const result = await sendTextMessage(to, fallbackText, { account });
        return { channel: PLUGIN_ID, ...result };
      }
    },
  },
  status: {
    defaultRuntime: {
      accountId: DEFAULT_ACCOUNT_ID,
      running: false,
      lastStartAt: null,
      lastStopAt: null,
      lastError: null,
    },
    collectStatusIssues: (accounts: ChannelAccountSnapshot[]) => {
      const issues: ChannelStatusIssue[] = [];
      for (const account of accounts) {
        const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
        // Check if configured flag is false
        if (!account.configured) {
          issues.push({
            channel: PLUGIN_ID,
            accountId,
            kind: "config",
            message: "DingTalk credentials (clientId/clientSecret) not configured",
          });
        }
      }
      return issues;
    },
    buildChannelSummary: ({ snapshot }) => ({
      configured: snapshot.configured ?? false,
      tokenSource: snapshot.tokenSource ?? "none",
      running: snapshot.running ?? false,
      mode: snapshot.mode ?? null,
      lastStartAt: snapshot.lastStartAt ?? null,
      lastStopAt: snapshot.lastStopAt ?? null,
      lastError: snapshot.lastError ?? null,
      probe: snapshot.probe,
      lastProbeAt: snapshot.lastProbeAt ?? null,
    }),
    probeAccount: async ({ account, timeoutMs }) => probeDingTalkBot(account, timeoutMs),
    buildAccountSnapshot: ({ account, runtime, probe }) => {
      const configured = Boolean(account.clientId?.trim() && account.clientSecret?.trim());
      return {
        accountId: account.accountId,
        name: account.name,
        enabled: account.enabled,
        configured,
        tokenSource: account.tokenSource,
        running: runtime?.running ?? false,
        lastStartAt: runtime?.lastStartAt ?? null,
        lastStopAt: runtime?.lastStopAt ?? null,
        lastError: runtime?.lastError ?? null,
        mode: "stream",
        probe,
        lastInboundAt: runtime?.lastInboundAt ?? null,
        lastOutboundAt: runtime?.lastOutboundAt ?? null,
      };
    },
  },
  gateway: {
    startAccount: async (ctx) => {
      const account = ctx.account;
      const clientId = account.clientId.trim();
      const clientSecret = account.clientSecret.trim();

      let botLabel = "";
      try {
        const probe = await probeDingTalkBot(account, 2500);
        const displayName = probe.ok ? probe.bot?.name?.trim() : null;
        if (displayName) {
          botLabel = ` (${displayName})`;
        }
      } catch (err) {
        if (getDingTalkRuntime().logging.shouldLogVerbose()) {
          ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
        }
      }

      ctx.log?.info(`[${account.accountId}] starting DingTalk provider${botLabel}`);

      return monitorDingTalkProvider({
        clientId,
        clientSecret,
        accountId: account.accountId,
        config: ctx.cfg,
        runtime: ctx.runtime,
        abortSignal: ctx.abortSignal,
      });
    },
    logoutAccount: async ({ cfg }) => {
      const nextCfg = { ...cfg } as OpenClawConfig;
      const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
      const nextDingTalk = { ...dingtalkConfig };
      let cleared = false;
      let changed = false;

      if (
        nextDingTalk.clientId ||
        nextDingTalk.clientSecret
      ) {
        delete nextDingTalk.clientId;
        delete nextDingTalk.clientSecret;
        cleared = true;
        changed = true;
      }

      if (changed) {
        if (Object.keys(nextDingTalk).length > 0) {
          nextCfg.channels = { ...nextCfg.channels, [PLUGIN_ID]: nextDingTalk };
        } else {
          const nextChannels = { ...nextCfg.channels };
          delete (nextChannels as Record<string, unknown>)[PLUGIN_ID];
          if (Object.keys(nextChannels).length > 0) {
            nextCfg.channels = nextChannels;
          } else {
            delete nextCfg.channels;
          }
        }
        await getDingTalkRuntime().config.writeConfigFile(nextCfg);
      }

      const resolved = resolveDingTalkAccount({
        cfg: changed ? nextCfg : cfg,
      });
      const loggedOut = resolved.tokenSource === "none";

      return { cleared, envToken: false, loggedOut };
    },
  },
};