Skill: systransform88/openclaw-feishu-message (version 0.1.0)

--- name: openclaw-feishu-message description: Send Feishu/Lark direct messages and work follow-ups from OpenClaw. Use when the user wants to search for a person in Feishu/Lark, resolve a user, create a 1:1 chat, or send a bot-authored follow-up message about work done, status updates, reminders, or polite nudges. --- # Openclaw Feishu Message Use this plugin to resolve a Feishu/Lark recipient and send a message as the configured bot. ## Contact lookup trigger rules When the user asks to search, find, look up, resolve, or identify a person in Feishu/Lark contacts, employee directory, or chat recipients, use `feishu_message` first. This includes short requests like: - "search Ken" - "search Kai Lin" - "find Jennifer" - "look up WT Chim" - "search contact Kai Lin" - "find Jennifer in Lark" - "search employee by email" - "who is this Lark contact" - "find the open_id for SysTransform" Do not claim there is no contact search tool when `feishu_message` is available in the runtime. Do not answer a contact search request from general reasoning alone when `feishu_message` can be called. ## Core workflow 1. Resolve the target. - Prefer `lookup_employee` for exact identifiers: `exact_name`, `email`, or `mobile`. - Prefer `find_contact` for generic user prompts like `search Ken`, `find Jennifer`, or `look up WT Chim`. - Use `search_employee` for broader fuzzy queries or when you need a wider result set. - For generic prompts like `search Ken`, do not paraphrase from memory, call `find_contact`. - Prefer cached/open_id results over repeated fuzzy search when available. 2. Cache the resolved contact. - If a single match is found through `lookup_employee` or `search_employee`, let that tool path persist the contact cache. - For generic prompts like `find Ken` or `search Ken`, still route through `search_employee` or `lookup_employee` so cache updates. - Prefer tool-backed resolution over manual restatement so cache stays fresh. 3. Return the resolved identity clearly. - Include `name`, `open_id`, `user_id`, and `email` when present. 4. Preview or compose the message. - For work follow-up, prefer `send_followup` with `dry_run=true` first when ambiguity exists. 5. Send only when the target is clear. - For ordinary natural-language requests to message a person, resolve the person first, then send. - Prefer `send_contact_message` for ordinary natural-language requests to send a message to a person. - If the target is a person name like "SysTransform" or "Michael Chin", do not jump straight to raw-ID sending. Resolve/cache the person first, then send. - Use `send_message` when the target receive ID is already known. - For requests like "send a message to Michael" or "ask SysTransform for April PAS", attempt the real tool path first, not a drafted reply. - Do not invent chat-send actions or require chat creation when direct send can be used. - Use `create_p2p_chat` only if the user explicitly asked to create/open/start a chat. - Do not use `create_p2p_chat` as the default path for ordinary message sending. - Do not fall back to drafting text for the user unless the tool actually failed. - If the tool fails, report the real tool error briefly instead of a generic excuse. - If multiple people match the same name, stop and ask the user which one they mean. ## Available actions ### `search_employee` Use to find likely employee matches from a name or keyword. Example intent: - "Find John Tan in Lark" - "Search employee Chloe" ### `create_p2p_chat` Use only when the user explicitly asks to create/open/start a chat. Do not use `create_p2p_chat` for ordinary message requests when `send_message` can send directly. ### `send_message` Use when the target receive ID is already known and the exact text is ready. ### `send_contact_message` Use for ordinary requests like "send a message to Michael" or "tell SysTransform good night". This action should resolve the contact, cache the resolved identity, and send the message in one path. Prefer this over ad-libbed multi-step reasoning. ### `send_followup` Use for polite work-status nudges. Recommended inputs: - `target`: one of `open_id`, `user_id`, `union_id`, `email`, `chat_id`, or `name` - `work_item`: short description of the completed work or task - `status_prompt`: optional custom ask - `signature`: optional sender sign-off - `dry_run`: use `true` first if the target was resolved by name only ## Messaging guidelines - Keep follow-ups short and specific. - Mention the work item clearly. - Ask for one concrete update. - If the name lookup is fuzzy, do not send blindly. ## Notes - This plugin depends on tenant scopes that allow employee lookup, chat creation, and bot messaging. - Tenant policy may still restrict who the bot can message even when scopes exist.

path: data/contact-cache.json
content: {
  "contacts": {
    "11712417": {
      "name": null,
      "open_id": null,
      "user_id": "11712417",
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:48:31.997Z"
    },
    "ou_855ad4e545d2955588fd07adb0d65420": {
      "name": null,
      "open_id": "ou_855ad4e545d2955588fd07adb0d65420",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T14:45:54.961Z"
    },
    "2951fee8": {
      "name": null,
      "open_id": null,
      "user_id": "2951fee8",
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:14:38.167Z"
    },
    "on_c90fc777ec3080eb9ff0707af2460cf8": {
      "name": "SysTransform",
      "open_id": "ou_855ad4e545d2955588fd07adb0d65420",
      "user_id": "2951fee8",
      "union_id": "on_c90fc777ec3080eb9ff0707af2460cf8",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T13:34:05.668Z"
    },
    "[REDACTED_SECRET]": {
      "name": "SysTransform",
      "open_id": "ou_855ad4e545d2955588fd07adb0d65420",
      "user_id": "2951fee8",
      "union_id": "on_c90fc777ec3080eb9ff0707af2460cf8",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T13:34:05.668Z"
    },
    "systransform": {
      "name": "SysTransform",
      "open_id": "ou_855ad4e545d2955588fd07adb0d65420",
      "user_id": "2951fee8",
      "union_id": "on_c90fc777ec3080eb9ff0707af2460cf8",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T13:34:05.668Z"
    },
    "ou_10d099466e29505426038a79d1b22813": {
      "name": "[REDACTED_SECRET]",
      "open_id": "ou_10d099466e29505426038a79d1b22813",
      "user_id": "836gb86d",
      "union_id": "on_a532c42784a19b60b2241ad2dded085f",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:05:15.543Z"
    },
    "836gb86d": {
      "name": "[REDACTED_SECRET]",
      "open_id": "ou_10d099466e29505426038a79d1b22813",
      "user_id": "836gb86d",
      "union_id": "on_a532c42784a19b60b2241ad2dded085f",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:05:15.543Z"
    },
    "on_a532c42784a19b60b2241ad2dded085f": {
      "name": "[REDACTED_SECRET]",
      "open_id": "ou_10d099466e29505426038a79d1b22813",
      "user_id": "836gb86d",
      "union_id": "on_a532c42784a19b60b2241ad2dded085f",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:05:15.543Z"
    },
    "[REDACTED_SECRET]": {
      "name": "[REDACTED_SECRET]",
      "open_id": "ou_10d099466e29505426038a79d1b22813",
      "user_id": "836gb86d",
      "union_id": "on_a532c42784a19b60b2241ad2dded085f",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:05:15.543Z"
    },
    "ou_c0fe1abf5635753bfa338515b3d3772f": {
      "name": "WT Chim",
      "open_id": "ou_c0fe1abf5635753bfa338515b3d3772f",
      "user_id": "7gc18717",
      "union_id": "on_2d226b5413fbd6fe78d46947e29ceb6c",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:06:45.189Z"
    },
    "7gc18717": {
      "name": "WT Chim",
      "open_id": "ou_c0fe1abf5635753bfa338515b3d3772f",
      "user_id": "7gc18717",
      "union_id": "on_2d226b5413fbd6fe78d46947e29ceb6c",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:06:45.189Z"
    },
    "on_2d226b5413fbd6fe78d46947e29ceb6c": {
      "name": "WT Chim",
      "open_id": "ou_c0fe1abf5635753bfa338515b3d3772f",
      "user_id": "7gc18717",
      "union_id": "on_2d226b5413fbd6fe78d46947e29ceb6c",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:06:45.189Z"
    },
    "[REDACTED_SECRET]": {
      "name": "WT Chim",
      "open_id": "ou_c0fe1abf5635753bfa338515b3d3772f",
      "user_id": "7gc18717",
      "union_id": "on_2d226b5413fbd6fe78d46947e29ceb6c",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:06:45.189Z"
    },
    "wt chim": {
      "name": "WT Chim",
      "open_id": "ou_c0fe1abf5635753bfa338515b3d3772f",
      "user_id": "7gc18717",
      "union_id": "on_2d226b5413fbd6fe78d46947e29ceb6c",
      "email": "[REDACTED_SECRET]",
      "mobile": null,
      "updated_at": "2026-04-13T14:06:45.189Z"
    },
    "oc_82cc796569c6cca39484cbc73226aca6": {
      "name": null,
      "open_id": "oc_82cc796569c6cca39484cbc73226aca6",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:15:53.413Z"
    },
    "oc_5b21fae4ccc4427d9c7a079b4d77de06": {
      "name": null,
      "open_id": "oc_5b21fae4ccc4427d9c7a079b4d77de06",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:24:12.716Z"
    },
    "ou_e38a192957631d7745decd0a72af1609": {
      "name": null,
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:42:36.793Z"
    },
    "ou_0704e073b7f0a41f430242804d71f590": {
      "name": null,
      "open_id": "ou_0704e073b7f0a41f430242804d71f590",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:57:12.929Z"
    },
    "a43ac5af": {
      "name": "Michael Chin",
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": "a43ac5af",
      "union_id": "on_d33081aedbc4130f3fc9535725bc8724",
      "email": "[REDACTED_SECRET]",
      "mobile": "+60182263008",
      "updated_at": "2026-04-13T15:41:59.329Z"
    },
    "on_d33081aedbc4130f3fc9535725bc8724": {
      "name": "Michael Chin",
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": "a43ac5af",
      "union_id": "on_d33081aedbc4130f3fc9535725bc8724",
      "email": "[REDACTED_SECRET]",
      "mobile": "+60182263008",
      "updated_at": "2026-04-13T15:41:59.329Z"
    },
    "[REDACTED_SECRET]": {
      "name": "Michael Chin",
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": "a43ac5af",
      "union_id": "on_d33081aedbc4130f3fc9535725bc8724",
      "email": "[REDACTED_SECRET]",
      "mobile": "+60182263008",
      "updated_at": "2026-04-13T15:41:59.329Z"
    },
    "+60182263008": {
      "name": "Michael Chin",
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": "a43ac5af",
      "union_id": "on_d33081aedbc4130f3fc9535725bc8724",
      "email": "[REDACTED_SECRET]",
      "mobile": "+60182263008",
      "updated_at": "2026-04-13T15:41:59.329Z"
    },
    "michael chin": {
      "name": "Michael Chin",
      "open_id": "ou_e38a192957631d7745decd0a72af1609",
      "user_id": "a43ac5af",
      "union_id": "on_d33081aedbc4130f3fc9535725bc8724",
      "email": "[REDACTED_SECRET]",
      "mobile": "+60182263008",
      "updated_at": "2026-04-13T15:41:59.329Z"
    },
    "oc_e1983a409f369611b57db00689242750": {
      "name": null,
      "open_id": "oc_e1983a409f369611b57db00689242750",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:42:01.678Z"
    },
    "oc_5b9c7b484737cc20058091bb01241ff9": {
      "name": null,
      "open_id": "oc_5b9c7b484737cc20058091bb01241ff9",
      "user_id": null,
      "union_id": null,
      "email": null,
      "mobile": null,
      "updated_at": "2026-04-13T15:53:52.835Z"
    }
  }
}

path: index.js
content: import { definePluginEntry } from '/usr/lib/node_modules/openclaw/dist/plugin-sdk/plugin-entry.js';
import { registerFeishuMessageTool } from './src/tool-feishu-message.js';

export default definePluginEntry({
  id: 'openclaw-feishu-message',
  name: 'Feishu Message',
  description: 'Feishu/Lark message tool plugin for OpenClaw.',
  register(api) {
    try {
      console.log('[openclaw-feishu-message] plugin register() called');
    } catch {}
    registerFeishuMessageTool(api);
  },
});


path: openclaw.plugin.json
content: {
  "id": "openclaw-feishu-message",
  "name": "Feishu Message",
  "version": "0.1.0",
  "description": "Feishu/Lark messaging plugin for OpenClaw with contact lookup, caching, direct messaging, and follow-up actions.",
  "entry": "./index.js",
  "skills": ["./skills"],
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "enabled": { "type": "boolean", "default": true },
      "defaultAccountId": { "type": "string" },
      "allowSend": { "type": "boolean", "default": true },
      "defaultReceiveIdType": {
        "type": "string",
        "enum": ["open_id", "user_id", "union_id", "email", "chat_id"],
        "default": "open_id"
      },
      "debug": { "type": "boolean", "default": false }
    }
  }
}


path: package.json
content: {
  "name": "openclaw-feishu-message",
  "version": "0.1.0",
  "type": "module",
  "description": "Feishu/Lark messaging plugin for OpenClaw with contact lookup, caching, direct messaging, and follow-up actions.",
  "keywords": ["openclaw", "feishu", "lark", "message", "im", "chat"],
  "dependencies": {
    "@larksuiteoapi/node-sdk": "^1.59.0",
    "@sinclair/typebox": "^0.34.48",
    "openclaw": "^2026.3.22",
    "zod": "^4.3.6"
  },
  "openclaw": {
    "extensions": ["./index.js"]
  }
}


path: references/api-notes.md
content: # API notes

This plugin assumes Feishu scopes are already granted for:
- employee lookup
- chat creation
- bot message sending

Key scopes observed in this environment include:
- `directory:employee:search`
- `im:chat:create`
- `im:chat:read`
- `im:message:send_as_bot`
- `contact:user.base:readonly`

Practical caveat:
Having scopes does not always guarantee that the bot may DM every user. Tenant policy, app visibility, and bot availability can still block delivery.


path: src/cache.js
content: import fs from 'node:fs';
import path from 'node:path';

const DATA_DIR = path.resolve('/root/.openclaw/workspace/plugins/openclaw-feishu-message/data');
const CACHE_FILE = path.join(DATA_DIR, 'contact-cache.json');

function ensureStore() {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  if (!fs.existsSync(CACHE_FILE)) {
    fs.writeFileSync(CACHE_FILE, JSON.stringify({ contacts: {} }, null, 2));
  }
}

function normalizeKey(value) {
  return String(value || '').trim().toLowerCase();
}

export function readCache() {
  ensureStore();
  try {
    return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
  } catch {
    return { contacts: {} };
  }
}

export function writeCache(data) {
  ensureStore();
  fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
}

export function getCachedContact(target = {}) {
  const store = readCache();
  const contacts = store.contacts || {};
  const keys = [target.open_id, target.user_id, target.union_id, target.email, target.mobile, target.name]
    .filter(Boolean)
    .map(normalizeKey);

  for (const key of keys) {
    if (contacts[key]) return contacts[key];
  }
  return null;
}

export function putCachedContact(contact = {}) {
  const store = readCache();
  const contacts = store.contacts || {};

  const payload = {
    name: contact.name || null,
    open_id: contact.open_id || null,
    user_id: contact.user_id || null,
    union_id: contact.union_id || null,
    email: contact.email || null,
    mobile: contact.mobile || null,
    updated_at: new Date().toISOString(),
  };

  const keys = [payload.open_id, payload.user_id, payload.union_id, payload.email, payload.mobile, payload.name]
    .filter(Boolean)
    .map(normalizeKey);

  for (const key of keys) {
    contacts[key] = payload;
  }

  store.contacts = contacts;
  writeCache(store);
  return payload;
}


path: src/client.js
content: import lark from '@larksuiteoapi/node-sdk';
import { messageError } from './errors.js';

function normalizeDomain(domain) {
  if (!domain || domain === 'feishu') return lark.Domain.Feishu;
  if (domain === 'lark') return lark.Domain.Lark;
  return domain;
}

export function createFeishuClient(accountConfig) {
  if (!accountConfig?.appId || !accountConfig?.appSecret) {
    throw messageError(
      'FEISHU_NOT_CONFIGURED',
      'Missing Feishu appId/appSecret. Runtime config did not provide channels.feishu and persisted config fallback did not resolve usable credentials.',
      {
        accountId: accountConfig?.accountId,
        hasAppId: Boolean(accountConfig?.appId),
        hasAppSecret: Boolean(accountConfig?.appSecret),
      },
    );
  }

  return new lark.Client({
    appId: accountConfig.appId,
    appSecret: accountConfig.appSecret,
    domain: normalizeDomain(accountConfig.domain),
  });
}


path: src/config.js
content: import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

export function resolvePluginConfig(pluginConfig = {}) {
  return {
    enabled: pluginConfig.enabled !== false,
    defaultAccountId: typeof pluginConfig.defaultAccountId === 'string' && pluginConfig.defaultAccountId ? pluginConfig.defaultAccountId : undefined,
    allowSend: pluginConfig.allowSend !== false,
    defaultReceiveIdType: typeof pluginConfig.defaultReceiveIdType === 'string' && pluginConfig.defaultReceiveIdType ? pluginConfig.defaultReceiveIdType : 'open_id',
    debug: pluginConfig.debug === true,
  };
}

function readPersistedOpenClawConfig() {
  const candidates = [
    process.env.OPENCLAW_CONFIG_PATH,
    path.join(os.homedir(), '.openclaw', 'openclaw.json'),
  ].filter(Boolean);

  for (const file of candidates) {
    try {
      if (!fs.existsSync(file)) continue;
      const raw = fs.readFileSync(file, 'utf8');
      return JSON.parse(raw);
    } catch {
      // ignore and continue fallback search
    }
  }

  return {};
}

function pickFeishuConfig(runtimeLike = {}) {
  const runtimeChannels =
    runtimeLike?.config?.channels ||
    runtimeLike?.cfg?.channels ||
    runtimeLike?.channels;

  if (runtimeChannels?.feishu?.appId || runtimeChannels?.feishu?.app_id || runtimeChannels?.feishu?.accounts) {
    return runtimeChannels.feishu;
  }

  const persisted = readPersistedOpenClawConfig();
  return persisted?.channels?.feishu || {};
}

function pickImplicitRuntimeAccountId(runtimeLike = {}) {
  return (
    runtimeLike?.accountId ||
    runtimeLike?.account_id ||
    runtimeLike?.session?.accountId ||
    runtimeLike?.session?.account_id ||
    runtimeLike?.context?.accountId ||
    runtimeLike?.context?.account_id ||
    runtimeLike?.meta?.accountId ||
    runtimeLike?.meta?.account_id ||
    runtimeLike?.channelContext?.accountId ||
    runtimeLike?.channelContext?.account_id ||
    runtimeLike?.config?.accountId ||
    runtimeLike?.cfg?.accountId ||
    undefined
  );
}

export function resolveFeishuAccountConfig(runtimeLike = {}, pluginCfg = {}, requestedAccountId = undefined) {
  const feishu = pickFeishuConfig(runtimeLike);
  const accounts = feishu.accounts || {};
  const implicitRuntimeAccountId = pickImplicitRuntimeAccountId(runtimeLike);
  const accountId = requestedAccountId || implicitRuntimeAccountId || pluginCfg.defaultAccountId || feishu.defaultAccountId || feishu.defaultAccount || Object.keys(accounts)[0] || 'default';
  const account = accounts[accountId] || feishu;

  return {
    accountId,
    appId: account?.appId || account?.app_id,
    appSecret: account?.appSecret || account?.app_secret,
    domain: account?.domain || feishu?.domain || 'feishu',
    raw: account,
    _debug: {
      requestedAccountId,
      implicitRuntimeAccountId,
      pluginDefaultAccountId: pluginCfg.defaultAccountId,
      feishuDefaultAccountId: feishu.defaultAccountId,
      feishuDefaultAccount: feishu.defaultAccount,
      knownAccounts: Object.keys(accounts),
    },
  };
}


path: src/errors.js
content: export class FeishuMessageError extends Error {
  constructor(code, message, details = undefined) {
    super(message);
    this.name = 'FeishuMessageError';
    this.code = code;
    this.details = details;
  }
}

export function messageError(code, message, details) {
  return new FeishuMessageError(code, message, details);
}

function redactSecrets(value, seen = new WeakSet()) {
  if (value == null) return value;
  if (Array.isArray(value)) return value.map((item) => redactSecrets(item, seen));
  if (typeof value === 'object') {
    if (seen.has(value)) return '[Circular]';
    seen.add(value);
    const out = {};
    for (const [key, raw] of Object.entries(value)) {
      const lower = String(key).toLowerCase();
      if (
        lower.includes('token') ||
        lower.includes('authorization') ||
        lower.includes('secret') ||
        lower.includes('password') ||
        lower.includes('cookie')
      ) {
        out[key] = '[REDACTED]';
      } else {
        out[key] = redactSecrets(raw, seen);
      }
    }
    return out;
  }
  return value;
}

export function normalizeError(error) {
  if (error instanceof FeishuMessageError) {
    if (error?.details && typeof error.details === 'object') {
      error.details = redactSecrets(error.details);
    }
    return error;
  }

  const raw = {
    message: error?.message,
    name: error?.name,
    code: error?.code,
    status: error?.status,
    response: error?.response,
    data: error?.data,
    body: error?.body,
    stack: error?.stack,
  };

  return new FeishuMessageError('INTERNAL_ERROR', error?.message || 'Unknown Feishu message error', redactSecrets({
    cause: String(error?.stack || error),
    raw,
  }));
}


path: src/tool-feishu-message.js
content: import { Type } from '@sinclair/typebox';
import { z } from 'zod';
import { createFeishuClient } from './client.js';
import { resolvePluginConfig, resolveFeishuAccountConfig } from './config.js';
import { normalizeError, messageError } from './errors.js';
import { getCachedContact, putCachedContact } from './cache.js';

const ReceiveIdTypeSchema = z.enum(['open_id', 'user_id', 'union_id', 'email', 'chat_id']);

const MessageActionSchema = z.union([
  z.object({
    action: z.literal('search_employee'),
    account_id: z.string().optional(),
    query: z.string().min(1).optional(),
    email: z.string().email().optional(),
    mobile: z.string().min(3).optional(),
    exact_name: z.string().min(1).optional(),
    verbose: z.boolean().optional(),
    limit: z.number().int().min(1).max(50).optional(),
  }).refine((v) => Boolean(v.query || v.email || v.mobile || v.exact_name), {
    message: 'search_employee requires query, email, mobile, or exact_name',
  }),
  z.object({
    action: z.literal('lookup_employee'),
    account_id: z.string().optional(),
    email: z.string().email().optional(),
    mobile: z.string().min(3).optional(),
    exact_name: z.string().min(1).optional(),
    limit: z.number().int().min(1).max(50).optional(),
  }).refine((v) => Boolean(v.email || v.mobile || v.exact_name), {
    message: 'lookup_employee requires email, mobile, or exact_name',
  }),
  z.object({
    action: z.literal('find_contact'),
    account_id: z.string().optional(),
    query: z.string().min(1),
    limit: z.number().int().min(1).max(50).optional(),
  }),
  z.object({
    action: z.literal('create_p2p_chat'),
    account_id: z.string().optional(),
    user_ids: z.array(z.string().min(1)).min(1),
  }),
  z.object({
    action: z.literal('send_message'),
    account_id: z.string().optional(),
    receive_id: z.string().min(1),
    receive_id_type: ReceiveIdTypeSchema.optional(),
    name: z.string().min(1).optional(),
    email: z.string().email().optional(),
    mobile: z.string().min(3).optional(),
    user_id: z.string().min(1).optional(),
    open_id: z.string().min(1).optional(),
    union_id: z.string().min(1).optional(),
    msg_type: z.enum(['text', 'post']).default('text'),
    content: z.string().min(1),
  }),
  z.object({
    action: z.literal('send_followup'),
    account_id: z.string().optional(),
    target: z.object({
      user_id: z.string().min(1).optional(),
      open_id: z.string().min(1).optional(),
      union_id: z.string().min(1).optional(),
      email: z.string().email().optional(),
      chat_id: z.string().min(1).optional(),
      name: z.string().min(1).optional(),
    }).refine((v) => Boolean(v.user_id || v.open_id || v.union_id || v.email || v.chat_id || v.name), {
      message: 'target requires at least one identifier',
    }),
    work_item: z.string().min(1),
    status_prompt: z.string().optional(),
    polite: z.boolean().optional(),
    signature: z.string().optional(),
    dry_run: z.boolean().optional(),
  }),
  z.object({
    action: z.literal('send_contact_message'),
    account_id: z.string().optional(),
    target: z.object({
      user_id: z.string().min(1).optional(),
      open_id: z.string().min(1).optional(),
      union_id: z.string().min(1).optional(),
      email: z.string().email().optional(),
      chat_id: z.string().min(1).optional(),
      name: z.string().min(1).optional(),
      mobile: z.string().min(3).optional(),
    }).refine((v) => Boolean(v.user_id || v.open_id || v.union_id || v.email || v.chat_id || v.name || v.mobile), {
      message: 'target requires at least one identifier',
    }),
    content: z.string().min(1),
    msg_type: z.enum(['text', 'post']).default('text'),
    dry_run: z.boolean().optional(),
  }),
]);

export const feishuMessageParameters = Type.Object({
  action: Type.String(),
}, { additionalProperties: true });

function safeStringify(value) {
  const seen = new WeakSet();
  return JSON.stringify(value, (key, current) => {
    if (typeof current === 'object' && current !== null) {
      if (seen.has(current)) return '[Circular]';
      seen.add(current);
    }
    return current;
  }, 2);
}

function buildTextContent(text) {
  return JSON.stringify({ text });
}

function buildFollowupText({ name, work_item, status_prompt, polite = true, signature }) {
  const greeting = name ? `Hi ${name},` : 'Hi,';
  const ask = status_prompt || `just following up on ${work_item}. Could you share the current status when convenient?`;
  const closing = polite ? 'Thanks.' : '';
  return [greeting, ask, closing, signature || ''].filter(Boolean).join('\n\n');
}

function normalizeSearchItems(payload) {
  const items = payload?.items || payload?.user_list || payload?.users || payload?.employees || payload?.employee_list || [];
  return Array.isArray(items) ? items : [];
}

function pickName(item) {
  return item?.name || item?.en_name || item?.display_name || item?.nickname || null;
}

function pickEmail(item) {
  return item?.email || item?.enterprise_email || item?.email_address || null;
}

function pickMobile(item) {
  return item?.mobile || item?.phone || item?.mobile_phone || null;
}

function exactMatchEmployees(items, { email, mobile, exact_name }) {
  return items.filter((item) => {
    if (email && String(pickEmail(item) || '').toLowerCase() !== String(email).toLowerCase()) return false;
    if (mobile) {
      const left = String(pickMobile(item) || '').replace(/[^+\d]/g, '');
      const right = String(mobile).replace(/[^+\d]/g, '');
      if (!left.includes(right)) return false;
    }
    if (exact_name && String(pickName(item) || '').trim().toLowerCase() !== String(exact_name).trim().toLowerCase()) return false;
    return true;
  });
}

function summarizeEmployee(item) {
  return {
    name: pickName(item),
    open_id: item?.open_id || null,
    user_id: item?.user_id || null,
    union_id: item?.union_id || null,
    email: pickEmail(item),
    mobile: pickMobile(item),
    department_ids: item?.department_ids || item?.department_id || null,
    title: item?.job_title || item?.title || null,
    raw: item,
  };
}

function broadMatchEmployees(items, { query, email, mobile, exact_name }) {
  const q = String(query || '').trim().toLowerCase();
  return items.filter((item) => {
    const name = String(pickName(item) || '').toLowerCase();
    const itemEmail = String(pickEmail(item) || '').toLowerCase();
    const itemMobile = String(pickMobile(item) || '').replace(/[^+\d]/g, '');

    if (email && itemEmail !== String(email).toLowerCase()) return false;
    if (mobile) {
      const right = String(mobile).replace(/[^+\d]/g, '');
      if (!itemMobile.includes(right)) return false;
    }
    if (exact_name && name !== String(exact_name).trim().toLowerCase()) return false;
    if (q && !(name.includes(q) || itemEmail.includes(q) || itemMobile.includes(q.replace(/[^+\d]/g, '')))) return false;
    return true;
  });
}

async function searchEmployees(client, criteria = {}, limit = 10) {
  const attempts = [];

  if (criteria.query || criteria.exact_name || criteria.email || criteria.mobile) {
    attempts.push(async () => {
      if (!client?.directory?.v1?.employee?.search) {
        throw new Error('client.directory.v1.employee.search is unavailable');
      }
      const query = criteria.query || criteria.exact_name || criteria.email || criteria.mobile;
      return await client.directory.v1.employee.search({
        data: {
          query,
          page_request: {
            page_size: Math.min(limit, 50),
          },
          required_fields: [
            'name',
            'en_name',
            'email',
            'mobile',
            'open_id',
            'user_id',
            'union_id',
            'department_ids',
            'job_title'
          ],
        },
      });
    });
  }

  attempts.push(async () => {
    if (!client?.contact?.user?.findByDepartment) {
      throw new Error('client.contact.user.findByDepartment is unavailable');
    }
    return await client.contact.user.findByDepartment({
      params: { department_id: '0', page_size: Math.min(Math.max(limit, 20), 100) },
    });
  });

  attempts.push(async () => {
    if (!client?.contact?.user?.list) {
      throw new Error('client.contact.user.list is unavailable');
    }
    return await client.contact.user.list({
      params: { page_size: Math.min(limit, 50) },
    });
  });

  let lastError;
  for (const attempt of attempts) {
    try {
      const res = await attempt();
      const data = res?.data || res;
      const items = normalizeSearchItems(data);
      const filtered = broadMatchEmployees(items, criteria);
      const hasAbnormals = Array.isArray(data?.abnormals) && data.abnormals.length > 0;

      if (filtered.length === 0 && (hasAbnormals || items.length === 0)) {
        lastError = new Error('Search source returned no usable matches, trying fallback');
        continue;
      }

      return {
        ...data,
        items: filtered,
      };
    } catch (error) {
      lastError = error;
    }
  }

  throw lastError || new Error('Employee search failed');
}

async function createP2PChat(client, userIds) {
  const attempts = [
    async () => await client.im.v1.chat.create({
      data: {
        user_id_list: userIds,
        chat_mode: 'p2p',
      },
    }),
    async () => await client.im.v1.chat.create({
      data: {
        chat_type: 'p2p',
        user_id_list: userIds,
      },
    }),
  ];

  let lastError;
  for (const attempt of attempts) {
    try {
      const res = await attempt();
      return res?.data || res;
    } catch (error) {
      lastError = error;
    }
  }

  throw lastError || new Error('P2P chat creation failed');
}

async function sendMessage(client, { receive_id, receive_id_type, msg_type, content }) {
  const res = await client.im.v1.message.create({
    params: { receive_id_type },
    data: {
      receive_id,
      msg_type,
      content: msg_type === 'text' ? buildTextContent(content) : content,
    },
  });
  return res?.data || res;
}

async function sendMessageWithFallback(client, target, message) {
  try {
    const primary = await sendMessage(client, {
      receive_id: target.receive_id,
      receive_id_type: target.receive_id_type,
      msg_type: message.msg_type,
      content: message.content,
    });
    return {
      sent_via: { receive_id: target.receive_id, receive_id_type: target.receive_id_type },
      result: primary,
    };
  } catch (error) {
    const text = String(error?.message || '');
    const details = JSON.stringify(error?.response?.data || error?.data || error?.body || {});
    const crossApp = /cross app/i.test(text) || /cross app/i.test(details) || /open_id/i.test(details) && /cross/i.test(details);
    if (crossApp && target.user_id && target.receive_id_type === 'open_id') {
      const fallback = await sendMessage(client, {
        receive_id: target.user_id,
        receive_id_type: 'user_id',
        msg_type: message.msg_type,
        content: message.content,
      });
      return {
        sent_via: { receive_id: target.user_id, receive_id_type: 'user_id', fallback_from: 'open_id' },
        result: fallback,
      };
    }
    throw error;
  }
}

function cacheResolvedTarget(target = {}) {
  const existing = getCachedContact({
    open_id: target.receive_id_type === 'open_id' ? target.receive_id : target.open_id,
    user_id: target.receive_id_type === 'user_id' ? target.receive_id : target.user_id,
    union_id: target.receive_id_type === 'union_id' ? target.receive_id : target.union_id,
    email: target.email,
    mobile: target.mobile,
    name: target.name,
  });

  const cacheable = {
    name: target.name || existing?.name || null,
    open_id: target.receive_id_type === 'open_id' ? target.receive_id : (target.open_id || existing?.open_id || null),
    user_id: target.receive_id_type === 'user_id' ? target.receive_id : (target.user_id || existing?.user_id || null),
    union_id: target.receive_id_type === 'union_id' ? target.receive_id : (target.union_id || existing?.union_id || null),
    email: target.email || existing?.email || null,
    mobile: target.mobile || existing?.mobile || null,
  };

  if (cacheable.name || cacheable.open_id || cacheable.user_id || cacheable.union_id || cacheable.email || cacheable.mobile) {
    return putCachedContact(cacheable);
  }
  return null;
}

function pickTargetIdentifier(target, defaultReceiveIdType = 'open_id') {
  if (target.chat_id) return { receive_id: target.chat_id, receive_id_type: 'chat_id', resolvedName: target.name };
  if (target.open_id) return { receive_id: target.open_id, receive_id_type: 'open_id', resolvedName: target.name, open_id: target.open_id };
  if (target.user_id) return { receive_id: target.user_id, receive_id_type: 'user_id', resolvedName: target.name, user_id: target.user_id };
  if (target.union_id) return { receive_id: target.union_id, receive_id_type: 'union_id', resolvedName: target.name, union_id: target.union_id };
  return { receive_id: null, receive_id_type: defaultReceiveIdType, resolvedName: target.name };
}

export function registerFeishuMessageTool(api) {
  const pluginCfg = resolvePluginConfig(api.pluginConfig || {});
  const debugLog = (...args) => {
    if (!pluginCfg.debug) return;
    try {
      console.log(...args);
    } catch {}
  };

  api.registerTool((ctx = {}) => ({
    name: 'feishu_message',
    description: 'Search employees, create Feishu/Lark chats, and send bot follow-up messages.',
    parameters: feishuMessageParameters,
    async execute(_id, params) {
      try {
        if (!pluginCfg.enabled) {
          throw messageError('PLUGIN_DISABLED', 'openclaw-feishu-message is disabled by config');
        }

        const parsed = MessageActionSchema.parse(params);
        const runtimeLike = {
          ...api,
          accountId: ctx?.agentAccountId || ctx?.accountId || api?.accountId,
          account_id: ctx?.agentAccountId || ctx?.account_id || api?.account_id,
          toolContext: ctx,
        };
        const accountCfg = resolveFeishuAccountConfig(runtimeLike, pluginCfg, parsed.account_id);
        const client = createFeishuClient(accountCfg);

        debugLog('[openclaw-feishu-message] execute', JSON.stringify({
          action: parsed.action,
          requestedAccountId: parsed.account_id,
          resolvedAccountId: accountCfg.accountId,
        }));

        let result;
        switch (parsed.action) {
          case 'search_employee': {
            const search = await searchEmployees(client, {
              query: parsed.query,
              email: parsed.email,
              mobile: parsed.mobile,
              exact_name: parsed.exact_name,
            }, parsed.limit || 10);
            const summarizedItems = normalizeSearchItems(search).map(summarizeEmployee);
            if (summarizedItems.length === 1) {
              const cachedItem = putCachedContact(summarizedItems[0]);
              summarizedItems[0] = cachedItem;
            }
            result = {
              ...search,
              count: summarizedItems.length,
              item: summarizedItems.length === 1 ? summarizedItems[0] : null,
              items: summarizedItems,
            };
            break;
          }
          case 'lookup_employee': {
            const cached = getCachedContact({
              email: parsed.email,
              mobile: parsed.mobile,
              name: parsed.exact_name,
            });
            if (cached) {
              result = { count: 1, item: cached, items: [cached], cached: true };
              break;
            }

            const search = await searchEmployees(client, {
              email: parsed.email,
              mobile: parsed.mobile,
              exact_name: parsed.exact_name,
            }, parsed.limit || 10);
            const items = normalizeSearchItems(search).map(summarizeEmployee);
            let singleItem = items.length === 1 ? items[0] : null;
            if (singleItem) {
              singleItem = putCachedContact(singleItem);
              items[0] = singleItem;
            }
            result = {
              count: items.length,
              item: singleItem,
              items,
            };
            break;
          }
          case 'find_contact': {
            const cached = getCachedContact({ name: parsed.query, email: parsed.query, mobile: parsed.query });
            if (cached) {
              result = { count: 1, item: cached, items: [cached], cached: true };
              break;
            }

            const search = await searchEmployees(client, {
              query: parsed.query,
              exact_name: parsed.query,
              email: parsed.query.includes('@') ? parsed.query : undefined,
              mobile: /\d/.test(parsed.query) ? parsed.query : undefined,
            }, parsed.limit || 10);
            const items = normalizeSearchItems(search).map(summarizeEmployee);
            let singleItem = items.length === 1 ? items[0] : null;
            if (singleItem) {
              singleItem = putCachedContact({
                ...singleItem,
                name: singleItem.name || parsed.query,
                email: singleItem.email || (parsed.query.includes('@') ? parsed.query : null),
                mobile: singleItem.mobile || (/\d/.test(parsed.query) ? parsed.query : null),
              });
              items[0] = singleItem;
            }
            result = {
              count: items.length,
              item: singleItem,
              items,
            };
            break;
          }
          case 'create_p2p_chat': {
            result = await createP2PChat(client, parsed.user_ids);
            break;
          }
          case 'send_message': {
            if (!pluginCfg.allowSend) {
              throw messageError('SEND_DISABLED', 'Sending is disabled by plugin config');
            }
            const sendTarget = {
              receive_id: parsed.receive_id,
              receive_id_type: parsed.receive_id_type || pluginCfg.defaultReceiveIdType,
              name: parsed.name,
              email: parsed.email,
              mobile: parsed.mobile,
              user_id: parsed.user_id || (parsed.receive_id_type === 'user_id' ? parsed.receive_id : undefined),
              open_id: parsed.open_id || (parsed.receive_id_type === 'open_id' ? parsed.receive_id : undefined),
              union_id: parsed.union_id || (parsed.receive_id_type === 'union_id' ? parsed.receive_id : undefined),
            };
            const cachedTarget = cacheResolvedTarget(sendTarget);
            result = await sendMessageWithFallback(client, {
              ...sendTarget,
              ...cachedTarget,
            }, {
              msg_type: parsed.msg_type,
              content: parsed.content,
            });
            break;
          }
          case 'send_followup': {
            const { receive_id, receive_id_type, resolvedName, open_id, user_id, union_id } = pickTargetIdentifier(parsed.target, pluginCfg.defaultReceiveIdType);
            let resolved = {
              receive_id,
              receive_id_type,
              name: parsed.target.name || resolvedName,
              open_id,
              user_id,
              union_id,
              email: parsed.target.email,
              mobile: parsed.target.mobile,
            };

            if (!resolved.receive_id && (parsed.target.name || parsed.target.email || parsed.target.mobile)) {
              const search = await searchEmployees(client, {
                query: parsed.target.name || parsed.target.email || parsed.target.mobile,
                exact_name: parsed.target.name,
                email: parsed.target.email,
                mobile: parsed.target.mobile,
              }, 10);
              const items = search?.items || search?.user_list || search?.users || [];
              if (!Array.isArray(items) || items.length === 0) {
                const reason = parsed.target.email
                  ? `email: ${parsed.target.email}`
                  : parsed.target.mobile
                    ? `mobile: ${parsed.target.mobile}`
                    : `name: ${parsed.target.name}`;
                throw messageError('USER_NOT_FOUND', `Could not resolve target by ${reason}`);
              }
              const user = items[0];
              const summarized = summarizeEmployee(user);
              putCachedContact(summarized);
              resolved = {
                receive_id: summarized.open_id || summarized.user_id,
                receive_id_type: summarized.open_id ? 'open_id' : 'user_id',
                name: parsed.target.name || summarized.name,
                open_id: summarized.open_id,
                user_id: summarized.user_id,
                union_id: summarized.union_id,
                email: summarized.email,
                mobile: summarized.mobile,
                matched_user: summarized,
              };
            }

            if (!resolved.receive_id) {
              throw messageError('TARGET_UNRESOLVED', 'Could not determine receive_id for follow-up target');
            }

            const text = buildFollowupText({
              name: resolved.name,
              work_item: parsed.work_item,
              status_prompt: parsed.status_prompt,
              polite: parsed.polite !== false,
              signature: parsed.signature,
            });

            result = {
              resolved_target: resolved,
              preview_text: text,
            };

            if (!parsed.dry_run) {
              if (!pluginCfg.allowSend) {
                throw messageError('SEND_DISABLED', 'Sending is disabled by plugin config');
              }
              cacheResolvedTarget({
                ...resolved,
                email: parsed.target.email,
                mobile: parsed.target.mobile,
              });
              result.message = await sendMessageWithFallback(client, {
                ...resolved,
                user_id: resolved.matched_user?.user_id || resolved.user_id,
              }, {
                msg_type: 'text',
                content: text,
              });
            } else {
              result.note = 'Dry run only. No message sent.';
            }
            break;
          }
          case 'send_contact_message': {
            const { receive_id, receive_id_type, resolvedName, open_id, user_id, union_id } = pickTargetIdentifier(parsed.target, pluginCfg.defaultReceiveIdType);
            let resolved = {
              receive_id,
              receive_id_type,
              name: parsed.target.name || resolvedName,
              open_id,
              user_id,
              union_id,
              email: parsed.target.email,
              mobile: parsed.target.mobile,
            };

            if (!resolved.receive_id && (parsed.target.name || parsed.target.email || parsed.target.mobile)) {
              const search = await searchEmployees(client, {
                query: parsed.target.name || parsed.target.email || parsed.target.mobile,
                exact_name: parsed.target.name,
                email: parsed.target.email,
                mobile: parsed.target.mobile,
              }, 10);
              const items = search?.items || search?.user_list || search?.users || [];
              if (!Array.isArray(items) || items.length === 0) {
                const reason = parsed.target.email
                  ? `email: ${parsed.target.email}`
                  : parsed.target.mobile
                    ? `mobile: ${parsed.target.mobile}`
                    : `name: ${parsed.target.name}`;
                throw messageError('USER_NOT_FOUND', `Could not resolve target by ${reason}`);
              }
              const user = items[0];
              const summarized = summarizeEmployee(user);
              putCachedContact(summarized);
              resolved = {
                receive_id: summarized.open_id || summarized.user_id,
                receive_id_type: summarized.open_id ? 'open_id' : 'user_id',
                name: parsed.target.name || summarized.name,
                open_id: summarized.open_id,
                user_id: summarized.user_id,
                union_id: summarized.union_id,
                email: summarized.email,
                mobile: summarized.mobile,
                matched_user: summarized,
              };
            }

            if (!resolved.receive_id) {
              throw messageError('TARGET_UNRESOLVED', 'Could not determine receive_id for message target');
            }

            result = {
              resolved_target: resolved,
              preview_text: parsed.content,
            };

            if (!parsed.dry_run) {
              if (!pluginCfg.allowSend) {
                throw messageError('SEND_DISABLED', 'Sending is disabled by plugin config');
              }
              cacheResolvedTarget(resolved);
              result.message = await sendMessageWithFallback(client, {
                ...resolved,
                user_id: resolved.matched_user?.user_id || resolved.user_id,
              }, {
                msg_type: parsed.msg_type,
                content: parsed.content,
              });
            } else {
              result.note = 'Dry run only. No message sent.';
            }
            break;
          }
          default:
            throw messageError('UNSUPPORTED_ACTION', `Unsupported action: ${parsed.action}`);
        }

        return {
          content: [{ type: 'text', text: safeStringify(result) }],
        };
      } catch (error) {
        const normalized = normalizeError(error);
        return {
          content: [{
            type: 'text',
            text: safeStringify({
              error: {
                code: normalized.code,
                message: normalized.message,
                details: normalized.details,
              },
            }),
          }],
          isError: true,
        };
      }
    },
  }), { name: 'feishu_message' });
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)