#!/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 ', 'Target ID') .option('-x, --text ', 'Card body text') .option('-c, --content ', 'Content (alias for --text)') .option('-m, --markdown ', 'Markdown content (alias for --text)') .option('-f, --text-file ', 'Card body file') .option('--title ', 'Title') .option('--color ', 'Header color', 'blue') .option('--button-text ', 'Button text') .option('--button-url ', 'Button URL') .option('--image-path ', 'Image path') .option('--reply-to ', '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); } })(); }