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.
at master 245 lines 6.8 kB view raw
1import { GoogleGenerativeAI } from '@google/generative-ai'; 2import axios from 'axios'; 3import { getConfig } from './config-manager.js'; 4 5export async function generateAltText( 6 buffer: Buffer, 7 mimeType: string, 8 contextText: string, 9): Promise<string | undefined> { 10 const config = getConfig(); 11 12 // 1. Determine Provider and Credentials 13 // Priority: AI Config > Legacy Gemini Config > Environment Variables 14 15 const provider = config.ai?.provider || 'gemini'; 16 let apiKey = config.ai?.apiKey; 17 let model = config.ai?.model; 18 const baseUrl = config.ai?.baseUrl; 19 20 // Fallbacks for Environment Variables 21 if (!apiKey) { 22 if (process.env.AI_API_KEY) apiKey = process.env.AI_API_KEY; 23 else if (provider === 'gemini') apiKey = config.geminiApiKey || process.env.GEMINI_API_KEY; 24 else if (provider === 'openai') apiKey = process.env.OPENAI_API_KEY; 25 else if (provider === 'anthropic') apiKey = process.env.ANTHROPIC_API_KEY; 26 } 27 28 // Fallback for Gemini specific legacy env var if provider is implicitly gemini 29 if (!apiKey && provider === 'gemini') { 30 apiKey = process.env.GEMINI_API_KEY; 31 } 32 33 // API Key is mandatory for Gemini and Anthropic 34 if (!apiKey && (provider === 'gemini' || provider === 'anthropic')) { 35 return undefined; 36 } 37 38 // Default Models 39 if (!model) { 40 if (provider === 'gemini') model = 'models/gemini-2.5-flash'; 41 else if (provider === 'openai') model = 'gpt-4o'; 42 else if (provider === 'anthropic') model = 'claude-3-5-sonnet-20241022'; 43 } 44 45 try { 46 const prompt = buildAltTextPrompt(contextText); 47 switch (provider) { 48 case 'gemini': 49 // apiKey is guaranteed by check above 50 return normalizeAltTextOutput( 51 await callGemini(apiKey!, model || 'models/gemini-2.5-flash', buffer, mimeType, prompt), 52 ); 53 case 'openai': 54 case 'custom': 55 return normalizeAltTextOutput( 56 await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, prompt), 57 ); 58 case 'anthropic': 59 // apiKey is guaranteed by check above 60 return normalizeAltTextOutput( 61 await callAnthropic( 62 apiKey!, 63 model || 'claude-3-5-sonnet-20241022', 64 baseUrl, 65 buffer, 66 mimeType, 67 prompt, 68 ), 69 ); 70 default: 71 console.warn(`[AI] ⚠️ Unknown provider: ${provider}`); 72 return undefined; 73 } 74 } catch (err) { 75 console.warn(`[AI] ⚠️ Failed to generate alt text with ${provider}: ${(err as Error).message}`); 76 return undefined; 77 } 78} 79 80const ALT_TEXT_CONTEXT_MAX_CHARS = 400; 81 82function buildAltTextPrompt(contextText: string): string { 83 const normalized = contextText.replace(/\s+/g, ' ').trim(); 84 const trimmed = 85 normalized.length > ALT_TEXT_CONTEXT_MAX_CHARS 86 ? `${normalized.slice(0, ALT_TEXT_CONTEXT_MAX_CHARS).trim()}...` 87 : normalized; 88 89 return [ 90 'Write one alt text description (1-2 sentences).', 91 'Describe only what is visible.', 92 'Use context to identify people/places/objects if relevant for search.', 93 'Describe only this image; ignore other images in the post.', 94 'Return only the alt text with no labels, quotes, or options.', 95 'No hashtags or emojis.', 96 `Context: "${trimmed}"`, 97 ].join(' '); 98} 99 100function normalizeAltTextOutput(output: string | undefined): string | undefined { 101 if (!output) return undefined; 102 103 let cleaned = output.trim(); 104 if (!cleaned) return undefined; 105 106 cleaned = cleaned.replace(/^["'“”]+|["'“”]+$/g, '').trim(); 107 cleaned = cleaned.replace(/^(alt\s*text|description)\s*[:\-]\s*/i, '').trim(); 108 109 const lines = cleaned 110 .split(/\r?\n/) 111 .map((line) => line.trim()) 112 .filter((line): line is string => Boolean(line)); 113 if (lines.length > 0) cleaned = lines[0] ?? ''; 114 115 cleaned = cleaned.replace(/^option\s*\d+\s*[:\-]\s*/i, '').trim(); 116 cleaned = cleaned.replace(/^[\-\*\d\.\)]+\s*/g, '').trim(); 117 cleaned = cleaned.replace(/\s+/g, ' ').trim(); 118 119 return cleaned || undefined; 120} 121 122async function callGemini( 123 apiKey: string, 124 modelName: string, 125 buffer: Buffer, 126 mimeType: string, 127 prompt: string, 128): Promise<string | undefined> { 129 const genAI = new GoogleGenerativeAI(apiKey); 130 const model = genAI.getGenerativeModel({ model: modelName }); 131 132 const result = await model.generateContent([ 133 prompt, 134 { 135 inlineData: { 136 data: buffer.toString('base64'), 137 mimeType, 138 }, 139 }, 140 ]); 141 const response = await result.response; 142 return response.text(); 143} 144 145async function callOpenAICompatible( 146 apiKey: string | undefined, 147 model: string, 148 baseUrl: string | undefined, 149 buffer: Buffer, 150 mimeType: string, 151 prompt: string, 152): Promise<string | undefined> { 153 const url = baseUrl 154 ? `${baseUrl.replace(/\/+$/, '')}/chat/completions` 155 : 'https://api.openai.com/v1/chat/completions'; 156 157 const base64Image = `data:${mimeType};base64,${buffer.toString('base64')}`; 158 159 const payload = { 160 model: model, 161 messages: [ 162 { 163 role: 'user', 164 content: [ 165 { 166 type: 'text', 167 text: prompt, 168 }, 169 { 170 type: 'image_url', 171 image_url: { 172 url: base64Image, 173 }, 174 }, 175 ], 176 }, 177 ], 178 max_tokens: 300, 179 }; 180 181 const headers: Record<string, string> = { 182 'Content-Type': 'application/json', 183 }; 184 185 if (apiKey) { 186 headers['Authorization'] = `Bearer ${apiKey}`; 187 } 188 189 // OpenRouter specific headers (optional but good practice) 190 if (url.includes('openrouter.ai')) { 191 headers['HTTP-Referer'] = 'https://github.com/tweets-2-bsky'; 192 headers['X-Title'] = 'Tweets to Bluesky'; 193 } 194 195 const response = await axios.post(url, payload, { headers }); 196 197 return response.data.choices[0]?.message?.content || undefined; 198} 199 200async function callAnthropic( 201 apiKey: string, 202 model: string, 203 baseUrl: string | undefined, 204 buffer: Buffer, 205 mimeType: string, 206 prompt: string, 207): Promise<string | undefined> { 208 const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/v1/messages` : 'https://api.anthropic.com/v1/messages'; 209 210 const base64Data = buffer.toString('base64'); 211 212 const payload = { 213 model: model, 214 max_tokens: 300, 215 messages: [ 216 { 217 role: 'user', 218 content: [ 219 { 220 type: 'image', 221 source: { 222 type: 'base64', 223 media_type: mimeType, 224 data: base64Data, 225 }, 226 }, 227 { 228 type: 'text', 229 text: prompt, 230 }, 231 ], 232 }, 233 ], 234 }; 235 236 const response = await axios.post(url, payload, { 237 headers: { 238 'x-api-key': apiKey, 239 'anthropic-version': '2023-06-01', 240 'Content-Type': 'application/json', 241 }, 242 }); 243 244 return response.data.content[0]?.text || undefined; 245}