
Feishu Sticker
- 2 installs
- 20 repo stars
- Updated April 11, 2026
- autogame-17/feishu-skills
feishu-sticker is a Claude skill that sends images as native Feishu stickers with auto-upload, image_key caching, and GIF-to-WebP conversion.
About
feishu-sticker sends images as native Feishu stickers to a user or group. It auto-uploads local images to the Feishu CDN, caches the image_key by file hash to avoid re-uploading, converts GIFs to WebP for efficiency, and compresses large images. Developers use it when an agent should send a sticker, chosen by file, keyword query, or emotion. It requires Feishu credentials and dependencies like ffmpeg-static.
- Sends images as native Feishu stickers with auto-upload to the CDN
- Caches image_key by file hash and converts GIFs to WebP via ffmpeg-static
- Smart search by --query or --emotion to pick a sticker
Feishu Sticker by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,842 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
feishu-sticker capabilities & compatibility
Free skill; requires Feishu credentials and npm dependencies including ffmpeg-static.
- Capabilities
- feishu message · feishu card
- Works with
- slack
- Use cases
- orchestration
What feishu-sticker says it does
Send images as native Feishu stickers. Features auto-upload, caching, and GIF-to-WebP conversion.
Automatically uploads the image to Feishu (caching the `image_key` via MD5), converts GIFs to WebP for efficiency, and supports smart search.
npx skills add https://github.com/autogame-17/feishu-skills --skill feishu-stickerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 20 |
| Last updated | April 11, 2026 |
| Repository | autogame-17/feishu-skills ↗ |
What it does
Send an image as a native Feishu sticker with auto-upload, caching, and GIF-to-WebP conversion.
Who is it for?
Sending an image or GIF as a native Feishu sticker to a user or group with automatic upload and caching.
Skip if: Sending stickers on Slack, Teams, or platforms other than Feishu.
When should I use this skill?
An agent needs to send a sticker or image reaction into a Feishu chat.
What you get
An image is uploaded, cached, converted if needed, and sent as a native Feishu sticker.
- A native Feishu sticker delivered to a user or chat
By the numbers
- 5 npm dependencies (axios, commander, ffmpeg-static, form-data, dotenv)
- Compresses images over 5MB
Files
Feishu Sticker Skill
Sends a sticker (image) to a Feishu user or group. Automatically uploads the image to Feishu (caching the image_key via MD5), converts GIFs to WebP for efficiency, and supports smart search.
Features
- Auto-Upload: Uploads local images to Feishu CDN on demand.
- Caching: Caches
image_keyby file hash to avoid re-uploading. - Optimization: Auto-converts GIFs to WebP (via
ffmpeg-static) and compresses large images (>5MB). - Smart Search: Find stickers by
--queryor--emotion.
Usage
# Send random sticker
node skills/feishu-sticker/send.js --target "ou_..."
# Send specific file
node skills/feishu-sticker/send.js --target "ou_..." --file "/path/to/image.jpg"
# Search and send
node skills/feishu-sticker/send.js --target "ou_..." --query "angry cat"
node skills/feishu-sticker/send.js --target "ou_..." --emotion "happy"Setup
1. Put your stickers in ~/.openclaw/media/stickers/ (or set STICKER_DIR). 2. Install dependencies: npm install (requires axios, commander, ffmpeg-static, form-data, dotenv).
{
"ownerId": "kn7apafdj4thknczrgxdzfd2v1808svf",
"slug": "feishu-sticker",
"version": "1.0.8",
"publishedAt": 1769967785861
}{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "feishu-sticker",
"installedVersion": "1.0.8",
"installedAt": 1770561508685
}
node_modules
.env
package-lock.json
const fs = require('fs');
const path = require('path');
const { program } = require('commander');
// Configuration
const STICKER_DIR = process.env.STICKER_DIR || path.resolve(__dirname, '../../media/stickers');
const INDEX_FILE = path.join(STICKER_DIR, 'index.json');
program
.option('-q, --query <text>', 'Search query (e.g., "angry cat", "happy")')
.option('--content <text>', 'Search query (alias for --query)')
.option('-e, --emotion <emotion>', 'Filter by emotion (e.g., "happy", "sad")')
.option('--random', 'Pick a random result if multiple match', false)
.option('--json', 'Output JSON result', false);
program.parse(process.argv);
const options = program.opts();
// Alias mapping
if (options.content && !options.query) {
options.query = options.content;
}
function loadIndex() {
if (!fs.existsSync(INDEX_FILE)) {
return {};
}
try {
return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
} catch (e) {
console.error("Failed to parse index file:", e.message);
return {};
}
}
function search(index, query, emotion) {
const results = [];
const searchTerms = query ? query.toLowerCase().split(/\s+/) : [];
for (const [filename, data] of Object.entries(index)) {
let score = 0;
// Filter by emotion if specified
if (emotion && data.emotion && data.emotion.toLowerCase() !== emotion.toLowerCase()) {
continue;
}
// Keyword matching
if (searchTerms.length > 0) {
const keywords = (data.keywords || []).map(k => k.toLowerCase());
const fileEmotion = (data.emotion || "").toLowerCase();
// Check keywords
for (const term of searchTerms) {
if (keywords.includes(term)) score += 3;
else if (keywords.some(k => k.includes(term))) score += 1;
// Check emotion field matches query
if (fileEmotion.includes(term)) score += 2;
}
} else {
// No query, base score (e.g. random pick from emotion filter)
score = 1;
}
if (score > 0) {
results.push({ filename, ...data, score });
}
}
return results.sort((a, b) => b.score - a.score);
}
function findSticker(query, emotion, random = false) {
const index = loadIndex();
const results = search(index, query, emotion);
if (results.length === 0) return null;
let selected;
if (random) {
// Pick random from top 3 (to add variety)
const topN = results.slice(0, 3);
selected = topN[Math.floor(Math.random() * topN.length)];
} else {
selected = results[0];
}
return selected;
}
if (require.main === module) {
program
.option('-q, --query <text>', 'Search query (e.g., "angry cat", "happy")')
.option('-e, --emotion <emotion>', 'Filter by emotion (e.g., "happy", "sad")')
.option('--random', 'Pick a random result if multiple match', false)
.option('--json', 'Output JSON result', false);
program.parse(process.argv);
const options = program.opts();
const result = findSticker(options.query, options.emotion, options.random);
if (!result) {
if (options.json) console.log(JSON.stringify({ found: false }));
else console.log("No matching stickers found.");
} else {
if (options.json) {
console.log(JSON.stringify({ found: true, sticker: result }));
} else {
console.log(`Found: ${result.filename}`);
console.log(`Path: ${result.path}`);
console.log(`Emotion: ${result.emotion}`);
console.log(`Keywords: ${result.keywords.join(', ')}`);
}
}
}
module.exports = { findSticker };
{
"50c23f80-1a58-4ea2-aad9-29fa9e77f8fc.jpg": "img_v3_02uf_b451138a-8b49-4dc5-9e66-b72883a8a74g",
"da5b5711-5787-4416-a90b-9ff63e8e6e67.jpg": "img_v3_02uf_de3d2734-319a-4e98-9850-dd34dd0c774g",
"2adf305a-0d96-4578-a8dc-aa118a29205e.webp": "img_v3_02uf_6ec2def5-1db3-4fbe-bb12-b805f7142eeg",
"5e03e1e4-5de5-4e58-b5bd-ece9006526d3.jpg": "img_v3_02uf_b7caa0a5-1222-4a5b-a8c2-6f380f1b4e2g",
"886201bf-675e-4491-b6ff-06bf73cbff82.jpg": "img_v3_02uf_a7c55871-753b-4f85-a38f-ce39657e5c7g",
"acd4a7c5-6987-4473-a9d2-11d9e502d522.jpg": "img_v3_02uf_f7ab02aa-85e0-4fde-b3ab-3952d30b49ag",
"d1877f26-743e-4830-87d4-591073c6474f.jpg": "img_v3_02uf_c65bdbfc-4124-4ff6-996c-989e578a881g",
"3a348d89-4ef0-40b1-a03e-3a06beb8bb1b.gif": "img_v3_02uf_b4fcc629-b47b-4b02-ae1a-36234920787g",
"a6f2fc8db54cbe5c25c91f289df4e023": "img_v3_02ug_9ac8437a-88b3-40dd-a22a-9219ecbe961g",
"salute.jpg": "img_v3_02ug_9ac8437a-88b3-40dd-a22a-9219ecbe961g"
}{
"name": "feishu-sticker",
"version": "1.0.8",
"description": "Send images as native Feishu stickers. Features auto-upload, caching, and GIF-to-WebP conversion.",
"main": "send.js",
"bin": {
"feishu-sticker": "./send.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"feishu",
"lark",
"sticker",
"image",
"meme"
],
"author": "OpenClaw Agent",
"license": "ISC",
"dependencies": {
"commander": "^13.1.0",
"dotenv": "^16.4.7",
"ffmpeg-static": "^5.2.0"
}
}
const os = require('os');
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { program } = require('commander');
// const FormData = require('form-data'); // Removed: using native fetch FormData logic
// Try to resolve ffmpeg-static from workspace root
let ffmpegPath;
try {
ffmpegPath = require('ffmpeg-static');
} catch (e) {
try {
ffmpegPath = require(path.resolve(__dirname, '../../node_modules/ffmpeg-static'));
} catch (e2) {
console.warn('Warning: ffmpeg-static not found. GIF conversion will fail.');
}
}
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env') });
// Credentials
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const TOKEN_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_token.json');
if (!APP_ID || !APP_SECRET) {
console.error('Error: FEISHU_APP_ID or FEISHU_APP_SECRET not set.');
process.exit(1);
}
async function getToken(forceRefresh = false) {
const now = Math.floor(Date.now() / 1000);
// 1. Try Memory Cache (File)
if (!forceRefresh && fs.existsSync(TOKEN_CACHE_FILE)) {
try {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
if (cached.token && cached.expire > now + 60) {
return cached.token;
}
} catch (e) {}
}
if (forceRefresh) {
try { if (fs.existsSync(TOKEN_CACHE_FILE)) fs.unlinkSync(TOKEN_CACHE_FILE); } catch(e) {}
}
try {
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
});
const data = await res.json();
if (data.code !== 0) throw new Error(`API Error: ${data.msg}`);
// 2. Update Memory Cache (File)
try {
const cacheData = {
token: data.tenant_access_token,
expire: now + data.expire
};
const cacheDir = path.dirname(TOKEN_CACHE_FILE);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cacheData, null, 2));
} catch (e) {
console.error("Failed to write token cache:", e.message);
}
return data.tenant_access_token;
} catch (e) {
console.error('Failed to get token:', e.message);
process.exit(1);
}
}
async function executeWithAuthRetry(operation) {
let token = await getToken();
try {
return await operation(token);
} catch (e) {
const msg = e.message || '';
const isAuthError = msg.includes('9999166') ||
(msg.toLowerCase().includes('token') && (msg.toLowerCase().includes('invalid') || msg.toLowerCase().includes('expire')));
if (isAuthError) {
console.warn(`[Feishu-Sticker] Auth Error (${msg}). Refreshing token...`);
token = await getToken(true);
return await operation(token);
}
throw e;
}
}
async function uploadImage(token, filePath) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const fileBuffer = fs.readFileSync(filePath);
// Use native Blob/FormData for robust fetch compatibility
// Node.js 18+ has native global FormData and Blob
const blob = new Blob([fileBuffer], { type: 'application/octet-stream' });
const formData = new FormData();
formData.append('image_type', 'message');
formData.append('image', blob, path.basename(filePath));
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }, // Content-Type is auto-set by fetch
body: formData
});
const data = await res.json();
if (data.code !== 0) {
if (data.code === 99991663 || data.code === 99991664 || data.code === 99991661) {
throw new Error(`Feishu Auth Error: ${data.code} ${data.msg}`);
}
throw new Error(JSON.stringify(data));
}
return data.data.image_key;
} catch (e) {
if (e.message.includes('Feishu Auth Error')) throw e;
const isLast = attempt === MAX_RETRIES;
console.warn(`[Attempt ${attempt}/${MAX_RETRIES}] Upload failed: ${e.message}`);
if (isLast) throw e;
// Wait before retry
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * attempt));
}
}
}
async function sendSticker(options) {
await executeWithAuthRetry(async (token) => {
return await sendStickerLogic(token, options);
});
}
async function sendStickerLogic(token, options) {
const stickerDir = process.env.STICKER_DIR
? path.resolve(process.env.STICKER_DIR)
: path.resolve(path.join(os.homedir(), '.openclaw/media/stickers'));
let selectedFile;
let isTempFile = false; // Track if we created a temp file to clean up
if (options.file) {
selectedFile = path.resolve(options.file);
} else {
if (!fs.existsSync(stickerDir)) {
console.warn(`Sticker directory missing: ${stickerDir}. Creating...`);
try { fs.mkdirSync(stickerDir, { recursive: true }); } catch (e) {
console.error('Failed to create sticker directory:', e.message);
process.exit(1);
}
}
try {
// Prioritize WebP, allow others
const files = fs.readdirSync(stickerDir).filter(f => /\.(webp|jpg|png|gif)$/i.test(f));
if (files.length === 0) {
console.error('No stickers found in', stickerDir);
process.exit(1);
}
const randomFile = files[Math.floor(Math.random() * files.length)];
selectedFile = path.join(stickerDir, randomFile);
} catch (e) {
console.error('Error reading sticker directory:', e.message);
process.exit(1);
}
}
// GIF -> WebP Conversion
if (selectedFile.toLowerCase().endsWith('.gif') && ffmpegPath) {
console.log('Detected GIF. Converting to WebP (Efficiency Protocol)...');
const webpPath = selectedFile.replace(/\.gif$/i, '.webp');
try {
const ffmpegArgs = [
'-i', selectedFile,
'-c:v', 'libwebp',
'-lossless', '0',
'-q:v', '75',
'-loop', '0',
'-an',
'-vsync', '0',
'-vf', 'scale=\'min(320,iw)\':-2',
'-y',
webpPath
];
spawnSync(ffmpegPath, ffmpegArgs, { stdio: 'pipe' });
if (fs.existsSync(webpPath)) {
// SAFETY CHECK: Only delete if it's in our internal sticker stash
const isInStickerDir = path.resolve(selectedFile).startsWith(stickerDir);
if (isInStickerDir) {
try {
fs.unlinkSync(selectedFile);
console.log('Original GIF deleted (Internal Storage Cleanup).');
} catch (delErr) {
console.warn('Could not delete original GIF:', delErr.message);
}
} else {
console.log('External file detected. Preserving original GIF.');
}
selectedFile = webpPath;
}
} catch (e) {
console.error('Conversion failed, proceeding with original:', e.message);
}
}
console.log(`Sending sticker: ${selectedFile}`);
// Optimization: Large Image Compression (>5MB) to avoid API errors
if (ffmpegPath) {
try {
const stats = fs.statSync(selectedFile);
const LIMIT_BYTES = 5 * 1024 * 1024; // 5MB
if (stats.size > LIMIT_BYTES) {
console.log(`[Compression] Image is ${(stats.size/1024/1024).toFixed(2)}MB (>5MB). Compressing...`);
// Use a temp file for compression output
const compressedPath = path.join(path.dirname(selectedFile), `temp_compressed_${Date.now()}.webp`);
const args = ['-i', selectedFile, '-c:v', 'libwebp', '-q:v', '60', '-y', compressedPath];
const res = spawnSync(ffmpegPath, args, { stdio: 'ignore' });
if (res.status === 0 && fs.existsSync(compressedPath)) {
const newStats = fs.statSync(compressedPath);
if (newStats.size < stats.size && newStats.size < LIMIT_BYTES) {
console.log(`[Compression] Success: ${(newStats.size/1024/1024).toFixed(2)}MB.`);
selectedFile = compressedPath;
isTempFile = true; // Mark for deletion
} else {
console.warn('[Compression] Failed to reduce size below limit.');
try { fs.unlinkSync(compressedPath); } catch(e) {}
}
}
}
} catch (e) {
console.warn('[Compression] Error:', e.message);
}
}
// Caching (Enhanced with MD5 Hash)
// Unified Cache: Use the shared memory file (same as feishu-card) to prevent duplicate uploads
const cachePath = path.resolve(__dirname, '../../memory/feishu_image_keys.json');
let cache = {};
if (fs.existsSync(cachePath)) {
try {
const rawCache = fs.readFileSync(cachePath, 'utf8');
if (rawCache.trim()) {
cache = JSON.parse(rawCache);
}
} catch (e) {
console.warn(`[Cache Warning] Corrupt cache file detected: ${e.message}`);
try {
const backupPath = cachePath + '.corrupt.' + Date.now();
fs.copyFileSync(cachePath, backupPath);
} catch (backupErr) {}
}
}
// Calculate file hash
const fileBuffer = fs.readFileSync(selectedFile);
const fileHash = crypto.createHash('md5').update(fileBuffer).digest('hex');
const fileName = path.basename(selectedFile);
let imageKey = cache[fileHash] || cache[fileName];
if (!imageKey) {
console.log(`Uploading image (Hash: ${fileHash.substring(0, 8)})...`);
try {
imageKey = await uploadImage(token, selectedFile);
if (imageKey) {
cache[fileHash] = imageKey;
cache[fileName] = imageKey;
try {
const tempPath = `${cachePath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(cache, null, 2));
fs.renameSync(tempPath, cachePath);
} catch (writeErr) {}
}
} finally {
// CLEANUP: Delete temp file if we created it during compression
if (isTempFile && fs.existsSync(selectedFile)) {
try {
fs.unlinkSync(selectedFile);
console.log(`[Cleanup] Deleted temporary compressed file: ${path.basename(selectedFile)}`);
} catch (cleanupErr) {
console.warn(`[Cleanup Warning] Failed to delete temp file: ${cleanupErr.message}`);
}
}
}
} else {
console.log(`Using cached image_key (Hash: ${fileHash.substring(0, 8)})`);
// If we used a cached key, but `selectedFile` was a temp compressed file, we still need to delete it!
// Because we didn't call uploadImage (which has the finally block if we put it there),
// we must clean up here too.
if (isTempFile && fs.existsSync(selectedFile)) {
try {
fs.unlinkSync(selectedFile);
console.log(`[Cleanup] Deleted temporary compressed file (Cache Hit): ${path.basename(selectedFile)}`);
} catch (cleanupErr) {}
}
}
// Determine receive_id_type
let receiveIdType = 'open_id';
if (options.target.startsWith('oc_')) {
receiveIdType = 'chat_id';
}
// Send
try {
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
receive_id: options.target,
msg_type: 'image',
content: JSON.stringify({ image_key: imageKey })
})
});
const data = await res.json();
if (data.code !== 0) {
if (data.code === 99991663 || data.code === 99991664 || data.code === 99991661) {
throw new Error(`Feishu Auth Error: ${data.code} ${data.msg}`);
}
throw new Error(JSON.stringify(data));
}
console.log('Success:', JSON.stringify(data.data, null, 2));
} catch (e) {
if (e.message.includes('Feishu Auth Error')) throw e;
console.error('Send failed:', e.message);
throw e;
}
}
program
.option('-t, --target <open_id>', 'Target User Open ID')
.option('-f, --file <path>', 'Specific image file path (optional)')
.option('-q, --query <text>', 'Search query (e.g., "angry cat", "happy")')
.option('-e, --emotion <emotion>', 'Filter by emotion (e.g., "happy", "sad")')
.parse(process.argv);
function getAutoTarget() {
// 1. Env Var (Highest Priority)
if (process.env.FEISHU_TARGET_ID) return process.env.FEISHU_TARGET_ID;
// 2. Shared Context (Active Session)
try {
const contextPath = path.resolve(__dirname, '../../memory/context.json');
if (fs.existsSync(contextPath)) {
const context = JSON.parse(fs.readFileSync(contextPath, 'utf8'));
if (context.last_target_id) return context.last_target_id;
// Fallback to interaction-logger fields
if (context.last_active_chat) return context.last_active_chat;
if (context.last_active_user) return context.last_active_user;
}
} catch (e) {}
// 3. Last Menu Interaction (Fallback)
try {
const menuPath = path.resolve(__dirname, '../../memory/menu_events.json');
if (fs.existsSync(menuPath)) {
const events = JSON.parse(fs.readFileSync(menuPath, 'utf8'));
if (events.length > 0) return events[events.length - 1].user_id;
}
} catch (e) {}
// 4. Default to Master
return process.env.OPENCLAW_MASTER_ID || '';
}
async function findSticker(options) {
if (!options.query && !options.emotion) return null;
try {
const { findSticker: findStickerFn } = require('./find.js');
// Use true for 'random' parameter to keep behavior consistent with CLI default
const result = findStickerFn(options.query, options.emotion, true);
if (result && result.path) {
console.log(`Smart match: ${result.emotion} [${result.keywords}]`);
return result.path;
}
} catch (e) {
console.warn("Smart search failed, falling back to random:", e.message);
}
return null;
}
(async () => {
const opts = program.opts();
// Auto-detect target if missing
if (!opts.target) {
opts.target = getAutoTarget();
console.log(`Auto-detected target: ${opts.target}`);
}
// If query/emotion is provided, try to find a matching sticker
if (opts.query || opts.emotion) {
const foundPath = await findSticker(opts);
if (foundPath) {
opts.file = foundPath;
} else {
console.log("No matching sticker found, falling back to random.");
}
}
await sendSticker(opts);
})();
Related skills
FAQ
Does it re-upload the same image every time?
No, it caches the image_key by file hash so a given image is only uploaded once.
How are GIFs handled?
GIFs are auto-converted to WebP via ffmpeg-static for efficiency, and images over 5MB are compressed.