325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
#!/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);
|
|
}
|
|
})();
|
|
}
|