A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

feat: add multi-provider AI support for alt text (Gemini, OpenAI, Anthropic)

jack a2eb980b aa76ee80

+322 -53
+22
.env.example
··· 18 18 CHECK_INTERVAL_MINUTES=5 19 19 # BLUESKY_SERVICE_URL=https://bsky.social 20 20 PORT=3000 21 + 22 + # --- AI Configuration (Optional) --- 23 + # Supports: gemini, openai, anthropic, custom 24 + # Default provider is 'gemini' if GEMINI_API_KEY is present. 25 + 26 + # 1. Google Gemini (Default) 27 + # Get your API key from https://ai.google.dev/ 28 + GEMINI_API_KEY= 29 + 30 + # 2. OpenAI / OpenRouter / Custom 31 + # OPENAI_API_KEY=sk-... 32 + # AI_PROVIDER=openai # or 'custom' 33 + # AI_MODEL=gpt-4o 34 + # AI_BASE_URL=https://api.openai.com/v1 # or https://openrouter.ai/api/v1 35 + 36 + # 3. Anthropic 37 + # ANTHROPIC_API_KEY=sk-ant-... 38 + # AI_PROVIDER=anthropic 39 + # AI_MODEL=claude-3-5-sonnet-20241022 40 + 41 + # Generic Override (takes precedence) 42 + # AI_API_KEY=
+48 -14
public/index.html
··· 107 107 const [view, setView] = useState(localStorage.getItem('token') ? 'dashboard' : 'login'); 108 108 const [mappings, setMappings] = useState([]); 109 109 const [twitterConfig, setTwitterConfig] = useState({ authToken: '', ct0: '' }); 110 - const [geminiApiKey, setGeminiApiKey] = useState(''); 110 + const [aiConfig, setAiConfig] = useState({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 111 111 const [isAdmin, setIsAdmin] = useState(false); 112 112 const [status, setStatus] = useState({}); 113 113 const [loading, setLoading] = useState(false); ··· 149 149 if (meRes.data.isAdmin) { 150 150 const twitRes = await axios.get('/api/twitter-config', { headers }); 151 151 setTwitterConfig(twitRes.data); 152 - const gemRes = await axios.get('/api/gemini-config', { headers }); 153 - setGeminiApiKey(gemRes.data.apiKey); 152 + const aiRes = await axios.get('/api/ai-config', { headers }); 153 + setAiConfig(aiRes.data || { provider: 'gemini', apiKey: '' }); 154 154 } 155 155 fetchStatus(); 156 156 } catch (err) { ··· 353 353 } 354 354 }; 355 355 356 - const updateGemini = async (e) => { 356 + const updateAiConfig = async (e) => { 357 357 e.preventDefault(); 358 358 const formData = new FormData(e.target); 359 + const provider = formData.get('provider'); 360 + 359 361 try { 360 - await axios.post('/api/gemini-config', { 361 - apiKey: formData.get('apiKey') 362 + await axios.post('/api/ai-config', { 363 + provider: provider, 364 + apiKey: formData.get('apiKey'), 365 + model: formData.get('model'), 366 + baseUrl: formData.get('baseUrl') 362 367 }, { headers: { Authorization: `Bearer ${token}` } }); 363 - alert('Gemini API Key updated!'); 368 + alert('AI Config updated!'); 364 369 fetchData(); 365 370 } catch (err) { 366 - alert('Failed to update Gemini config'); 371 + alert('Failed to update AI config'); 367 372 } 368 373 }; 369 374 ··· 491 496 492 497 <hr className="my-4" /> 493 498 494 - <form onSubmit={updateGemini} className="mb-4"> 499 + <form onSubmit={updateAiConfig} className="mb-4"> 495 500 <h6 className="small text-uppercase text-muted fw-bold mb-3 d-flex justify-content-between"> 496 - Gemini AI (Alt Text) 497 - <span className="badge bg-light text-dark border">New</span> 501 + AI Configuration (Alt Text) 502 + <span className="badge bg-light text-dark border">Updated</span> 498 503 </h6> 499 504 <div className="mb-3"> 500 - <input name="apiKey" defaultValue={geminiApiKey} className="form-control form-control-sm" placeholder="AIza... (API Key)" /> 501 - <div className="form-text small">Uses <code>gemini-2.0-flash</code> for image descriptions.</div> 505 + <label className="form-label small fw-bold">Provider</label> 506 + <select 507 + name="provider" 508 + className="form-select form-select-sm" 509 + defaultValue={aiConfig.provider} 510 + onChange={(e) => setAiConfig({...aiConfig, provider: e.target.value})} 511 + > 512 + <option value="gemini">Google Gemini (Default)</option> 513 + <option value="openai">OpenAI / OpenRouter</option> 514 + <option value="anthropic">Anthropic (Claude)</option> 515 + <option value="custom">Custom (OpenAI Compatible)</option> 516 + </select> 502 517 </div> 503 - <button className="btn btn-light btn-sm w-100 border">Save Gemini Key</button> 518 + <div className="mb-3"> 519 + <label className="form-label small fw-bold">API Key</label> 520 + <input name="apiKey" defaultValue={aiConfig.apiKey} className="form-control form-control-sm" placeholder="sk-..." required /> 521 + </div> 522 + 523 + {(aiConfig.provider !== 'gemini') && ( 524 + <> 525 + <div className="mb-3"> 526 + <label className="form-label small fw-bold">Model ID (Optional)</label> 527 + <input name="model" defaultValue={aiConfig.model} className="form-control form-control-sm" placeholder={aiConfig.provider === 'openai' ? 'gpt-4o' : 'claude-3-5-sonnet'} /> 528 + </div> 529 + <div className="mb-3"> 530 + <label className="form-label small fw-bold">Base URL (Optional)</label> 531 + <input name="baseUrl" defaultValue={aiConfig.baseUrl} className="form-control form-control-sm" placeholder="https://api.openai.com/v1" /> 532 + <div className="form-text small">Required for OpenRouter or custom endpoints.</div> 533 + </div> 534 + </> 535 + )} 536 + 537 + <button className="btn btn-light btn-sm w-100 border">Save AI Config</button> 504 538 </form> 505 539 506 540 <hr className="my-4" />
+162
src/ai-manager.ts
··· 1 + import { GoogleGenerativeAI } from '@google/generative-ai'; 2 + import axios from 'axios'; 3 + import { getConfig } from './config-manager.js'; 4 + 5 + export async function generateAltText(buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 6 + const config = getConfig(); 7 + 8 + // 1. Determine Provider and Credentials 9 + // Priority: AI Config > Legacy Gemini Config > Environment Variables 10 + 11 + let provider = config.ai?.provider || 'gemini'; 12 + let apiKey = config.ai?.apiKey; 13 + let model = config.ai?.model; 14 + let baseUrl = config.ai?.baseUrl; 15 + 16 + // Fallbacks for Environment Variables 17 + if (!apiKey) { 18 + if (process.env.AI_API_KEY) apiKey = process.env.AI_API_KEY; 19 + else if (provider === 'gemini') apiKey = config.geminiApiKey || process.env.GEMINI_API_KEY; 20 + else if (provider === 'openai') apiKey = process.env.OPENAI_API_KEY; 21 + else if (provider === 'anthropic') apiKey = process.env.ANTHROPIC_API_KEY; 22 + } 23 + 24 + // Fallback for Gemini specific legacy env var if provider is implicitly gemini 25 + if (!apiKey && provider === 'gemini') { 26 + apiKey = process.env.GEMINI_API_KEY; 27 + } 28 + 29 + if (!apiKey) { 30 + // If no API key found, we can't do anything. 31 + // Silently fail or log warning? The original code returned undefined. 32 + return undefined; 33 + } 34 + 35 + // Default Models 36 + if (!model) { 37 + if (provider === 'gemini') model = 'models/gemini-2.5-flash'; 38 + else if (provider === 'openai') model = 'gpt-4o'; 39 + else if (provider === 'anthropic') model = 'claude-3-5-sonnet-20241022'; 40 + } 41 + 42 + try { 43 + switch (provider) { 44 + case 'gemini': 45 + return await callGemini(apiKey, model || 'models/gemini-2.5-flash', buffer, mimeType, contextText); 46 + case 'openai': 47 + case 'custom': 48 + return await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, contextText); 49 + case 'anthropic': 50 + return await callAnthropic(apiKey, model || 'claude-3-5-sonnet-20241022', baseUrl, buffer, mimeType, contextText); 51 + default: 52 + console.warn(`[AI] ⚠️ Unknown provider: ${provider}`); 53 + return undefined; 54 + } 55 + } catch (err) { 56 + console.warn(`[AI] ⚠️ Failed to generate alt text with ${provider}: ${(err as Error).message}`); 57 + return undefined; 58 + } 59 + } 60 + 61 + async function callGemini(apiKey: string, modelName: string, buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 62 + const genAI = new GoogleGenerativeAI(apiKey); 63 + const model = genAI.getGenerativeModel({ model: modelName }); 64 + 65 + const prompt = `Describe this image for alt text. Be concise but descriptive. 66 + Context from the tweet text: "${contextText}". 67 + Use the context to identify specific people, objects, or context mentioned, but describe what is visually present in the image.`; 68 + 69 + const result = await model.generateContent([ 70 + prompt, 71 + { 72 + inlineData: { 73 + data: buffer.toString('base64'), 74 + mimeType 75 + } 76 + } 77 + ]); 78 + const response = await result.response; 79 + return response.text(); 80 + } 81 + 82 + async function callOpenAICompatible(apiKey: string, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 83 + const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/chat/completions` : 'https://api.openai.com/v1/chat/completions'; 84 + 85 + const base64Image = `data:${mimeType};base64,${buffer.toString('base64')}`; 86 + 87 + const payload = { 88 + model: model, 89 + messages: [ 90 + { 91 + role: "user", 92 + content: [ 93 + { 94 + type: "text", 95 + text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".` 96 + }, 97 + { 98 + type: "image_url", 99 + image_url: { 100 + url: base64Image 101 + } 102 + } 103 + ] 104 + } 105 + ], 106 + max_tokens: 300 107 + }; 108 + 109 + const response = await axios.post(url, payload, { 110 + headers: { 111 + 'Authorization': `Bearer ${apiKey}`, 112 + 'Content-Type': 'application/json', 113 + // OpenRouter specific headers (optional but good practice) 114 + ...(url.includes('openrouter.ai') ? { 115 + 'HTTP-Referer': 'https://github.com/tweets-2-bsky', 116 + 'X-Title': 'Tweets to Bluesky' 117 + } : {}) 118 + } 119 + }); 120 + 121 + return response.data.choices[0]?.message?.content || undefined; 122 + } 123 + 124 + async function callAnthropic(apiKey: string, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 125 + const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/v1/messages` : 'https://api.anthropic.com/v1/messages'; 126 + 127 + const base64Data = buffer.toString('base64'); 128 + 129 + const payload = { 130 + model: model, 131 + max_tokens: 300, 132 + messages: [ 133 + { 134 + role: "user", 135 + content: [ 136 + { 137 + type: "image", 138 + source: { 139 + type: "base64", 140 + media_type: mimeType, 141 + data: base64Data 142 + } 143 + }, 144 + { 145 + type: "text", 146 + text: `Describe this image for alt text. Be concise but descriptive. Context from the tweet text: "${contextText}".` 147 + } 148 + ] 149 + } 150 + ] 151 + }; 152 + 153 + const response = await axios.post(url, payload, { 154 + headers: { 155 + 'x-api-key': apiKey, 156 + 'anthropic-version': '2023-06-01', 157 + 'Content-Type': 'application/json' 158 + } 159 + }); 160 + 161 + return response.data.content[0]?.text || undefined; 162 + }
+61 -1
src/cli.ts
··· 1 1 import { Command } from 'commander'; 2 2 import inquirer from 'inquirer'; 3 - import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig } from './config-manager.js'; 3 + import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig, type AIConfig } from './config-manager.js'; 4 4 5 5 const program = new Command(); 6 6 ··· 8 8 .name('tweets-2-bsky-cli') 9 9 .description('CLI to manage Twitter to Bluesky crossposting mappings') 10 10 .version('1.0.0'); 11 + 12 + program 13 + .command('setup-ai') 14 + .description('Configure AI settings for alt text generation') 15 + .action(async () => { 16 + const config = getConfig(); 17 + const currentAi = config.ai || { provider: 'gemini' }; 18 + 19 + // Check legacy gemini key if not in new config 20 + if (!config.ai && config.geminiApiKey) { 21 + currentAi.apiKey = config.geminiApiKey; 22 + } 23 + 24 + const answers = await inquirer.prompt([ 25 + { 26 + type: 'list', 27 + name: 'provider', 28 + message: 'Select AI Provider:', 29 + choices: [ 30 + { name: 'Google Gemini (Default)', value: 'gemini' }, 31 + { name: 'OpenAI / OpenRouter', value: 'openai' }, 32 + { name: 'Anthropic (Claude)', value: 'anthropic' }, 33 + { name: 'Custom (OpenAI Compatible)', value: 'custom' } 34 + ], 35 + default: currentAi.provider 36 + }, 37 + { 38 + type: 'input', 39 + name: 'apiKey', 40 + message: 'Enter API Key:', 41 + default: currentAi.apiKey, 42 + }, 43 + { 44 + type: 'input', 45 + name: 'model', 46 + message: 'Enter Model ID (optional, leave empty for default):', 47 + default: currentAi.model, 48 + }, 49 + { 50 + type: 'input', 51 + name: 'baseUrl', 52 + message: 'Enter Base URL (optional, e.g. for OpenRouter):', 53 + default: currentAi.baseUrl, 54 + when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider) 55 + } 56 + ]); 57 + 58 + config.ai = { 59 + provider: answers.provider, 60 + apiKey: answers.apiKey, 61 + model: answers.model || undefined, 62 + baseUrl: answers.baseUrl || undefined 63 + }; 64 + 65 + // Clear legacy key to avoid confusion 66 + delete config.geminiApiKey; 67 + 68 + saveConfig(config); 69 + console.log('AI configuration updated!'); 70 + }); 11 71 12 72 program 13 73 .command('setup-twitter')
+8
src/config-manager.ts
··· 17 17 passwordHash: string; 18 18 } 19 19 20 + export interface AIConfig { 21 + provider: 'gemini' | 'openai' | 'anthropic' | 'custom'; 22 + apiKey?: string; 23 + model?: string; 24 + baseUrl?: string; 25 + } 26 + 20 27 export interface AccountMapping { 21 28 id: string; 22 29 twitterUsernames: string[]; ··· 33 40 users: WebUser[]; 34 41 checkIntervalMinutes: number; 35 42 geminiApiKey?: string; 43 + ai?: AIConfig; 36 44 } 37 45 38 46 export function getConfig(): AppConfig {
+1 -33
src/index.ts
··· 14 14 import puppeteer from 'puppeteer-core'; 15 15 import * as cheerio from 'cheerio'; 16 16 import sharp from 'sharp'; 17 - import { GoogleGenerativeAI } from '@google/generative-ai'; 18 - 19 - // ... existing code ... 20 - 21 - async function generateAltText(buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 22 - const config = getConfig(); 23 - const apiKey = config.geminiApiKey || 'AIzaSyCByANEpkVGkYG6559CqBRlDVKh24dbxE8'; 24 - 25 - if (!apiKey) return undefined; 26 - 27 - try { 28 - const genAI = new GoogleGenerativeAI(apiKey); 29 - const modelRequested = genAI.getGenerativeModel({ model: 'models/gemini-2.5-flash' }); 30 - 31 - const prompt = `Describe this image for alt text. Be concise but descriptive. 32 - Context from the tweet text: "${contextText}". 33 - Use the context to identify specific people, objects, or context mentioned, but describe what is visually present in the image.`; 17 + import { generateAltText } from './ai-manager.js'; 34 18 35 - const result = await modelRequested.generateContent([ 36 - prompt, 37 - { 38 - inlineData: { 39 - data: buffer.toString('base64'), 40 - mimeType 41 - } 42 - } 43 - ]); 44 - const response = await result.response; 45 - return response.text(); 46 - } catch (err) { 47 - console.warn(`[GEMINI] ⚠️ Failed to generate alt text: ${(err as Error).message}`); 48 - return undefined; 49 - } 50 - } 51 19 import { getConfig } from './config-manager.js'; 52 20 53 21 // ESM __dirname equivalent
+20 -5
src/server.ts
··· 213 213 res.json({ success: true }); 214 214 }); 215 215 216 - app.get('/api/gemini-config', authenticateToken, requireAdmin, (_req, res) => { 216 + app.get('/api/ai-config', authenticateToken, requireAdmin, (_req, res) => { 217 217 const config = getConfig(); 218 - res.json({ apiKey: config.geminiApiKey || '' }); 218 + // Return legacy gemini key as part of new structure if needed 219 + const aiConfig = config.ai || { 220 + provider: 'gemini', 221 + apiKey: config.geminiApiKey || '' 222 + }; 223 + res.json(aiConfig); 219 224 }); 220 225 221 - app.post('/api/gemini-config', authenticateToken, requireAdmin, (req, res) => { 222 - const { apiKey } = req.body; 226 + app.post('/api/ai-config', authenticateToken, requireAdmin, (req, res) => { 227 + const { provider, apiKey, model, baseUrl } = req.body; 223 228 const config = getConfig(); 224 - config.geminiApiKey = apiKey; 229 + 230 + config.ai = { 231 + provider, 232 + apiKey, 233 + model: model || undefined, 234 + baseUrl: baseUrl || undefined 235 + }; 236 + 237 + // Clear legacy key to avoid confusion 238 + delete config.geminiApiKey; 239 + 225 240 saveConfig(config); 226 241 res.json({ success: true }); 227 242 });