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.

fix: make API key optional for custom/openai providers (e.g. Ollama)

jack b5031d87 a2eb980b

+36 -19
+7 -1
public/index.html
··· 517 517 </div> 518 518 <div className="mb-3"> 519 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 /> 520 + <input 521 + name="apiKey" 522 + defaultValue={aiConfig.apiKey} 523 + className="form-control form-control-sm" 524 + placeholder={['gemini', 'anthropic'].includes(aiConfig.provider) ? "Required" : "Optional (e.g. for Ollama)"} 525 + required={['gemini', 'anthropic'].includes(aiConfig.provider)} 526 + /> 521 527 </div> 522 528 523 529 {(aiConfig.provider !== 'gemini') && (
+22 -17
src/ai-manager.ts
··· 26 26 apiKey = process.env.GEMINI_API_KEY; 27 27 } 28 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. 29 + // API Key is mandatory for Gemini and Anthropic 30 + if (!apiKey && (provider === 'gemini' || provider === 'anthropic')) { 32 31 return undefined; 33 32 } 34 33 ··· 42 41 try { 43 42 switch (provider) { 44 43 case 'gemini': 45 - return await callGemini(apiKey, model || 'models/gemini-2.5-flash', buffer, mimeType, contextText); 44 + // apiKey is guaranteed by check above 45 + return await callGemini(apiKey!, model || 'models/gemini-2.5-flash', buffer, mimeType, contextText); 46 46 case 'openai': 47 47 case 'custom': 48 48 return await callOpenAICompatible(apiKey, model || 'gpt-4o', baseUrl, buffer, mimeType, contextText); 49 49 case 'anthropic': 50 - return await callAnthropic(apiKey, model || 'claude-3-5-sonnet-20241022', baseUrl, buffer, mimeType, contextText); 50 + // apiKey is guaranteed by check above 51 + return await callAnthropic(apiKey!, model || 'claude-3-5-sonnet-20241022', baseUrl, buffer, mimeType, contextText); 51 52 default: 52 53 console.warn(`[AI] ⚠️ Unknown provider: ${provider}`); 53 54 return undefined; ··· 79 80 return response.text(); 80 81 } 81 82 82 - async function callOpenAICompatible(apiKey: string, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 83 + async function callOpenAICompatible(apiKey: string | undefined, model: string, baseUrl: string | undefined, buffer: Buffer, mimeType: string, contextText: string): Promise<string | undefined> { 83 84 const url = baseUrl ? `${baseUrl.replace(/\/+$/, '')}/chat/completions` : 'https://api.openai.com/v1/chat/completions'; 84 85 85 86 const base64Image = `data:${mimeType};base64,${buffer.toString('base64')}`; ··· 106 107 max_tokens: 300 107 108 }; 108 109 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 - }); 110 + const headers: Record<string, string> = { 111 + 'Content-Type': 'application/json' 112 + }; 113 + 114 + if (apiKey) { 115 + headers['Authorization'] = `Bearer ${apiKey}`; 116 + } 117 + 118 + // OpenRouter specific headers (optional but good practice) 119 + if (url.includes('openrouter.ai')) { 120 + headers['HTTP-Referer'] = 'https://github.com/tweets-2-bsky'; 121 + headers['X-Title'] = 'Tweets to Bluesky'; 122 + } 123 + 124 + const response = await axios.post(url, payload, { headers }); 120 125 121 126 return response.data.choices[0]?.message?.content || undefined; 122 127 }
+7 -1
src/cli.ts
··· 37 37 { 38 38 type: 'input', 39 39 name: 'apiKey', 40 - message: 'Enter API Key:', 40 + message: 'Enter API Key (optional for some custom providers):', 41 41 default: currentAi.apiKey, 42 + validate: (input: string, answers: any) => { 43 + if (['gemini', 'anthropic'].includes(answers.provider) && !input) { 44 + return 'API Key is required for this provider.'; 45 + } 46 + return true; 47 + } 42 48 }, 43 49 { 44 50 type: 'input',