Files
openclaw-qqlobster-skills/skills/feishu-post/send.js
T

196 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
const { fetchWithAuth } = require('./utils/feishu-client.js');
const { parseMarkdownToFeishu } = require('./utils/markdown-parser.js');
const fs = require('fs');
const path = require('path');
// --- Upstream Logic Injection (Simplified) ---
// Ported from upstream src/send.ts (m1heng/clawdbot-feishu)
// Adapted for our lightweight architecture
async function sendPost(options) {
// Normalize common mis-usage:
// - Feishu message_id usually starts with "om_". If user passes it as --target,
// interpret it as --reply-to to avoid sending an invalid open_id.
if (options && typeof options.target === 'string' && options.target.startsWith('om_') && !options.replyTo) {
options.replyTo = options.target;
delete options.target;
}
if (options.content && !options.text) {
options.text = options.content;
}
if (options.markdown && !options.text) {
options.text = options.markdown;
}
let contentText = options.text || '';
if (options.textFile) {
try {
if (!fs.existsSync(options.textFile)) {
throw new Error(`File not found: ${options.textFile}`);
}
contentText = fs.readFileSync(options.textFile, 'utf8');
} catch (e) {
throw new Error(`Failed to read message file: ${e.message}`);
}
}
if (!contentText && !options.content) {
throw new Error('No content provided (use --text or --text-file)');
}
// Validate target/replyTo requirements
const hasReplyTo = !!options.replyTo;
const hasTarget = typeof options.target === 'string' && options.target.length > 0;
if (!hasReplyTo && !hasTarget) {
throw new Error('Missing target (use --target "ou_..." or "oc_..." or provide --reply-to "om_...")');
}
// Determine ID Type
let receiveIdType = 'open_id';
if (hasTarget) {
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
else if (options.target.startsWith('ou_')) receiveIdType = 'open_id';
else if (options.target.includes('@')) receiveIdType = 'email';
else if (options.target.startsWith('om_')) {
// Should have been normalized earlier; keep as explicit guard.
throw new Error('Invalid target "om_...": message_id cannot be used as receive_id. Use --reply-to "om_..."');
} else {
throw new Error('Invalid target id. Expected "ou_..." (open_id), "oc_..." (chat_id), or an email address.');
}
}
// Build Payload (RichText)
// Upstream Logic: Wrap markdown in Post Object
// Split text by newlines to create paragraphs
// Unescape literal \n if passed from command line
const rawLines = contentText.replace(/\\n/g, '\n').split(/\r?\n/);
const contentBody = rawLines.map(line => parseMarkdownToFeishu(line));
const postContent = {
zh_cn: {
title: options.title || '',
content: contentBody
}
};
const messageBody = {
receive_id: options.target,
msg_type: 'post',
content: JSON.stringify(postContent)
};
// Support Reply (New Feature from Upstream)
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; // Reply doesn't need receive_id
}
// console.log(`Sending Post to ${options.target}...`);
try {
const res = await fetchWithAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageBody)
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
throw new Error(`Invalid JSON response: ${text.slice(0, 200)}...`);
}
if (!res.ok) {
// Check if it's a recoverable error (like invalid emoji)
const isEmojiError = data.code === 230001 && data.msg && data.msg.includes("emoji_type is invalid");
if (!isEmojiError) {
throw new Error(`HTTP ${res.status} ${res.statusText}: ${JSON.stringify(data)}`);
}
// If it is an emoji error, flow through to the fallback logic below
}
if (data.code !== 0) {
// Hotfix: If invalid emoji detected (230001), fallback to plain text stripping emojis
if (data.code === 230001 && data.msg && data.msg.includes("emoji_type is invalid")) {
console.warn("Detected invalid emoji in Feishu post. Retrying as plain text fallback...");
// Fallback strategy: Send as plain text message, which renders emoji codes as text [Code]
// This ensures the message gets through even if the specific emoji is not supported.
const fallbackBody = {
receive_id: messageBody.receive_id,
msg_type: 'text',
content: JSON.stringify({ text: contentText })
};
// Reuse URL (it might be a reply URL)
if (options.replyTo) {
delete fallbackBody.receive_id;
}
const res2 = await fetchWithAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fallbackBody)
});
const text2 = await res2.text();
const data2 = JSON.parse(text2);
if (data2.code === 0) return data2.data;
// If fallback fails, throw original error (or new one)
throw new Error(`API Error ${data.code}: ${data.msg} (Fallback also failed: ${data2.code} ${data2.msg})`);
}
throw new Error(`API Error ${data.code}: ${data.msg}`);
}
// ... (rest of function)
} catch (e) {
console.error(`Send Failed: ${e.message}`);
throw e;
}
}
// CLI Wrapper
if (require.main === module) {
const { program } = require('commander');
program
.option('-t, --target <id>', 'Target ID')
.option('-x, --text <text>', 'Text content')
.option('-c, --content <text>', 'Content (alias for --text)')
.option('-m, --markdown <text>', 'Markdown content (alias for --text)')
.option('-f, --text-file <path>', 'File content')
.option('--title <text>', 'Title')
.option('--reply-to <id>', 'Message ID to reply to')
.parse(process.argv);
const opts = program.opts();
// Safety: if --text contains shell-mangled content (missing chars),
// try to detect and warn. Also support stdin piping as fallback.
if (opts.text && !opts.textFile) {
// Write to temp file to avoid shell escaping issues with special chars
const tmpPath = path.join('/tmp', `feishu_post_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
fs.writeFileSync(tmpPath, opts.text);
opts.textFile = tmpPath;
opts.text = undefined;
// Clean up after send
sendPost(opts).then(() => {
try { fs.unlinkSync(tmpPath); } catch (_) {}
}).catch((e) => {
try { fs.unlinkSync(tmpPath); } catch (_) {}
process.exit(1);
});
} else {
sendPost(opts).catch(() => process.exit(1));
}
}
module.exports = { sendPost };