Skill: autogame-17/feishu-card (version 1.4.11)

# Feishu Card Skill Send rich interactive cards to Feishu (Lark) users or groups. Supports Markdown (code blocks, tables), titles, color headers, and buttons. ## Prerequisites - Install `feishu-common` first. - This skill depends on `../feishu-common/index.js` for token and API auth. ## Usage ### 1. Simple Text (No special characters) ```bash 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: ```bash # (Use 'write' tool) write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`" ``` 2. Send using `--text-file`: ```bash 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. ```bash 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-file` instead. ## 4. Persona Messaging Send stylized messages from different AI personas. Adds themed headers, colors, and formatting automatically. ```bash 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. ### Usage - `-p, --persona <type>`: Select persona (d-guide, green-tea, mad-dog). - `-x, --text <string>`: Message content. - `-f, --text-file <path>`: Message content from file (supports markdown).

path: README.md
content: # 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)
```bash
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:
```bash
# (Use 'write' tool)
write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`"
```

2. Send using `--text-file`:
```bash
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.

```bash
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-file` instead.


path: TROUBLESHOOTING.md
content: # 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`.

```bash
cp -r temp/github-openclaw-workspace/skills/feishu-card skills/
cd skills/feishu-card
npm install
```

**Date:** 2026-02-07


path: handle_event.js
content: 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 };


path: index.js
content: module.exports = require('./send.js');


path: package-lock.json
content: {
  "name": "feishu-card",
  "version": "1.4.9",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "feishu-card",
      "version": "1.4.9",
      "license": "ISC",
      "dependencies": {
        "commander": "^13.1.0",
        "dotenv": "^16.6.1"
      }
    },
    "node_modules/commander": {
      "version": "13.1.0",
      "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/dotenv": {
      "version": "16.6.1",
      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://dotenvx.com"
      }
    }
  }
}


path: package.json
content: {
  "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"
  }
}

path: send.js
content: #!/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);
        }
    })();
}


path: send_persona.js
content: 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();


path: send_safe.js
content: #!/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) {}
}


path: test.js
content: 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!');


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