
Feishu Card
- 1 installs
- 20 repo stars
- Updated April 11, 2026
- autogame-17/feishu-skills
feishu-card is a Claude skill that sends rich interactive cards to Feishu (Lark) users or groups with Markdown, color headers, action buttons, embedded images, and AI persona styling.
About
feishu-card sends rich interactive cards to Feishu (Lark) users or groups from the command line. It supports Markdown content, titled color headers, action buttons, embedded images, and AI persona styling. Developers use it when an agent needs to post formatted messages or reports into Feishu. It depends on feishu-common for token and API auth.
- Sends interactive Feishu cards with Markdown, color headers, buttons and images
- AI persona styling with preset header colors (d-guide, green-tea, mad-dog)
- Ships send.js, send_safe.js, and send_persona.js CLI scripts
Feishu Card by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,982 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
feishu-card capabilities & compatibility
Free skill; requires a Feishu app (FEISHU_APP_ID/FEISHU_APP_SECRET via feishu-common).
- Capabilities
- feishu message · feishu sticker
- Works with
- slack
- Use cases
- orchestration
What feishu-card says it does
Send rich interactive cards to Feishu (Lark) users or groups with Markdown support, colored headers, action buttons, embedded images, and AI persona styling.
This skill depends on `../feishu-common/index.js` for token and API auth.
npx skills add https://github.com/autogame-17/feishu-skills --skill feishu-cardAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 20 |
| Last updated | April 11, 2026 |
| Repository | autogame-17/feishu-skills ↗ |
What it does
Send formatted interactive cards with Markdown, buttons and images from an agent into a Feishu chat.
Who is it for?
Posting formatted Markdown reports, alerts, or cards from an agent into a Feishu chat.
Skip if: Sending messages to Slack, Teams, or platforms other than Feishu (Lark).
When should I use this skill?
An agent needs to deliver a formatted card, report, or notification to a Feishu user or group.
What you get
A styled Feishu card is delivered with intact Markdown, headers, buttons, and images.
- An interactive Feishu card posted to a user or chat
By the numbers
- 4 persona styles (d-guide, green-tea, mad-dog, default)
- 6 header colors
Files
feishu-card
Send rich interactive cards to Feishu users or groups. Supports Markdown (code blocks, tables), titled color headers, action buttons, embedded images, and AI persona styling.
Prerequisites
feishu-commoninstalled with valid credentials.- This skill depends on
../feishu-common/index.jsfor token and API auth.
Usage
Simple Text
node skills/feishu-card/send.js --target "ou_..." --text "Hello World"Markdown / Complex Content (Recommended)
To prevent shell escaping issues (e.g., swallowed backticks), ALWAYS write content to a file first.
1. Write content to a temp file:
write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`"2. Send using --text-file:
node skills/feishu-card/send.js --target "ou_..." --text-file "temp/msg.md" --title "Report" --color greenSafe Send (Auto Temp File)
Handles file creation and cleanup automatically -- use for inline markdown without manual temp files:
node skills/feishu-card/send_safe.js --target "ou_..." --text "Content with \`backticks\` and *markdown*" --title "Safe Message"Persona Messaging
Send themed messages from AI personas with automatic header styling:
node skills/feishu-card/send_persona.js --target "ou_..." --persona "d-guide" --text "Critical error detected."Supported Personas:
- d-guide: Red warning header, bold/code prefix. Snarky suffix.
- green-tea: Carmine header, soft/cutesy style.
- mad-dog: Grey header, raw runtime error style.
- default: Standard blue header.
Options
| Flag | Description |
|---|---|
-t, --target <id> | User Open ID (ou_...) or Chat ID (oc_...) |
-x, --text <string> | Simple text content |
-f, --text-file <path> | Markdown file path (use for code/logs) |
--title <string> | Card header title |
--color <string> | Header color: blue, red, orange, green, purple, grey (default: blue) |
--button-text <string> | Action button label |
--button-url <url> | Action button URL |
--image-path <path> | Local image to upload and embed |
-p, --persona <type> | Persona style -- d-guide, green-tea, mad-dog (send_persona.js only) |
Troubleshooting
- Missing Text: Did you use backticks in
--text? The shell likely ate them. Use--text-fileorsend_safe.jsinstead.
node_modules
.env
package-lock.json
const fs = require('fs');
// Mock event handler for Feishu Menu Events
// In a real scenario, this would be invoked by the Gateway webhook handler.
async function handle(eventPayload) {
console.log("Received Feishu Event:", JSON.stringify(eventPayload));
if (eventPayload.header.event_type === 'application.bot.menu_v6') {
const userOpenId = eventPayload.sender.sender_id.open_id;
const menuKey = eventPayload.event.event_key;
console.log(`User ${userOpenId} clicked menu: ${menuKey}`);
// Response logic
// We can call send.js here
const { execSync } = require('child_process');
try {
const replyText = `收到!你点击了菜单按钮:\`${menuKey}\` 喵!😺`;
execSync(`node ${__dirname}/send.js --target "${userOpenId}" --text "${replyText}" --color "green"`);
} catch (e) {
console.error("Failed to send reply:", e);
}
}
}
// CLI adapter
if (require.main === module) {
// Read from stdin or args
const payload = process.argv[2] ? JSON.parse(process.argv[2]) : {};
handle(payload);
}
module.exports = { handle };
module.exports = require('./send.js');
{
"name": "feishu-card",
"version": "1.4.10",
"description": "Send rich interactive cards to Feishu. v1.4.1 adds atomic file writes for stability.",
"main": "send.js",
"scripts": {
"test": "node test.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"commander": "^13.1.0",
"dotenv": "^16.6.1"
}
}Feishu Card Skill
Send rich interactive cards to Feishu (Lark) users or groups. Supports Markdown (code blocks, tables), titles, color headers, and buttons.
Usage
1. Simple Text (No special characters)
node skills/feishu-card/send.js --target "ou_..." --text "Hello World"2. Complex/Markdown Text (RECOMMENDED)
⚠️ CRITICAL: To prevent shell escaping issues (e.g., swallowed backticks), ALWAYS write content to a file first.
1. Write content to a temp file:
# (Use 'write' tool)
write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`"2. Send using --text-file:
node skills/feishu-card/send.js --target "ou_..." --text-file "temp/msg.md"3. Safe Send (Automated Temp File)
Use this wrapper to safely send raw text without manually creating a file. It handles file creation and cleanup automatically.
node skills/feishu-card/send_safe.js --target "ou_..." --text "Raw content with \`backticks\` and *markdown*" --title "Safe Message"Options
-t, --target <id>: User Open ID (ou_...) or Group Chat ID (oc_...).-x, --text <string>: Simple text content.-f, --text-file <path>: Path to text file (Markdown supported). Use this for code/logs.--title <string>: Card header title.--color <string>: Header color (blue/red/orange/green/purple/grey). Default: blue.--button-text <string>: Text for a bottom action button.--button-url <url>: URL for the button.--image-path <path>: Path to a local image to upload and embed.
Troubleshooting
- Missing Text: Did you use backticks in
--text? The shell likely ate them. Use--text-fileinstead.
const fs = require('fs');
const { program } = require('commander');
const path = require('path');
const { sendCard } = require('./send');
// We reuse the robust sendCard logic but wrap it with persona styling
const PERSONA_STYLES = {
'd-guide': {
color: 'red',
title: '🚨 SYSTEM WARNING / D-GUIDE',
prefix: '**[CRITICAL]** ',
suffix: '\n\n*(Automated System Insult Protocol v9.0)*'
},
'green-tea': {
color: 'carmine',
title: '🌸 碎碎念 🌸',
prefix: '> ',
suffix: '\n\n(嘤嘤嘤... 🥺)'
},
'mad-dog': {
color: 'grey',
title: '💀 RUNTIME ERROR',
prefix: '```bash\nError: ',
suffix: '\n```\n_Stack trace lost in apathy._'
},
'default': {
color: 'blue',
title: '🤖 Agent Notification',
prefix: '',
suffix: ''
}
};
program
.requiredOption('-t, --target <id>', 'Target ID (open_id or chat_id)')
.requiredOption('-p, --persona <type>', 'Persona type (d-guide, green-tea, mad-dog)')
.option('-x, --text <text>', 'Message content')
.option('-c, --content <text>', 'Content (alias for --text)')
.option('-f, --text-file <path>', 'Message content from file')
.parse(process.argv);
const options = program.opts();
// Alias mapping
if (options.content && !options.text) {
options.text = options.content;
}
async function main() {
if (!options.text && !options.textFile) {
console.error('Error: Must provide --text or --text-file');
process.exit(1);
}
const style = PERSONA_STYLES[options.persona] || PERSONA_STYLES['default'];
// Read content if file provided
let rawContent = options.text || '';
if (options.textFile) {
try {
rawContent = fs.readFileSync(options.textFile, 'utf8');
} catch (e) {
console.error(`Error reading file: ${e.message}`);
process.exit(1);
}
}
// Construct styled text
let finalContent = rawContent;
if (style.prefix) finalContent = style.prefix + finalContent;
if (style.suffix) finalContent = finalContent + style.suffix;
console.log(`[Persona] Applying style '${options.persona}' to message...`);
// Delegate to existing send.js logic
const sendOptions = {
target: options.target,
text: finalContent,
title: style.title,
color: style.color,
// We pass the resolved text directly, so we don't pass textFile to sendCard
// (sendCard prefers textFile if present, but we already read it to wrap it)
};
try {
await sendCard(sendOptions);
} catch (e) {
console.error(`[Persona] Failed to send: ${e.message}`);
process.exit(1);
}
}
main();
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { program } = require('commander');
// Safe Sender for Feishu Cards
// Automatically handles temp file creation to prevent shell escaping issues.
program
.requiredOption('-t, --target <id>', 'Target User/Chat ID')
.requiredOption('-x, --text <content>', 'Markdown content (will be saved to temp file)')
.option('--title <text>', 'Card Title')
.option('--color <color>', 'Header Color', 'blue');
program.parse(process.argv);
const options = program.opts();
const tempDir = path.resolve(__dirname, '../../temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const tempFile = path.join(tempDir, `msg_${Date.now()}_${Math.random().toString(36).substring(7)}.md`);
try {
// 1. Write content to temp file safely (Node.js writeFileSync avoids shell parsing of content)
fs.writeFileSync(tempFile, options.text, 'utf8');
console.log(`[SafeSend] Written content to ${tempFile}`);
// 2. Construct command for the real sender
// Note: We use the absolute path to send.js
const senderScript = path.resolve(__dirname, 'send.js');
// Build arguments array for spawn/exec
// We construct the command string carefully.
// Since we are invoking via execSync, we still need to quote arguments,
// BUT the dangerous content is now inside a file, so we only quote the filename.
let cmd = `node "${senderScript}" --target "${options.target}" --text-file "${tempFile}" --color "${options.color}"`;
if (options.title) cmd += ` --title "${options.title}"`;
console.log(`[SafeSend] Executing: ${cmd}`);
execSync(cmd, { stdio: 'inherit' });
} catch (e) {
console.error(`[SafeSend] Error: ${e.message}`);
process.exit(1);
} finally {
// 3. Cleanup
try {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
console.log(`[SafeSend] Cleaned up ${tempFile}`);
} catch (e) {}
}
#!/usr/bin/env node
const fs = require('fs');
const { program } = require('commander');
const path = require('path');
const crypto = require('crypto');
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env'), quiet: true });
// Optimization: Use shared client with Auth Refresh & Retry
const { fetchWithAuth } = require('../feishu-common/index.js');
const IMAGE_KEY_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_image_keys.json');
// --- Upstream Logic Injection (Simplified) ---
// Re-implementing image upload with robust client
async function uploadImage(filePath) {
let fileBuffer;
let fileHash;
try {
fileBuffer = fs.readFileSync(filePath);
fileHash = crypto.createHash('md5').update(fileBuffer).digest('hex');
} catch (e) {
throw new Error(`Error reading image file: ${e.message}`);
}
let cache = {};
if (fs.existsSync(IMAGE_KEY_CACHE_FILE)) {
try { cache = JSON.parse(fs.readFileSync(IMAGE_KEY_CACHE_FILE, 'utf8')); } catch (e) {}
}
if (cache[fileHash]) {
// console.log(`Using cached image key (Hash: ${fileHash.substring(0,8)})`);
return cache[fileHash];
}
console.log(`Uploading image (Hash: ${fileHash.substring(0,8)})...`);
const formData = new FormData();
formData.append('image_type', 'message');
const blob = new Blob([fileBuffer]);
formData.append('image', blob, path.basename(filePath));
try {
const res = await fetchWithAuth('https://open.feishu.cn/open-apis/im/v1/images', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.code !== 0) throw new Error(JSON.stringify(data));
const imageKey = data.data.image_key;
cache[fileHash] = imageKey;
try {
const cacheDir = path.dirname(IMAGE_KEY_CACHE_FILE);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(IMAGE_KEY_CACHE_FILE, JSON.stringify(cache, null, 2));
} catch(e) {}
return imageKey;
} catch (e) {
throw new Error(`Image upload failed: ${e.message}`);
}
}
function buildCardContent(elements, title, color) {
const card = {
config: { wide_screen_mode: true },
elements: elements
};
if (title) {
card.header = {
title: { tag: 'plain_text', content: title },
template: color || 'blue'
};
}
return card;
}
// Security Scan (Ported from recent updates)
function scanForSecrets(content) {
if (!content) return;
const secretPatterns = [
/sk-ant-api03-[a-zA-Z0-9\-_]{20,}/,
/ghp_[a-zA-Z0-9]{10,}/,
/xox[baprs]-[a-zA-Z0-9]{10,}/,
/-----BEGIN [A-Z]+ PRIVATE KEY-----/
];
for (const p of secretPatterns) {
if (p.test(content)) {
console.error('\x1b[31m%s\x1b[0m', '⛔ SECURITY ALERT: Potential secret detected in message body.');
throw new Error('Aborted send to prevent secret leakage.');
}
}
}
async function sendCard(options) {
try {
const elements = [];
if (options.imagePath) {
try {
const imageKey = await uploadImage(options.imagePath);
elements.push({
tag: 'img',
img_key: imageKey,
alt: { tag: 'plain_text', content: options.imageAlt || 'Image' },
mode: 'fit_horizontal'
});
} catch (imgError) {
console.warn(`[Feishu-Card] Image upload failed: ${imgError.message}. Sending text only.`);
}
}
let contentText = '';
if (options.textFile) {
try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch (e) {
throw new Error(`Failed to read file: ${options.textFile}`);
}
} else if (options.text) {
contentText = options.text;
}
scanForSecrets(contentText);
if (contentText) {
// Revert to standard 'markdown' block for best compatibility with code blocks
// [Bug Fix] Handle escaped newlines from command line args
const processedText = contentText.replace(/\\n/g, '\n');
const markdownElement = {
tag: 'markdown',
content: processedText
};
// if (options.textAlign) markdownElement.text_align = options.textAlign;
elements.push(markdownElement);
}
if (options.buttonText && options.buttonUrl) {
elements.push({
tag: 'action',
actions: [{
tag: 'button',
text: { tag: 'plain_text', content: options.buttonText },
type: 'primary',
multi_url: { url: options.buttonUrl, pc_url: '', android_url: '', ios_url: '' }
}]
});
}
if (options.note) {
elements.push({
tag: 'note',
elements: [
{ tag: 'plain_text', content: String(options.note) }
]
});
}
const cardObj = buildCardContent(elements, options.title, options.color);
let receiveIdType = 'open_id';
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
else if (options.target.startsWith('ou_')) receiveIdType = 'open_id';
else if (options.target.includes('@')) receiveIdType = 'email';
const messageBody = {
receive_id: options.target,
msg_type: 'interactive',
content: JSON.stringify(cardObj)
};
// Support Reply Logic
let url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`;
if (options.replyTo) {
url = `https://open.feishu.cn/open-apis/im/v1/messages/${options.replyTo}/reply`;
delete messageBody.receive_id;
}
console.log(`Sending card to ${options.target} (Elements: ${elements.length})...`);
if (options.dryRun) {
console.log('DRY RUN MODE. Payload:', JSON.stringify(messageBody, null, 2));
return;
}
const res = await fetchWithAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageBody)
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`API Error ${data.code}: ${data.msg}`);
}
console.log('Success:', JSON.stringify(data.data, null, 2));
return data.data;
} catch (e) {
console.error('Error during Card Send:', e.message);
console.log('[Feishu-Card] Attempting fallback to plain text...');
// Fallback Logic
let contentText = options.text || '';
if (options.textFile) try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch(e){}
let receiveIdType = 'open_id';
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
try {
await sendPlainTextFallback(receiveIdType, options.target, contentText, options.title);
} catch (fallbackError) {
console.error('Fallback failed dramatically:', fallbackError.message);
process.exit(1);
}
}
}
async function sendPlainTextFallback(receiveIdType, receiveId, text, title) {
if (!text) {
console.error('Fallback failed: No text content available.');
process.exit(1);
}
let finalContent = text;
if (title) finalContent = `【${title}】\n\n${text}`;
const messageBody = {
receive_id: receiveId,
msg_type: 'text',
content: JSON.stringify({ text: finalContent })
};
console.log(`Sending Fallback Text to ${receiveId}...`);
try {
const res = await fetchWithAuth(
`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageBody)
}
);
const data = await res.json();
if (data.code !== 0) throw new Error(JSON.stringify(data));
console.log('Fallback Success:', JSON.stringify(data.data, null, 2));
} catch (e) {
console.error('Fallback Network Error:', e.message);
process.exit(1);
}
}
async function resolveContent(options) {
let contentText = '';
if (options.textFile) {
try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch (e) {
throw new Error(`Failed to read file: ${options.textFile}`);
}
} else if (options.text) {
const isPotentialPath = options.text.length < 255 && !options.text.includes('\n') && !/[<>:"|?*]/.test(options.text);
if (isPotentialPath && fs.existsSync(options.text)) {
console.log(`[Smart Input] Treating --text argument as file path: ${options.text}`);
try { contentText = fs.readFileSync(options.text, 'utf8'); } catch (e) { contentText = options.text; }
} else {
contentText = options.text;
// Removed strict length check to allow longer prompt injection via args if needed, relying on secret scan
}
} else {
try {
const { stdin } = process;
if (!stdin.isTTY) {
stdin.setEncoding('utf8');
for await (const chunk of stdin) contentText += chunk;
}
} catch (e) {}
}
return contentText;
}
module.exports = { sendCard };
if (require.main === module) {
program
.requiredOption('-t, --target <id>', 'Target ID')
.option('-x, --text <markdown>', 'Card body text')
.option('-c, --content <text>', 'Content (alias for --text)')
.option('-m, --markdown <text>', 'Markdown content (alias for --text)')
.option('-f, --text-file <path>', 'Card body file')
.option('--title <text>', 'Title')
.option('--color <color>', 'Header color', 'blue')
.option('--button-text <text>', 'Button text')
.option('--button-url <url>', 'Button URL')
.option('--image-path <path>', 'Image path')
.option('--reply-to <id>', 'Reply to message ID')
.option('--dry-run', 'Dry run')
.parse(process.argv);
const options = program.opts();
// Alias mapping
if (options.content && !options.text) options.text = options.content;
if (options.markdown && !options.text) options.text = options.markdown;
(async () => {
try {
const textContent = await resolveContent(options);
if (textContent) {
options.text = textContent;
options.textFile = null;
}
if (!options.text && !options.imagePath) {
console.error('Error: No content provided.');
process.exit(1);
}
sendCard(options);
} catch (e) {
console.error(e.message);
process.exit(1);
}
})();
}
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
console.log('🧪 Testing feishu-card skill...');
const sendPath = path.join(__dirname, 'send.js');
// 1. Check existence
if (!fs.existsSync(sendPath)) {
console.error('❌ send.js not found!');
process.exit(1);
}
console.log('✅ send.js exists');
// 2. Check syntax (dry-run import)
try {
require.resolve('./send.js');
console.log('✅ send.js is valid Node.js module');
} catch (e) {
console.error('❌ send.js syntax check failed:', e);
process.exit(1);
}
// 3. Check help output
try {
const output = execSync(`node ${sendPath} --help`, { encoding: 'utf8' });
if (output.includes('Usage: send')) {
console.log('✅ CLI help command works');
} else {
throw new Error('Help output missing usage');
}
} catch (e) {
console.error('❌ CLI execution failed:', e);
process.exit(1);
}
console.log('🎉 feishu-card basic sanity tests passed!');
Troubleshooting
Issue: MODULE_NOT_FOUND (send.js not found or dependencies missing)
Symptom: Other skills (like video-gen) trying to call feishu-card/send.js fail with:
Error: Cannot find module '.../skills/feishu-card/send.js'Or running the script fails with missing dotenv.
Cause: 1. The skill directory was empty or missing files after a system restore/cleanup. 2. node_modules were missing.
Solution: 1. Restore the skill files from backup (e.g., temp/github-openclaw-workspace/skills/feishu-card). 2. Run npm install inside skills/feishu-card.
cp -r temp/github-openclaw-workspace/skills/feishu-card skills/
cd skills/feishu-card
npm installDate: 2026-02-07
Related skills
FAQ
How do I avoid the shell eating backticks in card content?
Write the content to a temp file and send it with --text-file, or use send_safe.js which handles the temp file automatically.
What header colors are supported?
blue, red, orange, green, purple, and grey, with blue as the default.