@hasna/microservices 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/microservices/microservice-social/package.json +2 -1
  2. package/microservices/microservice-social/src/cli/index.ts +906 -12
  3. package/microservices/microservice-social/src/db/migrations.ts +72 -0
  4. package/microservices/microservice-social/src/db/social.ts +33 -3
  5. package/microservices/microservice-social/src/lib/audience.ts +353 -0
  6. package/microservices/microservice-social/src/lib/content-ai.ts +278 -0
  7. package/microservices/microservice-social/src/lib/media.ts +311 -0
  8. package/microservices/microservice-social/src/lib/mentions.ts +434 -0
  9. package/microservices/microservice-social/src/lib/metrics-sync.ts +264 -0
  10. package/microservices/microservice-social/src/lib/publisher.ts +377 -0
  11. package/microservices/microservice-social/src/lib/scheduler.ts +229 -0
  12. package/microservices/microservice-social/src/lib/sentiment.ts +256 -0
  13. package/microservices/microservice-social/src/lib/threads.ts +291 -0
  14. package/microservices/microservice-social/src/mcp/index.ts +776 -6
  15. package/microservices/microservice-social/src/server/index.ts +441 -0
  16. package/microservices/microservice-transcriber/src/cli/index.ts +247 -1
  17. package/microservices/microservice-transcriber/src/db/comments.ts +166 -0
  18. package/microservices/microservice-transcriber/src/db/migrations.ts +46 -0
  19. package/microservices/microservice-transcriber/src/db/proofread.ts +119 -0
  20. package/microservices/microservice-transcriber/src/lib/downloader.ts +68 -0
  21. package/microservices/microservice-transcriber/src/lib/proofread.ts +296 -0
  22. package/microservices/microservice-transcriber/src/mcp/index.ts +263 -3
  23. package/package.json +1 -1
@@ -0,0 +1,278 @@
1
+ /**
2
+ * AI-powered content generation for social media.
3
+ * Supports OpenAI (gpt-4o-mini) and Anthropic (claude-haiku) — same pattern as transcriber summarizer.
4
+ * Default: OpenAI if OPENAI_API_KEY set, else Anthropic.
5
+ */
6
+
7
+ import { PLATFORM_LIMITS, type Platform } from "../db/social.js";
8
+
9
+ export type AIProvider = "openai" | "anthropic";
10
+ export type Tone = "professional" | "casual" | "witty";
11
+
12
+ export interface GeneratePostOptions {
13
+ tone?: Tone;
14
+ includeHashtags?: boolean;
15
+ includeEmoji?: boolean;
16
+ language?: string;
17
+ }
18
+
19
+ export interface GeneratedPost {
20
+ content: string;
21
+ hashtags: string[];
22
+ suggested_media_prompt: string;
23
+ }
24
+
25
+ export interface OptimizedPost {
26
+ optimized_content: string;
27
+ improvements: string[];
28
+ }
29
+
30
+ export interface RepurposedPost {
31
+ content: string;
32
+ }
33
+
34
+ // ---- Provider Detection ----
35
+
36
+ export function getDefaultAIProvider(): AIProvider | null {
37
+ if (process.env["OPENAI_API_KEY"]) return "openai";
38
+ if (process.env["ANTHROPIC_API_KEY"]) return "anthropic";
39
+ return null;
40
+ }
41
+
42
+ function resolveProvider(provider?: AIProvider): AIProvider {
43
+ const resolved = provider ?? getDefaultAIProvider();
44
+ if (!resolved) throw new Error("No AI API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.");
45
+ return resolved;
46
+ }
47
+
48
+ // ---- Prompt Builders (exported for testing) ----
49
+
50
+ export function buildGeneratePostPrompt(
51
+ topic: string,
52
+ platform: Platform,
53
+ options: GeneratePostOptions = {}
54
+ ): string {
55
+ const limit = PLATFORM_LIMITS[platform];
56
+ const tone = options.tone || "professional";
57
+ const lang = options.language || "English";
58
+ const hashtagLine = options.includeHashtags !== false
59
+ ? "Include 3-5 relevant hashtags."
60
+ : "Do NOT include hashtags.";
61
+ const emojiLine = options.includeEmoji
62
+ ? "Use emojis where appropriate."
63
+ : "Do not use emojis.";
64
+
65
+ return `Write a social media post about the following topic for ${platform}.
66
+ Character limit: ${limit} characters. The post content MUST be within this limit.
67
+ Tone: ${tone}
68
+ Language: ${lang}
69
+ ${hashtagLine}
70
+ ${emojiLine}
71
+
72
+ Return ONLY valid JSON with these fields:
73
+ - "content": the post text (within ${limit} chars)
74
+ - "hashtags": array of hashtag strings (without # prefix)
75
+ - "suggested_media_prompt": a short description for AI image generation that would complement this post
76
+
77
+ Topic: ${topic}`;
78
+ }
79
+
80
+ export function buildSuggestHashtagsPrompt(
81
+ content: string,
82
+ platform: Platform,
83
+ count: number
84
+ ): string {
85
+ return `Analyze the following social media post for ${platform} and suggest ${count} relevant hashtags.
86
+ Return ONLY a valid JSON array of strings (without the # prefix).
87
+
88
+ Post: ${content}`;
89
+ }
90
+
91
+ export function buildOptimizePostPrompt(content: string, platform: Platform): string {
92
+ const limit = PLATFORM_LIMITS[platform];
93
+ return `Optimize the following social media post for better engagement on ${platform}.
94
+ Character limit: ${limit} characters. The optimized content MUST be within this limit.
95
+
96
+ Return ONLY valid JSON with these fields:
97
+ - "optimized_content": the improved post text (within ${limit} chars)
98
+ - "improvements": array of strings explaining each change made
99
+
100
+ Original post: ${content}`;
101
+ }
102
+
103
+ export function buildGenerateThreadPrompt(topic: string, tweetCount: number): string {
104
+ return `Write a Twitter/X thread of exactly ${tweetCount} tweets about the following topic.
105
+ Each tweet MUST be within 280 characters.
106
+ Number each tweet (1/${tweetCount}, 2/${tweetCount}, etc.) at the start.
107
+
108
+ Return ONLY a valid JSON array of strings, one per tweet.
109
+
110
+ Topic: ${topic}`;
111
+ }
112
+
113
+ export function buildRepurposePostPrompt(
114
+ content: string,
115
+ sourcePlatform: Platform,
116
+ targetPlatform: Platform
117
+ ): string {
118
+ const targetLimit = PLATFORM_LIMITS[targetPlatform];
119
+ return `Adapt the following ${sourcePlatform} post for ${targetPlatform}.
120
+ Target character limit: ${targetLimit} characters. The content MUST be within this limit.
121
+ Adjust the tone, length, hashtag style, and formatting to match ${targetPlatform} conventions.
122
+
123
+ Return ONLY valid JSON with a single field:
124
+ - "content": the adapted post text (within ${targetLimit} chars)
125
+
126
+ Original ${sourcePlatform} post: ${content}`;
127
+ }
128
+
129
+ // ---- AI Functions ----
130
+
131
+ export async function generatePost(
132
+ topic: string,
133
+ platform: Platform,
134
+ options: GeneratePostOptions = {},
135
+ provider?: AIProvider
136
+ ): Promise<GeneratedPost> {
137
+ const resolved = resolveProvider(provider);
138
+ const prompt = buildGeneratePostPrompt(topic, platform, options);
139
+
140
+ const raw = resolved === "openai"
141
+ ? await callOpenAI(prompt, 500)
142
+ : await callAnthropic(prompt, 500);
143
+
144
+ return parseJSON<GeneratedPost>(raw, {
145
+ content: "",
146
+ hashtags: [],
147
+ suggested_media_prompt: "",
148
+ });
149
+ }
150
+
151
+ export async function suggestHashtags(
152
+ content: string,
153
+ platform: Platform,
154
+ count: number = 5,
155
+ provider?: AIProvider
156
+ ): Promise<string[]> {
157
+ const resolved = resolveProvider(provider);
158
+ const prompt = buildSuggestHashtagsPrompt(content, platform, count);
159
+
160
+ const raw = resolved === "openai"
161
+ ? await callOpenAI(prompt, 200)
162
+ : await callAnthropic(prompt, 200);
163
+
164
+ const parsed = parseJSON<string[]>(raw, []);
165
+ return Array.isArray(parsed) ? parsed : [];
166
+ }
167
+
168
+ export async function optimizePost(
169
+ content: string,
170
+ platform: Platform,
171
+ provider?: AIProvider
172
+ ): Promise<OptimizedPost> {
173
+ const resolved = resolveProvider(provider);
174
+ const prompt = buildOptimizePostPrompt(content, platform);
175
+
176
+ const raw = resolved === "openai"
177
+ ? await callOpenAI(prompt, 600)
178
+ : await callAnthropic(prompt, 600);
179
+
180
+ return parseJSON<OptimizedPost>(raw, {
181
+ optimized_content: "",
182
+ improvements: [],
183
+ });
184
+ }
185
+
186
+ export async function generateThread(
187
+ topic: string,
188
+ tweetCount: number = 5,
189
+ provider?: AIProvider
190
+ ): Promise<string[]> {
191
+ const resolved = resolveProvider(provider);
192
+ const prompt = buildGenerateThreadPrompt(topic, tweetCount);
193
+
194
+ const raw = resolved === "openai"
195
+ ? await callOpenAI(prompt, 1500)
196
+ : await callAnthropic(prompt, 1500);
197
+
198
+ const parsed = parseJSON<string[]>(raw, []);
199
+ return Array.isArray(parsed) ? parsed : [];
200
+ }
201
+
202
+ export async function repurposePost(
203
+ content: string,
204
+ sourcePlatform: Platform,
205
+ targetPlatform: Platform,
206
+ provider?: AIProvider
207
+ ): Promise<RepurposedPost> {
208
+ const resolved = resolveProvider(provider);
209
+ const prompt = buildRepurposePostPrompt(content, sourcePlatform, targetPlatform);
210
+
211
+ const raw = resolved === "openai"
212
+ ? await callOpenAI(prompt, 500)
213
+ : await callAnthropic(prompt, 500);
214
+
215
+ return parseJSON<RepurposedPost>(raw, { content: "" });
216
+ }
217
+
218
+ // ---- JSON Parser ----
219
+
220
+ function parseJSON<T>(raw: string, fallback: T): T {
221
+ const cleaned = raw.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
222
+ try {
223
+ return JSON.parse(cleaned) as T;
224
+ } catch {
225
+ return fallback;
226
+ }
227
+ }
228
+
229
+ // ---- Shared AI Callers ----
230
+
231
+ export async function callOpenAI(prompt: string, maxTokens: number): Promise<string> {
232
+ const apiKey = process.env["OPENAI_API_KEY"];
233
+ if (!apiKey) throw new Error("OPENAI_API_KEY is not set");
234
+
235
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
236
+ method: "POST",
237
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ model: "gpt-4o-mini",
240
+ messages: [{ role: "user", content: prompt }],
241
+ max_tokens: maxTokens,
242
+ temperature: 0.7,
243
+ }),
244
+ });
245
+
246
+ if (!res.ok) {
247
+ const body = await res.text();
248
+ throw new Error(`OpenAI API error ${res.status}: ${body}`);
249
+ }
250
+ const data = (await res.json()) as { choices: Array<{ message: { content: string } }> };
251
+ return data.choices[0]?.message?.content?.trim() ?? "";
252
+ }
253
+
254
+ export async function callAnthropic(prompt: string, maxTokens: number): Promise<string> {
255
+ const apiKey = process.env["ANTHROPIC_API_KEY"];
256
+ if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set");
257
+
258
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
259
+ method: "POST",
260
+ headers: {
261
+ "x-api-key": apiKey,
262
+ "anthropic-version": "2023-06-01",
263
+ "Content-Type": "application/json",
264
+ },
265
+ body: JSON.stringify({
266
+ model: "claude-haiku-4-5-20251001",
267
+ max_tokens: maxTokens,
268
+ messages: [{ role: "user", content: prompt }],
269
+ }),
270
+ });
271
+
272
+ if (!res.ok) {
273
+ const body = await res.text();
274
+ throw new Error(`Anthropic API error ${res.status}: ${body}`);
275
+ }
276
+ const data = (await res.json()) as { content: Array<{ type: string; text: string }> };
277
+ return data.content.find((b) => b.type === "text")?.text?.trim() ?? "";
278
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Media upload handling for social media platforms
3
+ *
4
+ * Provides media validation, format checking, and platform-specific
5
+ * upload functions for X (Twitter) and Meta (Facebook/Instagram).
6
+ */
7
+
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { extname } from "node:path";
10
+ import type { Platform } from "../db/social.js";
11
+
12
+ // ---- Types ----
13
+
14
+ export interface MediaUpload {
15
+ filePath: string;
16
+ platform: Platform;
17
+ mediaId?: string;
18
+ url?: string;
19
+ }
20
+
21
+ export interface UploadResult {
22
+ mediaId: string;
23
+ url?: string;
24
+ }
25
+
26
+ export interface ValidationResult {
27
+ valid: boolean;
28
+ errors: string[];
29
+ }
30
+
31
+ // ---- Platform Configs ----
32
+
33
+ const SUPPORTED_FORMATS: Record<Platform, string[]> = {
34
+ x: ["jpg", "jpeg", "png", "gif", "mp4", "webp"],
35
+ linkedin: ["jpg", "jpeg", "png", "gif", "mp4"],
36
+ instagram: ["jpg", "jpeg", "png", "mp4", "mov"],
37
+ threads: ["jpg", "jpeg", "png", "gif", "mp4"],
38
+ bluesky: ["jpg", "jpeg", "png", "gif"],
39
+ };
40
+
41
+ /** Maximum file sizes in bytes per platform */
42
+ const MAX_FILE_SIZE: Record<Platform, Record<string, number>> = {
43
+ x: {
44
+ image: 5 * 1024 * 1024, // 5 MB for images
45
+ gif: 15 * 1024 * 1024, // 15 MB for GIFs
46
+ video: 512 * 1024 * 1024, // 512 MB for video
47
+ },
48
+ linkedin: {
49
+ image: 10 * 1024 * 1024,
50
+ gif: 10 * 1024 * 1024,
51
+ video: 200 * 1024 * 1024,
52
+ },
53
+ instagram: {
54
+ image: 8 * 1024 * 1024,
55
+ video: 100 * 1024 * 1024,
56
+ },
57
+ threads: {
58
+ image: 8 * 1024 * 1024,
59
+ gif: 8 * 1024 * 1024,
60
+ video: 100 * 1024 * 1024,
61
+ },
62
+ bluesky: {
63
+ image: 1 * 1024 * 1024, // 1 MB for Bluesky images
64
+ gif: 1 * 1024 * 1024,
65
+ },
66
+ };
67
+
68
+ const VIDEO_EXTENSIONS = new Set(["mp4", "mov"]);
69
+ const GIF_EXTENSIONS = new Set(["gif"]);
70
+
71
+ // ---- Helpers ----
72
+
73
+ function getFileExtension(filePath: string): string {
74
+ return extname(filePath).slice(1).toLowerCase();
75
+ }
76
+
77
+ function getMediaType(ext: string): "image" | "gif" | "video" {
78
+ if (VIDEO_EXTENSIONS.has(ext)) return "video";
79
+ if (GIF_EXTENSIONS.has(ext)) return "gif";
80
+ return "image";
81
+ }
82
+
83
+ // ---- Validation ----
84
+
85
+ /**
86
+ * Get supported media formats for a platform
87
+ */
88
+ export function getSupportedFormats(platform: Platform): string[] {
89
+ return SUPPORTED_FORMATS[platform] || [];
90
+ }
91
+
92
+ /**
93
+ * Validate a media file against platform requirements:
94
+ * - File exists
95
+ * - Format is supported
96
+ * - File size is within limits
97
+ */
98
+ export function validateMedia(filePath: string, platform: Platform): ValidationResult {
99
+ const errors: string[] = [];
100
+
101
+ // Check file exists
102
+ if (!existsSync(filePath)) {
103
+ return { valid: false, errors: [`File not found: ${filePath}`] };
104
+ }
105
+
106
+ // Check format
107
+ const ext = getFileExtension(filePath);
108
+ const supportedFormats = getSupportedFormats(platform);
109
+ if (!supportedFormats.includes(ext)) {
110
+ errors.push(
111
+ `Format '${ext}' not supported on ${platform}. Supported: ${supportedFormats.join(", ")}`
112
+ );
113
+ }
114
+
115
+ // Check file size
116
+ const stats = statSync(filePath);
117
+ const mediaType = getMediaType(ext);
118
+ const platformLimits = MAX_FILE_SIZE[platform];
119
+ const maxSize = platformLimits?.[mediaType];
120
+
121
+ if (maxSize && stats.size > maxSize) {
122
+ const maxMB = (maxSize / (1024 * 1024)).toFixed(0);
123
+ const fileMB = (stats.size / (1024 * 1024)).toFixed(2);
124
+ errors.push(
125
+ `File size ${fileMB} MB exceeds ${platform} ${mediaType} limit of ${maxMB} MB`
126
+ );
127
+ }
128
+
129
+ return { valid: errors.length === 0, errors };
130
+ }
131
+
132
+ // ---- Upload Functions ----
133
+
134
+ /**
135
+ * Upload media to X (Twitter) using the media upload API.
136
+ * Uses chunked upload for video, simple upload for images.
137
+ *
138
+ * Requires X_BEARER_TOKEN or X_ACCESS_TOKEN env var.
139
+ */
140
+ export async function uploadToX(filePath: string): Promise<UploadResult> {
141
+ const validation = validateMedia(filePath, "x");
142
+ if (!validation.valid) {
143
+ throw new Error(`Validation failed: ${validation.errors.join("; ")}`);
144
+ }
145
+
146
+ const token = process.env["X_BEARER_TOKEN"] || process.env["X_ACCESS_TOKEN"];
147
+ if (!token) {
148
+ throw new Error("Missing X_BEARER_TOKEN or X_ACCESS_TOKEN environment variable");
149
+ }
150
+
151
+ const ext = getFileExtension(filePath);
152
+ const mediaType = getMediaType(ext);
153
+
154
+ if (mediaType === "video") {
155
+ return uploadToXChunked(filePath, token);
156
+ }
157
+
158
+ return uploadToXSimple(filePath, token);
159
+ }
160
+
161
+ async function uploadToXSimple(filePath: string, token: string): Promise<UploadResult> {
162
+ const file = Bun.file(filePath);
163
+ const formData = new FormData();
164
+ formData.append("media", file);
165
+
166
+ const response = await fetch("https://upload.twitter.com/1.1/media/upload.json", {
167
+ method: "POST",
168
+ headers: {
169
+ Authorization: `Bearer ${token}`,
170
+ },
171
+ body: formData,
172
+ });
173
+
174
+ if (!response.ok) {
175
+ const text = await response.text();
176
+ throw new Error(`X media upload failed (${response.status}): ${text}`);
177
+ }
178
+
179
+ const data = (await response.json()) as { media_id_string: string };
180
+ return { mediaId: data.media_id_string };
181
+ }
182
+
183
+ async function uploadToXChunked(filePath: string, token: string): Promise<UploadResult> {
184
+ const file = Bun.file(filePath);
185
+ const fileSize = file.size;
186
+ const ext = getFileExtension(filePath);
187
+ const mimeType = ext === "mp4" ? "video/mp4" : "video/quicktime";
188
+
189
+ // INIT
190
+ const initResponse = await fetch("https://upload.twitter.com/1.1/media/upload.json", {
191
+ method: "POST",
192
+ headers: {
193
+ Authorization: `Bearer ${token}`,
194
+ "Content-Type": "application/x-www-form-urlencoded",
195
+ },
196
+ body: new URLSearchParams({
197
+ command: "INIT",
198
+ total_bytes: String(fileSize),
199
+ media_type: mimeType,
200
+ }),
201
+ });
202
+
203
+ if (!initResponse.ok) {
204
+ const text = await initResponse.text();
205
+ throw new Error(`X chunked upload INIT failed (${initResponse.status}): ${text}`);
206
+ }
207
+
208
+ const initData = (await initResponse.json()) as { media_id_string: string };
209
+ const mediaId = initData.media_id_string;
210
+
211
+ // APPEND — send in 5MB chunks
212
+ const CHUNK_SIZE = 5 * 1024 * 1024;
213
+ const buffer = await file.arrayBuffer();
214
+ let segmentIndex = 0;
215
+
216
+ for (let offset = 0; offset < fileSize; offset += CHUNK_SIZE) {
217
+ const chunk = buffer.slice(offset, Math.min(offset + CHUNK_SIZE, fileSize));
218
+ const formData = new FormData();
219
+ formData.append("command", "APPEND");
220
+ formData.append("media_id", mediaId);
221
+ formData.append("segment_index", String(segmentIndex));
222
+ formData.append("media", new Blob([chunk]));
223
+
224
+ const appendResponse = await fetch("https://upload.twitter.com/1.1/media/upload.json", {
225
+ method: "POST",
226
+ headers: { Authorization: `Bearer ${token}` },
227
+ body: formData,
228
+ });
229
+
230
+ if (!appendResponse.ok) {
231
+ const text = await appendResponse.text();
232
+ throw new Error(`X chunked upload APPEND failed (${appendResponse.status}): ${text}`);
233
+ }
234
+
235
+ segmentIndex++;
236
+ }
237
+
238
+ // FINALIZE
239
+ const finalizeResponse = await fetch("https://upload.twitter.com/1.1/media/upload.json", {
240
+ method: "POST",
241
+ headers: {
242
+ Authorization: `Bearer ${token}`,
243
+ "Content-Type": "application/x-www-form-urlencoded",
244
+ },
245
+ body: new URLSearchParams({
246
+ command: "FINALIZE",
247
+ media_id: mediaId,
248
+ }),
249
+ });
250
+
251
+ if (!finalizeResponse.ok) {
252
+ const text = await finalizeResponse.text();
253
+ throw new Error(`X chunked upload FINALIZE failed (${finalizeResponse.status}): ${text}`);
254
+ }
255
+
256
+ return { mediaId };
257
+ }
258
+
259
+ /**
260
+ * Upload media to Meta (Facebook/Instagram) using the Graph API.
261
+ * Uses /{page-id}/photos for images.
262
+ *
263
+ * Requires META_ACCESS_TOKEN env var.
264
+ */
265
+ export async function uploadToMeta(filePath: string, pageId: string): Promise<UploadResult> {
266
+ const validation = validateMedia(filePath, "instagram");
267
+ if (!validation.valid) {
268
+ throw new Error(`Validation failed: ${validation.errors.join("; ")}`);
269
+ }
270
+
271
+ const token = process.env["META_ACCESS_TOKEN"];
272
+ if (!token) {
273
+ throw new Error("Missing META_ACCESS_TOKEN environment variable");
274
+ }
275
+
276
+ const file = Bun.file(filePath);
277
+ const formData = new FormData();
278
+ formData.append("source", file);
279
+ formData.append("access_token", token);
280
+
281
+ const response = await fetch(`https://graph.facebook.com/v19.0/${pageId}/photos`, {
282
+ method: "POST",
283
+ body: formData,
284
+ });
285
+
286
+ if (!response.ok) {
287
+ const text = await response.text();
288
+ throw new Error(`Meta media upload failed (${response.status}): ${text}`);
289
+ }
290
+
291
+ const data = (await response.json()) as { id: string; post_id?: string };
292
+ return { mediaId: data.id };
293
+ }
294
+
295
+ /**
296
+ * Route upload to the correct platform uploader
297
+ */
298
+ export async function uploadMedia(filePath: string, platform: Platform, pageId?: string): Promise<UploadResult> {
299
+ switch (platform) {
300
+ case "x":
301
+ return uploadToX(filePath);
302
+ case "instagram":
303
+ case "linkedin":
304
+ if (!pageId) {
305
+ throw new Error(`Page ID required for ${platform} uploads`);
306
+ }
307
+ return uploadToMeta(filePath, pageId);
308
+ default:
309
+ throw new Error(`Media upload not yet supported for platform '${platform}'`);
310
+ }
311
+ }