forked from
j4ck.xyz/tweets2bsky
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.
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}