From 76900f287be1ae1dffa0eb0502a84419503c1a02 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Mon, 13 Jan 2025 22:03:07 -0700 Subject: [PATCH 1/3] refactor to config --- backend/src/config/admins.ts | 6 - backend/src/config/config.ts | 66 +-- backend/src/index.ts | 51 ++- backend/src/services/config/config.service.ts | 59 +++ backend/src/services/config/index.ts | 1 + backend/src/services/exports/external/rss.ts | 80 ++-- .../src/services/exports/external/telegram.ts | 50 +-- backend/src/services/exports/manager.ts | 165 +++++-- backend/src/services/exports/types.ts | 37 +- .../submissions/submission.service.ts | 269 ++++++++++++ backend/src/services/twitter/client.ts | 406 ++---------------- backend/src/types/config.ts | 58 +++ backend/src/types/index.ts | 8 - backend/src/utils/config.ts | 37 ++ curate.config.json | 205 +++++++++ 15 files changed, 902 insertions(+), 596 deletions(-) delete mode 100644 backend/src/config/admins.ts create mode 100644 backend/src/services/config/config.service.ts create mode 100644 backend/src/services/config/index.ts create mode 100644 backend/src/services/submissions/submission.service.ts create mode 100644 backend/src/types/config.ts delete mode 100644 backend/src/types/index.ts create mode 100644 backend/src/utils/config.ts create mode 100644 curate.config.json diff --git a/backend/src/config/admins.ts b/backend/src/config/admins.ts deleted file mode 100644 index 47ede5d..0000000 --- a/backend/src/config/admins.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const ADMIN_ACCOUNTS: string[] = [ - // Add admin Twitter handles here (without @) - // Example: "TwitterDev" - "elliot_braem", - "plugrel", -]; diff --git a/backend/src/config/config.ts b/backend/src/config/config.ts index da4bc25..6cfc4c6 100644 --- a/backend/src/config/config.ts +++ b/backend/src/config/config.ts @@ -1,49 +1,5 @@ -import { AppConfig, ExportConfig } from "../types"; -import path from "path"; - -// Configure export services -const exports: ExportConfig[] = []; - -// Add Telegram export if configured -if (process.env.TELEGRAM_ENABLED === "true") { - exports.push({ - type: "telegram", - enabled: true, - module: "telegram", - botToken: process.env.TELEGRAM_BOT_TOKEN!, - channelId: process.env.TELEGRAM_CHANNEL_ID!, - }); -} - -// Add RSS export if configured -if (process.env.RSS_ENABLED === "true") { - exports.push({ - type: "rss", - enabled: true, - module: "rss", - title: process.env.RSS_TITLE || "Public Goods News", - description: - process.env.RSS_DESCRIPTION || "Latest approved public goods submissions", - feedPath: - process.env.RSS_FEED_PATH || - path.join(process.cwd(), "public", "feed.xml"), - maxItems: process.env.RSS_MAX_ITEMS - ? parseInt(process.env.RSS_MAX_ITEMS) - : 100, - }); -} - -const config: AppConfig = { - twitter: { - username: process.env.TWITTER_USERNAME!, - password: process.env.TWITTER_PASSWORD!, - email: process.env.TWITTER_EMAIL!, - }, - environment: - (process.env.NODE_ENV as "development" | "production" | "test") || - "development", - exports, -}; +import { ConfigService } from '../services/config'; +import { AppConfig } from '../types/config'; export function validateEnv() { // Validate required Twitter credentials @@ -56,20 +12,12 @@ export function validateEnv() { "Missing required Twitter credentials. Please ensure TWITTER_USERNAME, TWITTER_PASSWORD, and TWITTER_EMAIL are set in your environment variables.", ); } +} - // Validate Telegram config if enabled - if (process.env.TELEGRAM_ENABLED === "true") { - if (!process.env.TELEGRAM_BOT_TOKEN || !process.env.TELEGRAM_CHANNEL_ID) { - throw new Error( - "Telegram export is enabled but missing required configuration. Please ensure TELEGRAM_BOT_TOKEN and TELEGRAM_CHANNEL_ID are set in your environment variables.", - ); - } - } +const configService = ConfigService.getInstance(); - // Validate RSS config if enabled - if (process.env.RSS_ENABLED === "true") { - // RSS has reasonable defaults, so no validation needed - } +export function getConfig(): AppConfig { + return configService.getConfig(); } -export default config; +export default configService; diff --git a/backend/src/index.ts b/backend/src/index.ts index 600a56b..fe92f95 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,11 @@ import { ServerWebSocket } from "bun"; import dotenv from "dotenv"; import path from "path"; -import config, { validateEnv } from "./config/config"; +import configService, { validateEnv } from "./config/config"; import { db } from "./services/db"; import { TwitterService } from "./services/twitter/client"; import { ExportManager } from "./services/exports/manager"; +import { SubmissionService } from "./services/submissions/submission.service"; import { cleanup, failSpinner, @@ -33,11 +34,12 @@ export function broadcastUpdate(data: unknown) { export async function main() { try { - // Load environment variables - startSpinner("env", "Loading environment variables..."); + // Load environment variables and config + startSpinner("env", "Loading environment variables and config..."); dotenv.config(); validateEnv(); - succeedSpinner("env", "Environment variables loaded"); + await configService.loadConfig(); + succeedSpinner("env", "Environment variables and config loaded"); // Initialize services startSpinner("server", "Starting server..."); @@ -154,23 +156,42 @@ export async function main() { succeedSpinner("server", `Server running on port ${PORT}`); + // Initialize Twitter service + startSpinner("twitter-init", "Initializing Twitter service..."); + const twitterService = new TwitterService({ + username: process.env.TWITTER_USERNAME!, + password: process.env.TWITTER_PASSWORD!, + email: process.env.TWITTER_EMAIL! + }); + await twitterService.initialize(); + succeedSpinner("twitter-init", "Twitter service initialized"); + // Initialize export service startSpinner("export-init", "Initializing export service..."); const exportManager = new ExportManager(); - await exportManager.initialize(config.exports); + const config = configService.getConfig(); + await exportManager.initialize(config.plugins); succeedSpinner("export-init", "Export service initialized"); - // Initialize Twitter service after server is running - startSpinner("twitter-init", "Initializing Twitter service..."); - const twitterService = new TwitterService(config.twitter, exportManager); - await twitterService.initialize(); - succeedSpinner("twitter-init", "Twitter service initialized"); + // Initialize submission service + startSpinner("submission-init", "Initializing submission service..."); + const submissionService = new SubmissionService( + twitterService, + exportManager, + config + ); + await submissionService.initialize(); + succeedSpinner("submission-init", "Submission service initialized"); // Handle graceful shutdown process.on("SIGINT", async () => { startSpinner("shutdown", "Shutting down gracefully..."); try { - await Promise.all([twitterService.stop(), exportManager.shutdown()]); + await Promise.all([ + twitterService.stop(), + submissionService.stop(), + exportManager.shutdown() + ]); succeedSpinner("shutdown", "Shutdown complete"); process.exit(0); } catch (error) { @@ -183,13 +204,13 @@ export async function main() { logger.info("🚀 Bot is running and ready for events", { twitterEnabled: true, websocketEnabled: true, - exportsEnabled: config.exports.length > 0, + exportsEnabled: Object.keys(config.plugins).length > 0, }); // Start checking for mentions - startSpinner("twitter-mentions", "Starting mentions check..."); - await twitterService.startMentionsCheck(); - succeedSpinner("twitter-mentions", "Mentions check started"); + startSpinner("submission-monitor", "Starting submission monitoring..."); + await submissionService.startMentionsCheck(); + succeedSpinner("submission-monitor", "Submission monitoring started"); } catch (error) { // Handle any initialization errors [ diff --git a/backend/src/services/config/config.service.ts b/backend/src/services/config/config.service.ts new file mode 100644 index 0000000..adc4f90 --- /dev/null +++ b/backend/src/services/config/config.service.ts @@ -0,0 +1,59 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { AppConfig } from '../../types/config'; +import { hydrateConfigValues } from '../../utils/config'; + +export class ConfigService { + private static instance: ConfigService; + private config: AppConfig | null = null; + private configPath: string; + + private constructor() { + // Default to local config file path + this.configPath = path.resolve(process.cwd(), '../../curate.config.json'); + } + + public static getInstance(): ConfigService { + if (!ConfigService.instance) { + ConfigService.instance = new ConfigService(); + } + return ConfigService.instance; + } + + public async loadConfig(): Promise { + try { + // This could be replaced with an API call in the future + const configFile = await fs.readFile(this.configPath, 'utf-8'); + const parsedConfig = JSON.parse(configFile) as AppConfig; + const hydratedConfig = hydrateConfigValues(parsedConfig); + this.config = hydratedConfig; + return hydratedConfig; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load config: ${message}`); + } + } + + public getConfig(): AppConfig { + if (!this.config) { + throw new Error('Config not loaded. Call loadConfig() first.'); + } + return this.config; + } + + public setConfigPath(path: string): void { + this.configPath = path; + } + + // Switch to a different config (if saving locally, wouldn't work in fly.io container) + public async updateConfig(newConfig: AppConfig): Promise { + // saving this for later + try { + await fs.writeFile(this.configPath, JSON.stringify(newConfig, null, 2)); + this.config = newConfig; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to update config: ${message}`); + } + } +} diff --git a/backend/src/services/config/index.ts b/backend/src/services/config/index.ts new file mode 100644 index 0000000..456e19e --- /dev/null +++ b/backend/src/services/config/index.ts @@ -0,0 +1 @@ +export { ConfigService } from './config.service'; diff --git a/backend/src/services/exports/external/rss.ts b/backend/src/services/exports/external/rss.ts index 6dd0b4c..2e80159 100644 --- a/backend/src/services/exports/external/rss.ts +++ b/backend/src/services/exports/external/rss.ts @@ -1,12 +1,13 @@ -import { ExportService, RssConfig } from "../types"; -import { TwitterSubmission } from "../../../types"; +import { DistributorPlugin } from "../types"; import { writeFile, readFile, mkdir } from "fs/promises"; import { existsSync } from "fs"; import path from "path"; -export class RssExportService implements ExportService { +export class RssPlugin implements DistributorPlugin { name = "rss"; - private config: RssConfig; + private title: string | null = null; + private path: string | null = null; + private maxItems: number = 100; private items: Array<{ title: string; description: string; @@ -15,18 +16,21 @@ export class RssExportService implements ExportService { guid: string; }> = []; - constructor(config: RssConfig) { - if (!config.enabled) { - throw new Error("RSS export service is not enabled"); + async initialize(config: Record): Promise { + if (!config.title || !config.path) { + throw new Error("RSS plugin requires title and path"); + } + + this.title = config.title; + this.path = config.path; + if (config.maxItems) { + this.maxItems = parseInt(config.maxItems); } - this.config = config; - } - async initialize(): Promise { try { // Load existing RSS items if file exists - if (existsSync(this.config.feedPath)) { - const content = await readFile(this.config.feedPath, "utf-8"); + if (existsSync(this.path)) { + const content = await readFile(this.path, "utf-8"); const match = content.match(/[\s\S]*?<\/item>/g); if (match) { this.items = match.map((item) => { @@ -40,49 +44,39 @@ export class RssExportService implements ExportService { }); } } - console.info("RSS export service initialized"); + console.info("RSS plugin initialized"); } catch (error) { - console.error("Failed to initialize RSS export service:", error); + console.error("Failed to initialize RSS plugin:", error); throw error; } } - async handleApprovedSubmission(submission: TwitterSubmission): Promise { - try { - const item = { - title: this.formatTitle(submission), - description: submission.content, - link: `https://twitter.com/user/status/${submission.tweetId}`, - pubDate: new Date(submission.createdAt).toUTCString(), - guid: submission.tweetId, - }; + async distribute(content: string): Promise { + if (!this.title || !this.path) { + throw new Error("RSS plugin not initialized"); + } - this.items.unshift(item); - if (this.config.maxItems) { - this.items = this.items.slice(0, this.config.maxItems); - } + const item = { + title: "New Update", + description: content, + link: "https://twitter.com/", // TODO: Update with actual link + pubDate: new Date().toUTCString(), + guid: Date.now().toString(), + }; - await this.updateFeed(); - console.info(`Exported submission ${submission.tweetId} to RSS`); - } catch (error) { - console.error("Failed to export submission to RSS:", error); - throw error; - } - } + this.items.unshift(item); + this.items = this.items.slice(0, this.maxItems); - private formatTitle(submission: TwitterSubmission): string { - const categories = submission.categories?.length - ? ` [${submission.categories.join(", ")}]` - : ""; - return `New Public Good by @${submission.username}${categories}`; + await this.updateFeed(); } private async updateFeed(): Promise { + if (!this.title || !this.path) return; + const feed = ` - ${this.config.title} - ${this.config.description} + ${this.title} https://twitter.com/ ${new Date().toUTCString()} ${this.items @@ -101,9 +95,9 @@ export class RssExportService implements ExportService { `; // Ensure directory exists - const dir = path.dirname(this.config.feedPath); + const dir = path.dirname(this.path); await mkdir(dir, { recursive: true }); - await writeFile(this.config.feedPath, feed, "utf-8"); + await writeFile(this.path, feed, "utf-8"); } private escapeXml(unsafe: string): string { diff --git a/backend/src/services/exports/external/telegram.ts b/backend/src/services/exports/external/telegram.ts index bc16481..63e7705 100644 --- a/backend/src/services/exports/external/telegram.ts +++ b/backend/src/services/exports/external/telegram.ts @@ -1,54 +1,46 @@ -import { ExportService, TelegramConfig } from "../types"; -import { TwitterSubmission } from "../../../types"; +import { DistributorPlugin } from "../types"; -export class TelegramExportService implements ExportService { +export class TelegramPlugin implements DistributorPlugin { name = "telegram"; - private botToken: string; - private channelId: string; + private botToken: string | null = null; + private channelId: string | null = null; - constructor(config: TelegramConfig) { - if (!config.enabled) { - throw new Error("Telegram export service is not enabled"); + async initialize(config: Record): Promise { + // Validate required config + if (!config.botToken || !config.channelId) { + throw new Error("Telegram plugin requires botToken and channelId"); } + this.botToken = config.botToken; this.channelId = config.channelId; - } - async initialize(): Promise { try { - // Validate bot token and channel ID by making a test API call + // Validate credentials const response = await fetch( `https://api.telegram.org/bot${this.botToken}/getChat?chat_id=${this.channelId}`, ); if (!response.ok) { throw new Error("Failed to validate Telegram credentials"); } - console.info("Telegram export service initialized"); + console.info("Telegram plugin initialized"); } catch (error) { - console.error("Failed to initialize Telegram export service:", error); + console.error("Failed to initialize Telegram plugin:", error); throw error; } } - async handleApprovedSubmission(submission: TwitterSubmission): Promise { - try { - const message = this.formatSubmission(submission); - await this.sendMessage(message); - console.info(`Exported submission ${submission.tweetId} to Telegram`); - } catch (error) { - console.error("Failed to export submission to Telegram:", error); - throw error; + async distribute(content: string): Promise { + if (!this.botToken || !this.channelId) { + throw new Error("Telegram plugin not initialized"); } - } - private formatSubmission(submission: TwitterSubmission): string { - const categories = submission.categories?.length - ? `\nCategories: ${submission.categories.join(", ")}` - : ""; + const message = this.formatMessage(content); + await this.sendMessage(message); + } - return `🆕 New Curation\n\n${submission.content}${categories}\n\nBy @${ - submission.username - }\nSource: https://twitter.com/user/status/${submission.tweetId}`; + private formatMessage(content: string): string { + // TODO + return content; } private async sendMessage(text: string): Promise { diff --git a/backend/src/services/exports/manager.ts b/backend/src/services/exports/manager.ts index 1d504a7..ec45718 100644 --- a/backend/src/services/exports/manager.ts +++ b/backend/src/services/exports/manager.ts @@ -1,64 +1,141 @@ -import { ExportService, ExportConfig } from "./types"; -import { TwitterSubmission } from "../../types"; +import { AppConfig, PluginConfig, PluginsConfig } from "../../types/config"; +import { Plugin, PluginModule } from "./types"; import { logger } from "../../utils/logger"; export class ExportManager { - private services: ExportService[] = []; - - async initialize(configs: ExportConfig[]): Promise { - for (const config of configs) { - if (!config.enabled) continue; + private plugins: Map = new Map(); + async initialize(config: PluginsConfig): Promise { + // Load all plugins + for (const [name, pluginConfig] of Object.entries(config)) { try { - // Simple relative import - works in both dev and prod since directory structure is preserved - const module = await import(`./external/${config.module}`); - const ServiceClass = module.default || Object.values(module)[0]; - const service = new ServiceClass(config); - await service.initialize(); - this.services.push(service); - logger.info(`Initialized ${service.name} export service`); + await this.loadPlugin(name, pluginConfig); } catch (error) { - logger.error( - `Failed to initialize ${config.type} export service:`, - error, - ); + logger.error(`Failed to load plugin ${name}:`, error); } } } - async handleApprovedSubmission(submission: TwitterSubmission): Promise { - const errors: Error[] = []; + private async loadPlugin(name: string, config: PluginConfig): Promise { + try { + // Dynamic import of plugin from URL + const module = await import(config.url) as PluginModule; + const plugin = new module.default(); + + // Store the plugin instance + this.plugins.set(name, plugin); + + logger.info(`Successfully loaded plugin: ${name}`); + } catch (error) { + logger.error(`Error loading plugin ${name}:`, error); + throw error; + } + } - await Promise.all( - this.services.map(async (service) => { - try { - await service.handleApprovedSubmission(submission); - } catch (error) { - errors.push(error as Error); - logger.error(`Export error in ${service.name}:`, error); - } - }), - ); + async transformContent(pluginName: string, content: string, config: { prompt: string }): Promise { + const plugin = this.plugins.get(pluginName); + if (!plugin || !('transform' in plugin)) { + throw new Error(`Transformer plugin ${pluginName} not found or invalid`); + } + + try { + await plugin.initialize(config); + return await plugin.transform(content); + } catch (error) { + logger.error(`Error transforming content with plugin ${pluginName}:`, error); + throw error; + } + } + + async distributeContent(pluginName: string, content: string, config: Record): Promise { + const plugin = this.plugins.get(pluginName); + if (!plugin || !('distribute' in plugin)) { + throw new Error(`Distributor plugin ${pluginName} not found or invalid`); + } - if (errors.length > 0) { - throw new Error( - `Export errors: ${errors.map((e) => e.message).join(", ")}`, + try { + await plugin.initialize(config); + await plugin.distribute(content); + } catch (error) { + logger.error(`Error distributing content with plugin ${pluginName}:`, error); + throw error; + } + } + + async processStreamOutput(feedId: string, content: string): Promise { + const config = await this.getConfig(); + const feed = config.feeds.find(f => f.id === feedId); + if (!feed?.outputs.stream?.enabled) { + return; + } + + const { transform, distribute } = feed.outputs.stream; + + // Transform content if configured + let processedContent = content; + if (transform) { + processedContent = await this.transformContent( + transform.plugin, + content, + transform.config + ); + } + + // Distribute to all configured outputs + for (const dist of distribute) { + await this.distributeContent( + dist.plugin, + processedContent, + dist.config ); } } + async processRecapOutput(feedId: string, content: string): Promise { + const config = await this.getConfig(); + const feed = config.feeds.find(f => f.id === feedId); + if (!feed?.outputs.recap?.enabled) { + return; + } + + const { transform, distribute } = feed.outputs.recap; + + // Transform content if configured + let processedContent = content; + if (transform) { + processedContent = await this.transformContent( + transform.plugin, + content, + transform.config + ); + } + + // Distribute to all configured outputs + for (const dist of distribute) { + await this.distributeContent( + dist.plugin, + processedContent, + dist.config + ); + } + } + + private async getConfig(): Promise { + const { ConfigService } = await import('../../services/config'); + return ConfigService.getInstance().getConfig(); + } + async shutdown(): Promise { - await Promise.all( - this.services.map(async (service) => { - try { - await service.shutdown?.(); - } catch (error) { - logger.error( - `Error shutting down ${service.name} export service:`, - error, - ); + // Shutdown all plugins + for (const [name, plugin] of this.plugins.entries()) { + try { + if (plugin.shutdown) { + await plugin.shutdown(); } - }), - ); + } catch (error) { + logger.error(`Error shutting down plugin ${name}:`, error); + } + } + this.plugins.clear(); } } diff --git a/backend/src/services/exports/types.ts b/backend/src/services/exports/types.ts index 7721998..9538134 100644 --- a/backend/src/services/exports/types.ts +++ b/backend/src/services/exports/types.ts @@ -1,30 +1,19 @@ -import { TwitterSubmission } from "../../types"; - -export interface BaseExportConfig { - enabled: boolean; - type: string; - module: string; // Module name (e.g., 'telegram', 'rss') -} - -export interface TelegramConfig extends BaseExportConfig { - type: "telegram"; - botToken: string; - channelId: string; +export interface DistributorPlugin { + name: string; + initialize(config: Record): Promise; + distribute(content: string): Promise; + shutdown?(): Promise; } -export interface RssConfig extends BaseExportConfig { - type: "rss"; - title: string; - description: string; - feedPath: string; - maxItems?: number; +export interface TransformerPlugin { + name: string; + initialize(config: Record): Promise; + transform(content: string): Promise; + shutdown?(): Promise; } -export type ExportConfig = TelegramConfig | RssConfig; +export type Plugin = DistributorPlugin | TransformerPlugin; -export interface ExportService { - name: string; - initialize(): Promise; - handleApprovedSubmission(submission: TwitterSubmission): Promise; - shutdown?(): Promise; +export interface PluginModule { + default: new () => Plugin; } diff --git a/backend/src/services/submissions/submission.service.ts b/backend/src/services/submissions/submission.service.ts new file mode 100644 index 0000000..ebcb702 --- /dev/null +++ b/backend/src/services/submissions/submission.service.ts @@ -0,0 +1,269 @@ +import { Tweet } from "agent-twitter-client"; +import { AppConfig } from "../../types/config"; +import { TwitterService } from "../twitter/client"; +import { ExportManager } from "../exports/manager"; +import { db } from "../db"; +import { logger } from "../../utils/logger"; +import { Moderation, TwitterSubmission } from "../../types/twitter"; +import { broadcastUpdate } from "../../index"; + +export class SubmissionService { + private checkInterval: NodeJS.Timer | null = null; + private lastCheckedTweetId: string | null = null; + private adminIdCache: Map = new Map(); + + constructor( + private readonly twitterService: TwitterService, + private readonly exportManager: ExportManager, + private readonly config: AppConfig + ) {} + + async initialize(): Promise { + // Initialize admin cache from config + for (const feed of this.config.feeds) { + for (const handle of feed.moderation.approvers.twitter) { + try { + const userId = await this.twitterService.getUserIdByScreenName(handle); + this.adminIdCache.set(userId, handle); + logger.info(`Cached admin ID for @${handle}: ${userId}`); + } catch (error) { + logger.error(`Failed to fetch ID for admin handle @${handle}:`, error); + } + } + } + + // Load last checked tweet ID + this.lastCheckedTweetId = await this.twitterService.getLastCheckedTweetId(); + broadcastUpdate({ type: "lastTweetId", data: this.lastCheckedTweetId }); + } + + async startMentionsCheck(): Promise { + logger.info("Starting submission monitoring..."); + + // Check mentions every minute + this.checkInterval = setInterval(async () => { + try { + logger.info("Checking mentions..."); + const newTweets = await this.twitterService.fetchAllNewMentions(this.lastCheckedTweetId); + + if (newTweets.length === 0) { + logger.info("No new mentions"); + } else { + logger.info(`Found ${newTweets.length} new mentions`); + + // Process new tweets + for (const tweet of newTweets) { + if (!tweet.id) continue; + + try { + if (this.isSubmission(tweet)) { + logger.info(`Received new submission: ${tweet.id}`); + await this.handleSubmission(tweet); + } else if (this.isModeration(tweet)) { + logger.info(`Received new moderation: ${tweet.id}`); + await this.handleModeration(tweet); + } + } catch (error) { + logger.error("Error processing tweet:", error); + } + } + + // Update the last checked tweet ID + const latestTweetId = newTweets[newTweets.length - 1].id; + if (latestTweetId) { + await this.setLastCheckedTweetId(latestTweetId); + } + } + } catch (error) { + logger.error("Error checking mentions:", error); + } + }, 60000); // Check every minute + } + + async stop(): Promise { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + private async handleSubmission(tweet: Tweet): Promise { + const userId = tweet.userId; + if (!userId || !tweet.id) return; + + const inReplyToId = tweet.inReplyToStatusId; + if (!inReplyToId) { + logger.error(`Submission tweet ${tweet.id} is not a reply to another tweet`); + return; + } + + try { + // Get daily submission count + const dailyCount = db.getDailySubmissionCount(userId); + const maxSubmissions = this.config.global.maxSubmissionsPerUser; + + if (dailyCount >= maxSubmissions) { + await this.twitterService.replyToTweet( + tweet.id, + "You've reached your daily submission limit. Please try again tomorrow." + ); + logger.info(`User ${userId} has reached limit, replied to submission.`); + return; + } + + // Fetch original tweet + const originalTweet = await this.twitterService.getTweet(inReplyToId); + if (!originalTweet) { + logger.error(`Could not fetch original tweet ${inReplyToId}`); + return; + } + + // Create submission + const submission: TwitterSubmission = { + tweetId: originalTweet.id!, + userId: originalTweet.userId!, + username: originalTweet.username!, + content: originalTweet.text || "", + categories: tweet.hashtags || [], + description: this.extractDescription(tweet), + status: this.config.global.defaultStatus as "pending" | "approved" | "rejected", + moderationHistory: [], + createdAt: originalTweet.timeParsed?.toISOString() || new Date().toISOString(), + submittedAt: new Date().toISOString(), + }; + + // Save submission + db.saveSubmission(submission); + db.incrementDailySubmissionCount(userId); + + // Send acknowledgment + const acknowledgmentTweetId = await this.twitterService.replyToTweet( + tweet.id, + "Successfully submitted to publicgoods.news!" + ); + + if (acknowledgmentTweetId) { + db.updateSubmissionAcknowledgment(originalTweet.id!, acknowledgmentTweetId); + logger.info(`Successfully submitted. Sent reply: ${acknowledgmentTweetId}`); + } + } catch (error) { + logger.error(`Error handling submission for tweet ${tweet.id}:`, error); + } + } + + private async handleModeration(tweet: Tweet): Promise { + const userId = tweet.userId; + if (!userId || !tweet.id) return; + + if (!this.isAdmin(userId)) { + logger.info(`User ${userId} is not admin.`); + return; + } + + const inReplyToId = tweet.inReplyToStatusId; + if (!inReplyToId) return; + + const submission = db.getSubmissionByAcknowledgmentTweetId(inReplyToId); + if (!submission || submission.status !== "pending") return; + + const action = this.getModerationAction(tweet); + if (!action) return; + + const adminUsername = this.adminIdCache.get(userId); + if (!adminUsername) { + logger.error(`Could not find username for admin ID ${userId}`); + return; + } + + // Create moderation record + const moderation: Moderation = { + adminId: adminUsername, + action, + timestamp: tweet.timeParsed || new Date(), + tweetId: submission.tweetId, + categories: tweet.hashtags, + note: this.extractNote(tweet), + }; + + db.saveModerationAction(moderation); + + // Process based on action + if (action === "approve") { + await this.processApproval(tweet, submission); + } else { + await this.processRejection(tweet, submission); + } + } + + private async processApproval(tweet: Tweet, submission: TwitterSubmission): Promise { + const responseTweetId = await this.twitterService.replyToTweet( + tweet.id!, + "Your submission has been approved and will be added to the public goods news feed!" + ); + + if (responseTweetId) { + db.updateSubmissionStatus(submission.tweetId, "approved", responseTweetId); + + // Process through export manager + try { + const feed = this.config.feeds.find(f => + f.moderation.approvers.twitter.includes(this.adminIdCache.get(tweet.userId!) || '') + ); + + if (feed) { + await this.exportManager.processStreamOutput(feed.id, submission.content); + } + } catch (error) { + logger.error("Failed to process approved submission:", error); + } + } + } + + private async processRejection(tweet: Tweet, submission: TwitterSubmission): Promise { + const responseTweetId = await this.twitterService.replyToTweet( + tweet.id!, + "Your submission has been reviewed and was not accepted for the public goods news feed." + ); + + if (responseTweetId) { + db.updateSubmissionStatus(submission.tweetId, "rejected", responseTweetId); + } + } + + private isAdmin(userId: string): boolean { + return this.adminIdCache.has(userId); + } + + private getModerationAction(tweet: Tweet): "approve" | "reject" | null { + const hashtags = tweet.hashtags?.map(tag => tag.toLowerCase()) || []; + if (hashtags.includes("approve")) return "approve"; + if (hashtags.includes("reject")) return "reject"; + return null; + } + + private isModeration(tweet: Tweet): boolean { + return this.getModerationAction(tweet) !== null; + } + + private isSubmission(tweet: Tweet): boolean { + return tweet.text?.toLowerCase().includes("!submit") || false; + } + + private extractDescription(tweet: Tweet): string | undefined { + return tweet.text + ?.replace(/!submit\s+@\w+/i, "") + .replace(/#\w+/g, "") + .trim() || undefined; + } + + private extractNote(tweet: Tweet): string | undefined { + return tweet.text + ?.replace(/#\w+/g, "") + .trim() || undefined; + } + + private async setLastCheckedTweetId(tweetId: string) { + this.lastCheckedTweetId = tweetId; + await this.twitterService.setLastCheckedTweetId(tweetId); + } +} diff --git a/backend/src/services/twitter/client.ts b/backend/src/services/twitter/client.ts index d0bb054..2016487 100644 --- a/backend/src/services/twitter/client.ts +++ b/backend/src/services/twitter/client.ts @@ -1,12 +1,5 @@ import { Scraper, SearchMode, Tweet } from "agent-twitter-client"; -import { ADMIN_ACCOUNTS } from "config/admins"; -import { broadcastUpdate } from "index"; -import { - Moderation, - TwitterConfig, - TwitterSubmission, -} from "../../types/twitter"; -import { ExportManager } from "../exports/manager"; +import { logger } from "../../utils/logger"; import { TwitterCookie, cacheCookies, @@ -15,27 +8,21 @@ import { getLastCheckedTweetId, saveLastCheckedTweetId, } from "../../utils/cache"; -import { logger } from "../../utils/logger"; -import { db } from "../db"; export class TwitterService { private client: Scraper; - private readonly DAILY_SUBMISSION_LIMIT = 10; - private twitterUsername: string; - private config: TwitterConfig; - private isInitialized = false; - private checkInterval: NodeJS.Timer | null = null; private lastCheckedTweetId: string | null = null; - private configuredTweetId: string | null = null; - private adminIdCache: Map = new Map(); + private twitterUsername: string; constructor( - config: TwitterConfig, - private readonly exportManager?: ExportManager, + private readonly config: { + username: string; + password: string; + email: string; + } ) { this.client = new Scraper(); this.twitterUsername = config.username; - this.config = config; } private async setCookiesFromArray(cookiesArray: TwitterCookie[]) { @@ -50,22 +37,6 @@ export class TwitterService { await this.client.setCookies(cookieStrings); } - private async initializeAdminIds() { - for (const handle of ADMIN_ACCOUNTS) { - try { - const userId = await this.client.getUserIdByScreenName(handle); - this.adminIdCache.set(userId, handle); - logger.info(`Cached admin ID for @${handle}: ${userId}`); - } catch (error) { - logger.error(`Failed to fetch ID for admin handle @${handle}:`, error); - } - } - } - - private isAdmin(userId: string): boolean { - return this.adminIdCache.has(userId); - } - async initialize() { try { // Ensure cache directory exists @@ -77,13 +48,8 @@ export class TwitterService { await this.setCookiesFromArray(cachedCookies); } - // Load last checked tweet ID from cache if no configured ID exists - if (!this.configuredTweetId) { - this.lastCheckedTweetId = await getLastCheckedTweetId(); - broadcastUpdate({ type: "lastTweetId", data: this.lastCheckedTweetId }); - } else { - this.lastCheckedTweetId = this.configuredTweetId; - } + // Load last checked tweet ID from cache + this.lastCheckedTweetId = await getLastCheckedTweetId(); // Try to login with retries logger.info("Attempting Twitter login..."); @@ -109,10 +75,6 @@ export class TwitterService { await new Promise((resolve) => setTimeout(resolve, 2000)); } - // Initialize admin IDs after successful login (convert from handle to account id) - await this.initializeAdminIds(); - - this.isInitialized = true; logger.info("Successfully logged in to Twitter"); } catch (error) { logger.error("Failed to initialize Twitter client:", error); @@ -120,7 +82,29 @@ export class TwitterService { } } - private async fetchAllNewMentions(): Promise { + async getUserIdByScreenName(screenName: string): Promise { + return await this.client.getUserIdByScreenName(screenName); + } + + async getTweet(tweetId: string): Promise { + return await this.client.getTweet(tweetId); + } + + async replyToTweet(tweetId: string, message: string): Promise { + try { + const response = await this.client.sendTweet(message, tweetId); + const responseData = (await response.json()) as any; + // Extract tweet ID from response + const replyTweetId = + responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; + return replyTweetId || null; + } catch (error) { + logger.error("Error replying to tweet:", error); + return null; + } + } + + async fetchAllNewMentions(lastCheckedId: string | null): Promise { const BATCH_SIZE = 20; let allNewTweets: Tweet[] = []; let foundOldTweet = false; @@ -140,14 +124,12 @@ export class TwitterService { ) ).tweets; - if (batch.length === 0) break; // No more tweets to fetch + if (batch.length === 0) break; - // Check if any tweet in this batch is older than or equal to our last checked ID for (const tweet of batch) { if (!tweet.id) continue; - const referenceId = this.configuredTweetId || this.lastCheckedTweetId; - if (!referenceId || BigInt(tweet.id) > BigInt(referenceId)) { + if (!lastCheckedId || BigInt(tweet.id) > BigInt(lastCheckedId)) { allNewTweets.push(tweet); } else { foundOldTweet = true; @@ -155,7 +137,7 @@ export class TwitterService { } } - if (batch.length < BATCH_SIZE) break; // Last batch was partial, no more to fetch + if (batch.length < BATCH_SIZE) break; attempts++; } catch (error) { logger.error("Error fetching mentions batch:", error); @@ -163,7 +145,6 @@ export class TwitterService { } } - // Sort all fetched tweets by ID (chronologically) return allNewTweets.sort((a, b) => { const aId = BigInt(a.id || "0"); const bId = BigInt(b.id || "0"); @@ -171,328 +152,17 @@ export class TwitterService { }); } - async startMentionsCheck() { - logger.info("Listening for mentions..."); - - // Check mentions every minute - this.checkInterval = setInterval(async () => { - if (!this.isInitialized) return; - - try { - logger.info("Checking mentions..."); - - const newTweets = await this.fetchAllNewMentions(); - - if (newTweets.length === 0) { - logger.info("No new mentions"); - } else { - logger.info(`Found ${newTweets.length} new mentions`); - - // Process new tweets - for (const tweet of newTweets) { - if (!tweet.id) continue; - - try { - if (this.isSubmission(tweet)) { - logger.info( - `Received new submission: ${this.getTweetLink(tweet.id, tweet.username)}`, - ); - await this.handleSubmission(tweet); - } else if (this.isModeration(tweet)) { - logger.info( - `Received new moderation: ${this.getTweetLink(tweet.id, tweet.username)}`, - ); - await this.handleModeration(tweet); - } - } catch (error) { - logger.error("Error processing tweet:", error); - } - } - - // Update the last checked tweet ID to the most recent one - const latestTweetId = newTweets[newTweets.length - 1].id; - if (latestTweetId) { - await this.setLastCheckedTweetId(latestTweetId); - } - } - } catch (error) { - logger.error("Error checking mentions:", error); - } - }, 60000); // Check every minute - } - - async stop() { - if (this.checkInterval) { - clearInterval(this.checkInterval); - this.checkInterval = null; - } - await this.client.logout(); - this.isInitialized = false; - } - - private async handleSubmission(tweet: Tweet): Promise { - const userId = tweet.userId; - if (!userId || !tweet.id) return; - - // Get the tweet being replied to - const inReplyToId = tweet.inReplyToStatusId; - if (!inReplyToId) { - logger.error( - `Submission tweet ${tweet.id} is not a reply to another tweet`, - ); - return; - } - - try { - // Fetch the original tweet that's being submitted - const originalTweet = await this.client.getTweet(inReplyToId); - if (!originalTweet) { - logger.error(`Could not fetch original tweet ${inReplyToId}`); - return; - } - - // Get submission count from database - const dailyCount = db.getDailySubmissionCount(userId); - - if (dailyCount >= this.DAILY_SUBMISSION_LIMIT) { - await this.replyToTweet( - tweet.id, - "You've reached your daily submission limit. Please try again tomorrow.", - ); - logger.info(`User ${userId} has reached limit, replied to submission.`); - return; - } - - // Extract curator handle from submission tweet - const submissionMatch = tweet.text?.match(/!submit\s+@(\w+)/i); - if (!submissionMatch) { - logger.error(`Invalid submission format in tweet ${tweet.id}`); - return; - } - - // Extract categories from hashtags in submission tweet (excluding command hashtags) - const categories = (tweet.hashtags || []).filter( - (tag) => !["submit", "approve", "reject"].includes(tag.toLowerCase()), - ); - - // Extract description: everything after !submit @handle that's not a hashtag - const description = - tweet.text - ?.replace(/!submit\s+@\w+/i, "") // Remove command - .replace(/#\w+/g, "") // Remove hashtags - .trim() || undefined; - - // Create submission using the original tweet's content and submission metadata - const submission: TwitterSubmission = { - tweetId: originalTweet.id!, // The tweet being submitted - userId: originalTweet.userId!, - username: originalTweet.username!, - content: originalTweet.text || "", - categories: categories, - description: description || undefined, - status: "pending", - moderationHistory: [], - createdAt: - originalTweet.timeParsed?.toISOString() || new Date().toISOString(), - submittedAt: new Date().toISOString(), - }; - - // Save submission to database - db.saveSubmission(submission); - // Increment submission count in database - db.incrementDailySubmissionCount(userId); - - // Send acknowledgment and save its ID - const acknowledgmentTweetId = await this.replyToTweet( - tweet.id, // Reply to the submission tweet - "Successfully submitted to publicgoods.news!", - ); - - if (acknowledgmentTweetId) { - db.updateSubmissionAcknowledgment( - originalTweet.id!, - acknowledgmentTweetId, - ); - logger.info( - `Successfully submitted. Sent reply: ${this.getTweetLink(acknowledgmentTweetId)}`, - ); - } else { - logger.error( - `Failed to acknowledge submission: ${this.getTweetLink(tweet.id, tweet.username)}`, - ); - } - } catch (error) { - logger.error(`Error handling submission for tweet ${tweet.id}:`, error); - } - } - - private async handleModeration(tweet: Tweet): Promise { - const userId = tweet.userId; - if (!userId || !tweet.id) return; - - // Verify admin status using cached ID - if (!this.isAdmin(userId)) { - logger.info(`User ${userId} is not admin.`); - return; // Silently ignore non-admin moderation attempts - } - - // Get the tweet this is in response to (should be our acknowledgment tweet) - const inReplyToId = tweet.inReplyToStatusId; - if (!inReplyToId) return; - - // Get submission by acknowledgment tweet ID - const submission = db.getSubmissionByAcknowledgmentTweetId(inReplyToId); - if (!submission) return; - - // Check if submission has already been moderated by any admin - if (submission.status !== "pending") { - logger.info( - `Submission ${submission.tweetId} has already been moderated, ignoring new moderation attempt.`, - ); - return; - } - - const action = this.getModerationAction(tweet); - if (!action) return; - - // Add moderation to database - const adminUsername = this.adminIdCache.get(userId); - if (!adminUsername) { - logger.error(`Could not find username for admin ID ${userId}`); - return; - } - - // Extract categories from hashtags in moderation tweet (excluding command hashtags) - const categories = (tweet.hashtags || []).filter( - (tag) => !["submit", "approve", "reject"].includes(tag.toLowerCase()), - ); - - // Extract note: everything in the tweet that's not a hashtag - const note = - tweet.text - ?.replace(/#\w+/g, "") // Remove hashtags - .trim() || undefined; - - const moderation: Moderation = { - adminId: adminUsername, - action: action, - timestamp: tweet.timeParsed || new Date(), - tweetId: submission.tweetId, // Use the original submission's tweetId - categories: categories.length > 0 ? categories : undefined, - note: note, - }; - db.saveModerationAction(moderation); - - // Process the moderation action - if (action === "approve") { - logger.info( - `Received review from Admin ${this.adminIdCache.get(userId)}, processing approval.`, - ); - await this.processApproval(tweet, submission); - } else { - logger.info( - `Received review from Admin ${this.adminIdCache.get(userId)}, processing rejection.`, - ); - await this.processRejection(tweet, submission); - } - } - - private async processApproval( - tweet: Tweet, - submission: TwitterSubmission, - ): Promise { - // TODO: Add NEAR integration here for approved submissions - const responseTweetId = await this.replyToTweet( - tweet.id!, - "Your submission has been approved and will be added to the public goods news feed!", - ); - if (responseTweetId) { - db.updateSubmissionStatus( - submission.tweetId, - "approved", - responseTweetId, - ); - - // Handle exports for approved submission - if (this.exportManager) { - try { - await this.exportManager.handleApprovedSubmission(submission); - } catch (error) { - logger.error( - "Failed to handle exports for approved submission:", - error, - ); - } - } - } - } - - private async processRejection( - tweet: Tweet, - submission: TwitterSubmission, - ): Promise { - // TODO: Add NEAR integration here for rejected submissions - const responseTweetId = await this.replyToTweet( - tweet.id!, - "Your submission has been reviewed and was not accepted for the public goods news feed.", - ); - if (responseTweetId) { - db.updateSubmissionStatus( - submission.tweetId, - "rejected", - responseTweetId, - ); - } - } - - private getModerationAction(tweet: Tweet): "approve" | "reject" | null { - const hashtags = tweet.hashtags?.map((tag) => tag.toLowerCase()) || []; - if (hashtags.includes("approve")) return "approve"; - if (hashtags.includes("reject")) return "reject"; - return null; - } - - private isModeration(tweet: Tweet): boolean { - return this.getModerationAction(tweet) !== null; - } - - private isSubmission(tweet: Tweet): boolean { - return tweet.text?.toLowerCase().includes("!submit") || false; - } - - private async replyToTweet( - tweetId: string, - message: string, - ): Promise { - try { - const response = await this.client.sendTweet(message, tweetId); - const responseData = (await response.json()) as any; - // Extract tweet ID from response - const replyTweetId = - responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; - return replyTweetId || null; - } catch (error) { - logger.error("Error replying to tweet:", error); - return null; - } - } - async setLastCheckedTweetId(tweetId: string) { - this.configuredTweetId = tweetId; this.lastCheckedTweetId = tweetId; await saveLastCheckedTweetId(tweetId); - logger.info(`Last checked tweet ID configured to: ${tweetId}`); - broadcastUpdate({ type: "lastTweetId", data: tweetId }); + logger.info(`Last checked tweet ID updated to: ${tweetId}`); } getLastCheckedTweetId(): string | null { return this.lastCheckedTweetId; } - private getTweetLink( - tweetId: string, - username: string = this.twitterUsername, - ): string { - return `https://x.com/${username}/status/${tweetId}`; + async stop() { + await this.client.logout(); } } diff --git a/backend/src/types/config.ts b/backend/src/types/config.ts new file mode 100644 index 0000000..86fd920 --- /dev/null +++ b/backend/src/types/config.ts @@ -0,0 +1,58 @@ +export interface GlobalConfig { + defaultStatus: string; + maxSubmissionsPerUser: number; +} + +export interface PluginConfig { + type: 'distributor' | 'transformer'; + url: string; +} + +export interface ModerationConfig { + approvers: { + twitter: string[]; + }; + templates: { + approve: string; + reject: string; + acknowledge: string; + }; +} + +export interface TransformConfig { + plugin: string; + config: { + prompt: string; + }; +} + +export interface DistributorConfig { + plugin: string; + config: Record; +} + +export interface OutputConfig { + enabled: boolean; + schedule?: string; + transform?: TransformConfig; + distribute: DistributorConfig[]; +} + +export type PluginsConfig = Record; + +export interface FeedConfig { + id: string; + name: string; + description: string; + moderation: ModerationConfig; + outputs: { + stream?: OutputConfig; + recap?: OutputConfig; + }; +} + +export interface AppConfig { + global: GlobalConfig; + plugins: PluginsConfig; + feeds: FeedConfig[]; +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts deleted file mode 100644 index bf18ab9..0000000 --- a/backend/src/types/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./twitter"; -export * from "../services/exports/types"; - -export interface AppConfig { - twitter: import("./twitter").TwitterConfig; - environment: "development" | "production" | "test"; - exports: import("../services/exports/types").ExportConfig[]; -} diff --git a/backend/src/utils/config.ts b/backend/src/utils/config.ts new file mode 100644 index 0000000..d2a1f3d --- /dev/null +++ b/backend/src/utils/config.ts @@ -0,0 +1,37 @@ +/** + * Recursively processes a config object, replacing environment variable placeholders + * with their actual values. + * + * Format: "{ENV_VAR_NAME}" will be replaced with process.env.ENV_VAR_NAME + */ +export function hydrateConfigValues>(config: T): T { + const processValue = (value: any): any => { + if (typeof value === 'string') { + // Match strings like "{SOME_ENV_VAR}" + const match = value.match(/^\{([A-Z_][A-Z0-9_]*)\}$/); + if (match) { + const envVar = match[1]; + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Required environment variable ${envVar} is not set`); + } + return envValue; + } + return value; + } + + if (Array.isArray(value)) { + return value.map(item => processValue(item)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, processValue(v)]) + ); + } + + return value; + }; + + return processValue(config); +} diff --git a/curate.config.json b/curate.config.json new file mode 100644 index 0000000..530acbe --- /dev/null +++ b/curate.config.json @@ -0,0 +1,205 @@ +{ + "global": { + "defaultStatus": "pending", + "maxSubmissionsPerUser": 5 + }, + "plugins": { + "@curatedotfun/telegram": { + "type": "distributor", + "url": "./external/telegram" + }, + "@curatedotfun/rss": { + "type": "distributor", + "url": "./external/rss" + }, + "@curatedotfun/gpt-transform": { + "type": "transformer", + "url": "./external/gpt-transofrm" + } + }, + "feeds": [ + { + "id": "grants", + "name": "Crypto Grant Wire", + "description": "Blockchain grant updates", + "moderation": { + "approvers": { + "twitter": ["plugrel", "sejal_rekhan", "arlery", "karmahq_"] + }, + "templates": { + "approve": "Approved grant update: {content}", + "reject": "Rejected grant update: {reason}", + "acknowledge": "" + } + }, + "outputs": { + "stream": { + "enabled": true, + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "Format this grant announcement..." + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_BOT_TOKEN}", + "channelId": "{TELEGRAM_CHANNEL_ID}" + } + }, + { + "plugin": "@curatedotfun/rss", + "config": { + "title": "Crypto Grant Wire", + "path": "./public/grants.xml" + } + } + ] + }, + "recap": { + "enabled": true, + "schedule": "0 0 * * *", + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "./prompts/grants_recap.txt" + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_RECAP_BOT_TOKEN}", + "channelId": "{TELEGRAM_RECAP_CHANNEL_ID}" + } + } + ] + } + } + }, + { + "id": "ethereum", + "name": "This Week in Ethereum", + "description": "Ethereum ecosystem updates", + "moderation": { + "approvers": { + "twitter": ["owoki"] + }, + "templates": { + "approve": "Approved Ethereum update: {content}", + "reject": "Rejected Ethereum update: {reason}", + "acknowledge": "" + } + }, + "outputs": { + "stream": { + "enabled": true, + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "Format this Ethereum update..." + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_BOT_TOKEN}", + "channelId": "{TELEGRAM_CHANNEL_ID}" + } + }, + { + "plugin": "@curatedotfun/rss", + "config": { + "title": "This Week in Ethereum", + "path": "./public/ethereum.xml" + } + } + ] + }, + "recap": { + "enabled": true, + "schedule": "0 0 * * 0", + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "./prompts/ethereum_weekly.txt" + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_RECAP_BOT_TOKEN}", + "channelId": "{TELEGRAM_RECAP_CHANNEL_ID}" + } + } + ] + } + } + }, + { + "id": "near", + "name": "NEARWEEK", + "description": "NEAR Protocol updates", + "moderation": { + "approvers": { + "twitter": ["peter", "plugrel", "jarednotjerry1"] + }, + "templates": { + "approve": "Approved NEAR update: {content}", + "reject": "Rejected NEAR update: {reason}", + "acknowledge": "" + } + }, + "outputs": { + "stream": { + "enabled": true, + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "Format this NEAR update..." + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_BOT_TOKEN}", + "channelId": "{TELEGRAM_CHANNEL_ID}" + } + }, + { + "plugin": "@curatedotfun/rss", + "config": { + "title": "NEARWEEK", + "path": "./public/near.xml" + } + } + ] + }, + "recap": { + "enabled": true, + "schedule": "0 0 * * 0", + "transform": { + "plugin": "@curatedotfun/gpt-transform", + "config": { + "prompt": "./prompts/near_weekly.txt" + } + }, + "distribute": [ + { + "plugin": "@curatedotfun/telegram", + "config": { + "botToken": "{TELEGRAM_RECAP_BOT_TOKEN}", + "channelId": "{TELEGRAM_RECAP_CHANNEL_ID}" + } + } + ] + } + } + } + ] +} From 223a56e7fd158f19960e9a013afc2cf9f867acac Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Mon, 13 Jan 2025 22:07:21 -0700 Subject: [PATCH 2/3] rename exports to distribution --- backend/src/index.ts | 22 +++++++++---------- backend/src/services/db/index.ts | 2 +- backend/src/services/db/queries.ts | 2 +- .../distribution.service.ts} | 4 ++-- .../{exports => distribution}/external/rss.ts | 0 .../external/telegram.ts | 0 .../{exports => distribution}/types.ts | 0 .../submissions/submission.service.ts | 6 ++--- 8 files changed, 18 insertions(+), 18 deletions(-) rename backend/src/services/{exports/manager.ts => distribution/distribution.service.ts} (97%) rename backend/src/services/{exports => distribution}/external/rss.ts (100%) rename backend/src/services/{exports => distribution}/external/telegram.ts (100%) rename backend/src/services/{exports => distribution}/types.ts (100%) diff --git a/backend/src/index.ts b/backend/src/index.ts index fe92f95..443bc1f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,11 +1,11 @@ import { ServerWebSocket } from "bun"; import dotenv from "dotenv"; import path from "path"; +import { DistributionService } from "services/distribution/distribution.service"; import configService, { validateEnv } from "./config/config"; import { db } from "./services/db"; -import { TwitterService } from "./services/twitter/client"; -import { ExportManager } from "./services/exports/manager"; import { SubmissionService } from "./services/submissions/submission.service"; +import { TwitterService } from "./services/twitter/client"; import { cleanup, failSpinner, @@ -166,18 +166,18 @@ export async function main() { await twitterService.initialize(); succeedSpinner("twitter-init", "Twitter service initialized"); - // Initialize export service - startSpinner("export-init", "Initializing export service..."); - const exportManager = new ExportManager(); + // Initialize distribution service + startSpinner("distribution-init", "Initializing distribution service..."); + const distributionService = new DistributionService(); const config = configService.getConfig(); - await exportManager.initialize(config.plugins); - succeedSpinner("export-init", "Export service initialized"); + await distributionService.initialize(config.plugins); + succeedSpinner("distribution-init", "distribution service initialized"); // Initialize submission service startSpinner("submission-init", "Initializing submission service..."); const submissionService = new SubmissionService( twitterService, - exportManager, + distributionService, config ); await submissionService.initialize(); @@ -190,7 +190,7 @@ export async function main() { await Promise.all([ twitterService.stop(), submissionService.stop(), - exportManager.shutdown() + distributionService.shutdown() ]); succeedSpinner("shutdown", "Shutdown complete"); process.exit(0); @@ -204,7 +204,7 @@ export async function main() { logger.info("🚀 Bot is running and ready for events", { twitterEnabled: true, websocketEnabled: true, - exportsEnabled: Object.keys(config.plugins).length > 0, + distributionsEnabled: Object.keys(config.plugins).length > 0, }); // Start checking for mentions @@ -216,7 +216,7 @@ export async function main() { [ "env", "twitter-init", - "export-init", + "distribution-init", "twitter-mentions", "server", ].forEach((key) => { diff --git a/backend/src/services/db/index.ts b/backend/src/services/db/index.ts index 26ea24c..0840642 100644 --- a/backend/src/services/db/index.ts +++ b/backend/src/services/db/index.ts @@ -1,8 +1,8 @@ import { Database } from "bun:sqlite"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import { join } from "node:path"; +import { Moderation, TwitterSubmission } from "types/twitter"; import { broadcastUpdate } from "../../index"; -import { Moderation, TwitterSubmission } from "../../types"; import * as queries from "./queries"; export class DatabaseService { diff --git a/backend/src/services/db/queries.ts b/backend/src/services/db/queries.ts index 548cdec..9aff87c 100644 --- a/backend/src/services/db/queries.ts +++ b/backend/src/services/db/queries.ts @@ -1,7 +1,7 @@ import { and, eq, sql } from "drizzle-orm"; import { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import { moderationHistory, submissionCounts, submissions } from "./schema"; -import { Moderation, TwitterSubmission } from "../../types"; +import { Moderation, TwitterSubmission } from "types/twitter"; export function saveSubmission( db: BunSQLiteDatabase, diff --git a/backend/src/services/exports/manager.ts b/backend/src/services/distribution/distribution.service.ts similarity index 97% rename from backend/src/services/exports/manager.ts rename to backend/src/services/distribution/distribution.service.ts index ec45718..63e0bb4 100644 --- a/backend/src/services/exports/manager.ts +++ b/backend/src/services/distribution/distribution.service.ts @@ -2,7 +2,7 @@ import { AppConfig, PluginConfig, PluginsConfig } from "../../types/config"; import { Plugin, PluginModule } from "./types"; import { logger } from "../../utils/logger"; -export class ExportManager { +export class DistributionService { private plugins: Map = new Map(); async initialize(config: PluginsConfig): Promise { @@ -121,7 +121,7 @@ export class ExportManager { } private async getConfig(): Promise { - const { ConfigService } = await import('../../services/config'); + const { ConfigService } = await import('../config'); return ConfigService.getInstance().getConfig(); } diff --git a/backend/src/services/exports/external/rss.ts b/backend/src/services/distribution/external/rss.ts similarity index 100% rename from backend/src/services/exports/external/rss.ts rename to backend/src/services/distribution/external/rss.ts diff --git a/backend/src/services/exports/external/telegram.ts b/backend/src/services/distribution/external/telegram.ts similarity index 100% rename from backend/src/services/exports/external/telegram.ts rename to backend/src/services/distribution/external/telegram.ts diff --git a/backend/src/services/exports/types.ts b/backend/src/services/distribution/types.ts similarity index 100% rename from backend/src/services/exports/types.ts rename to backend/src/services/distribution/types.ts diff --git a/backend/src/services/submissions/submission.service.ts b/backend/src/services/submissions/submission.service.ts index ebcb702..3d82480 100644 --- a/backend/src/services/submissions/submission.service.ts +++ b/backend/src/services/submissions/submission.service.ts @@ -1,7 +1,7 @@ +import { DistributionService } from './../distribution/distribution.service'; import { Tweet } from "agent-twitter-client"; import { AppConfig } from "../../types/config"; import { TwitterService } from "../twitter/client"; -import { ExportManager } from "../exports/manager"; import { db } from "../db"; import { logger } from "../../utils/logger"; import { Moderation, TwitterSubmission } from "../../types/twitter"; @@ -14,7 +14,7 @@ export class SubmissionService { constructor( private readonly twitterService: TwitterService, - private readonly exportManager: ExportManager, + private readonly DistributionService: DistributionService, private readonly config: AppConfig ) {} @@ -211,7 +211,7 @@ export class SubmissionService { ); if (feed) { - await this.exportManager.processStreamOutput(feed.id, submission.content); + await this.DistributionService.processStreamOutput(feed.id, submission.content); } } catch (error) { logger.error("Failed to process approved submission:", error); From f21301d097db748fe3ea08b00c55c5a9f9de7209 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Mon, 13 Jan 2025 22:38:04 -0700 Subject: [PATCH 3/3] add gpt transform --- backend/package.json | 2 +- backend/src/external/gpt-transform.ts | 76 ++++++++++ .../distribution => }/external/rss.ts | 2 +- .../distribution => }/external/telegram.ts | 2 +- .../distribution/distribution.service.ts | 2 +- .../transformers/transformation.service.ts | 141 ++++++++++++++++++ .../distribution/types.ts => types/plugin.ts} | 0 bun.lockb | Bin 711504 -> 705016 bytes curate.config.json | 2 +- 9 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 backend/src/external/gpt-transform.ts rename backend/src/{services/distribution => }/external/rss.ts (98%) rename backend/src/{services/distribution => }/external/telegram.ts (97%) create mode 100644 backend/src/services/transformers/transformation.service.ts rename backend/src/{services/distribution/types.ts => types/plugin.ts} (100%) diff --git a/backend/package.json b/backend/package.json index 4434250..1f84de9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "packageManager": "bun@1.0.27", "type": "module", "scripts": { - "build": "bun build ./src/index.ts --target=bun --outdir=dist --format=esm --external './src/services/exports/external' && cp -r src/services/exports/external dist/external/", + "build": "bun build ./src/index.ts --target=bun --outdir=dist --format=esm --external './src/external' && cp -r src/external dist/external/", "start": "bun run dist/index.js", "dev": "bun run --watch src/index.ts", "test": "jest", diff --git a/backend/src/external/gpt-transform.ts b/backend/src/external/gpt-transform.ts new file mode 100644 index 0000000..ed25e60 --- /dev/null +++ b/backend/src/external/gpt-transform.ts @@ -0,0 +1,76 @@ +import { TransformerPlugin } from '../types/plugin'; + +interface Message { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface OpenRouterResponse { + choices: { + message: { + content: string; + }; + }[]; +} + +export default class GPTTransformer implements TransformerPlugin { + name = 'gpt-transform'; + private prompt: string = ''; + private apiKey: string = ''; + + async initialize(config: Record): Promise { + if (!config.prompt) { + throw new Error('GPT transformer requires a prompt configuration'); + } + if (!config.apiKey) { + throw new Error('GPT transformer requires an OpenRouter API key'); + } + this.prompt = config.prompt; + this.apiKey = config.apiKey; + } + + async transform(content: string): Promise { + try { + const messages: Message[] = [ + { role: 'system', content: this.prompt }, + { role: 'user', content } + ]; + + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'HTTP-Referer': 'https://curate.fun', + 'X-Title': 'CurateDotFun' + }, + body: JSON.stringify({ + model: 'openai/gpt-3.5-turbo', // Default to GPT-3.5-turbo for cost efficiency + messages, + temperature: 0.7, + max_tokens: 1000 + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenRouter API error: ${error}`); + } + + const result = await response.json() as OpenRouterResponse; + + if (!result.choices?.[0]?.message?.content) { + throw new Error('Invalid response from OpenRouter API'); + } + + return result.choices[0].message.content; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`GPT transformation failed: ${errorMessage}`); + } + } + + async shutdown(): Promise { + // Cleanup any resources if needed + } +} diff --git a/backend/src/services/distribution/external/rss.ts b/backend/src/external/rss.ts similarity index 98% rename from backend/src/services/distribution/external/rss.ts rename to backend/src/external/rss.ts index 2e80159..1fe8772 100644 --- a/backend/src/services/distribution/external/rss.ts +++ b/backend/src/external/rss.ts @@ -1,7 +1,7 @@ -import { DistributorPlugin } from "../types"; import { writeFile, readFile, mkdir } from "fs/promises"; import { existsSync } from "fs"; import path from "path"; +import { DistributorPlugin } from "types/plugin"; export class RssPlugin implements DistributorPlugin { name = "rss"; diff --git a/backend/src/services/distribution/external/telegram.ts b/backend/src/external/telegram.ts similarity index 97% rename from backend/src/services/distribution/external/telegram.ts rename to backend/src/external/telegram.ts index 63e7705..eb9ebab 100644 --- a/backend/src/services/distribution/external/telegram.ts +++ b/backend/src/external/telegram.ts @@ -1,4 +1,4 @@ -import { DistributorPlugin } from "../types"; +import { DistributorPlugin } from "../types/plugin"; export class TelegramPlugin implements DistributorPlugin { name = "telegram"; diff --git a/backend/src/services/distribution/distribution.service.ts b/backend/src/services/distribution/distribution.service.ts index 63e0bb4..f2c59b2 100644 --- a/backend/src/services/distribution/distribution.service.ts +++ b/backend/src/services/distribution/distribution.service.ts @@ -1,5 +1,5 @@ import { AppConfig, PluginConfig, PluginsConfig } from "../../types/config"; -import { Plugin, PluginModule } from "./types"; +import { Plugin, PluginModule } from "../../types/plugin"; import { logger } from "../../utils/logger"; export class DistributionService { diff --git a/backend/src/services/transformers/transformation.service.ts b/backend/src/services/transformers/transformation.service.ts new file mode 100644 index 0000000..ef7ee52 --- /dev/null +++ b/backend/src/services/transformers/transformation.service.ts @@ -0,0 +1,141 @@ +import { PluginModule, Plugin } from "types/plugin"; +import { AppConfig, PluginConfig, PluginsConfig } from "../../types/config"; +import { logger } from "../../utils/logger"; + +export class DistributionService { + private plugins: Map = new Map(); + + async initialize(config: PluginsConfig): Promise { + // Load all plugins + for (const [name, pluginConfig] of Object.entries(config)) { + try { + await this.loadPlugin(name, pluginConfig); + } catch (error) { + logger.error(`Failed to load plugin ${name}:`, error); + } + } + } + + private async loadPlugin(name: string, config: PluginConfig): Promise { + try { + // Dynamic import of plugin from URL + const module = await import(config.url) as PluginModule; + const plugin = new module.default(); + + // Store the plugin instance + this.plugins.set(name, plugin); + + logger.info(`Successfully loaded plugin: ${name}`); + } catch (error) { + logger.error(`Error loading plugin ${name}:`, error); + throw error; + } + } + + async transformContent(pluginName: string, content: string, config: { prompt: string }): Promise { + const plugin = this.plugins.get(pluginName); + if (!plugin || !('transform' in plugin)) { + throw new Error(`Transformer plugin ${pluginName} not found or invalid`); + } + + try { + await plugin.initialize(config); + return await plugin.transform(content); + } catch (error) { + logger.error(`Error transforming content with plugin ${pluginName}:`, error); + throw error; + } + } + + async distributeContent(pluginName: string, content: string, config: Record): Promise { + const plugin = this.plugins.get(pluginName); + if (!plugin || !('distribute' in plugin)) { + throw new Error(`Distributor plugin ${pluginName} not found or invalid`); + } + + try { + await plugin.initialize(config); + await plugin.distribute(content); + } catch (error) { + logger.error(`Error distributing content with plugin ${pluginName}:`, error); + throw error; + } + } + + async processStreamOutput(feedId: string, content: string): Promise { + const config = await this.getConfig(); + const feed = config.feeds.find(f => f.id === feedId); + if (!feed?.outputs.stream?.enabled) { + return; + } + + const { transform, distribute } = feed.outputs.stream; + + // Transform content if configured + let processedContent = content; + if (transform) { + processedContent = await this.transformContent( + transform.plugin, + content, + transform.config + ); + } + + // Distribute to all configured outputs + for (const dist of distribute) { + await this.distributeContent( + dist.plugin, + processedContent, + dist.config + ); + } + } + + async processRecapOutput(feedId: string, content: string): Promise { + const config = await this.getConfig(); + const feed = config.feeds.find(f => f.id === feedId); + if (!feed?.outputs.recap?.enabled) { + return; + } + + const { transform, distribute } = feed.outputs.recap; + + // Transform content if configured + let processedContent = content; + if (transform) { + processedContent = await this.transformContent( + transform.plugin, + content, + transform.config + ); + } + + // Distribute to all configured outputs + for (const dist of distribute) { + await this.distributeContent( + dist.plugin, + processedContent, + dist.config + ); + } + } + + private async getConfig(): Promise { + const { ConfigService } = await import('../config'); + return ConfigService.getInstance().getConfig(); + } + + async shutdown(): Promise { + // Shutdown all plugins + for (const [name, plugin] of this.plugins.entries()) { + try { + if (plugin.shutdown) { + await plugin.shutdown(); + } + } catch (error) { + logger.error(`Error shutting down plugin ${name}:`, error); + } + } + this.plugins.clear(); + } +} diff --git a/backend/src/services/distribution/types.ts b/backend/src/types/plugin.ts similarity index 100% rename from backend/src/services/distribution/types.ts rename to backend/src/types/plugin.ts diff --git a/bun.lockb b/bun.lockb index 6289be968102d3feb971105446348f1624bd223c..59d1be629dd16fca0675782a90e16cd4aa3b654e 100755 GIT binary patch delta 23673 zcmeI4cYIXU*7wg$GQ&*hHI#&^QZztl0YecGkSd@ENRc9)0HGHlROtu=*`OkbaK(a% zc#T*i7kA3bvPkinzJW_bC&_IV`<4<0mdXn)dNAifyk z4R`vyLg;~`M~)sl&hsV>8#QkH=uzI56F#pfd|h@pr9zn{78)^OldF_5Iy}jpH~s>UGaI>pbz}+^J<}=L|=!V ziM}4)6I~Tu*LWPdHsKF1`@B^2c62553bgVYj>bQ0W-E+q0X5L&&;bjN;Kb^MgI18C zlv2Gmc2NI;<5Px>>p;8;*b*H^zY_FE+N!6et4u}IOI!2#Bl&DZEBzQ6Qf>UvMO8El zBjk^C|LCwkQh?9UMR2G{GbrO8gcH$kBt-pM6IQ`)M5|MFHH)6;I0aQ_wJKooXq|At zxX8Hh5(@ewEj_9K$PprQ35T1&pY=6fw#1xzZ}BWm0|=TFfurhdU%SZigM62M>U(9~HPJ^;r$wue-ht0)crD!&TD`l6hyQ|<2 z6{E#FiY}6!T=%6N8|w9_IcMg;oS6fCLx+Sbvk=`*a;EjhwD2rKO{1ZaY2n?38W8el zXP!4D>zjk8mpOAl0H)vA}BGBQ2f z4J!@jIG1^!FD<;2V7o}#%wuWcxRXAw86huw`(tUL7K9oSa{U_yYG?Nw779;=s1!bT zF6?XQs=w!J7Cs8Q8RpO4J})g?`&6_n{_ND3)4~G?MN3U6Zp|rQ_LG@SLLWo_YrX|o zHdTYT?97R2;dX>>ikxNpH573TL7l+M&g2B25mK{w*{QgeWrb`Z*X->V(!#w7HI9Tb zN0E+DO+umU)Qq%1Y1ZDGvbT;&50Av^9PvucObfqENL5bAPNkWDCDfWw^jytPN6!_E zR3?j1Zo0tx3b~X^zQ!R|XO%Bf<-kBfH{GA%OAkGYrP8?6X7SPHJ8hnU?Cq2&l~ue! zcFN1?;R#sEA&{M!ndaM>^Z3WU*8>6dQnJge;!6u$WMHLcXPizCFJ%2z+r(vWACngT zn2;JHP6Z4V`#Bnycfzg^wSTVn)BQ5TlD?WGqQD2k5GB%Q#F6Ai0v5@EY zGO{yHqz9&9O^R4QV2zgL@9%TNkNKJg-uL;vS&_KTe!n-@WgH{v$Cv{nFfGpS4T!?E zm}4SY`@`|>o3BVbG2ZWWk7Tz$-VHwvX&CT(9U}#tht*OAciZozF$k&p^u?I8K#<*} zQTEmm>EUKr5wA$~W)n(}q<#mhRc^J5u)DMo4|K_s;cqOc>F?}YA%gqSUX zLxj3!XUt0vU!UI}85;~{lATOf)7&XF~M|Mc1aKgwhpqnNygddkNN#M$S(QTq0CI(l#mV zWRbQHOvQ?vF#I7_r--i#Qjxu>NyN7gR`WOHz_u5y>t-yKfwhIR zUM8fKCQ|l58#d3T*{3(A2j*c###rE0tZ2$Wsc1@?o5t@*C~r(IC)C89zUXTlj}=W> zf*rKE%lMJi2umGJN?KtzA+2YAm${BH&%UW)L_JuJo0axwk2u{V@GSJUXv?#ob|l_& znJm14t_nsvXFXQ5QO;uBoV_(8J+5BaX!4b8wvh=CcQ{JIP1tTDCyk8VIfQglI*>*0 z6++PobqOmnqTIQjjG<<1y*EV5_FP(E1fj^d2=Byd505N2;c{%m-E%`@38@F7GuodV zDUd&3dH2LG4Cy=BoZDrmWTb~TVMPmcZhl($8$!)VRWdT|uCL&aY(1gqXd|St5YQIv zcNJWX-{|SLpt`6{xIdGUUL>Stk`^A97Our+9+{eMdpeQBEa)q@f=M<2BuatlJEzw<@_Bzf<7fp|`q}-&yZER;KA?Hja;3>46h@X1yvFpB|Wn z+1_pS`y0n&wT-4Pc}=d_H_v=5&pexFHo7*~cXponPM%rfIu^TVzP<9yjS(|LMa+Y> zt1^P4t`qXi7xT<3xn}t0YSB^60&_VnJfBc>zNS8x=08}?4UhLX4HT)aCdr8Rrw8uB z>?pHwe9TG@{G4aDu3_=%fo#n7?sN!OthUkgRj7;$|w=8_M*7^1o)HH}1x!RBQjwO&*c)#iQ(aP`x#k=VZ{fSvzJA0oJRDOqr|BY5Q zM=bu&w6gyKKlvX;D}2n-NhcA$VqtVv#L-tM@;lH=KnHLrjMnjII-dCA<|nNamb9?6 zPFxDDij}c&S+x9;&^nR|MSh%J4WbE?%|Tijl}GcV!%IP{fOXKysGh~wN9&N5e*=qe zXyHbtZ$u{(-`(PSW?|^)i7tW8Ko>zzHwVS*M6*oKF+I=peA5d}FE+i@^nIpRn9eqR zziIcujs5~zYRh#Hwbcf+jz7}!+hp<5s_JI62GP^TrNy^dSXxcD3$3Pm-Sl3IM@KtB zKo0vY;%cpeeE?Tee2C^JwD&n$Cpu#K3)5esbx14zsOe+IrFEW@XyyB@27zjJ#vG(I zeJ`N7U*i>`_;M_YR>D#iE`!zy%A$2hM@E;$SFms;ioI|No5> zsY1o5Kq9)jRpgI!Wd8k81nK@X;s2xf7Ru*OPo$b$PYu+kH&{)i6;46>bJpeer~PL) zraAu0F0JYx|7er_|9v;+4l2i=T@L)|Zalxycz;f#@o^vj z>W)u{Z|{1Hi7)7yOpI^u`~oDK0bUSTHwo~LI|cB)>zYqS?RRUX z-gD=q-gj-MpgwSqNF8uLOMU3NOhtX<9!I%q6UeCGG&1_T>p2Z@P~a7Tf4J~HfCUo) z8TSAVxt#(HCjpYD1ODlTO$VG5*e`I{m6-upGZ`>_2H=R>CveLYKuQ+iOE)D8a7o~> zz%f^SCSc1{z|xt3lN zyGBa^F9@t#3drwH2@G8T=(r3}(5+ntD7_GHS)j0Mdmmu0z}EW!McvN=lNSN{EC&>K zk1q#QTMQ_;0#MTRTmd*J@QOg93$Fw$SOUmc2`J-s3N%~_NX`b7bHlO$Ck6HkB)c-J z0Be>3rmq52aQg&qxet(XKcJGEazEgbz+r(ZuKH@gmgRt@s{z-#LjpZk0Gc>JRkzRq zLMs8M1**G7YXC0@tXl)P-klN{nhof<7LelBt_74{1-L9w%e8#~uvcK~1AtWbv%uv0 z0ev0>)OC+P2&lFiQ1Br@eb@6Lz(Ijm1kzl19bkb2WUK=;aytbYt^p)J3`lpw9tNBg z*e}q;m01s1vlcLYJ)oJ}CveLHfRqh@=5ERcz$Jmh0xezjN0`SqyIE4L+##vfE_EZS zjaw*ni~CZlt!wlss-0UQ)!vfoAhLUnX&r8>EDQn$Lcn^B$JBT`-5&r)4om%pHH zbB{~i?tG7-y1AZGcew3R-CcMKs)y?@)zj^iy3-YX9M#JWlj`mENcC}Loq(-lR%({bB{}ncfMy)6I@TJiEg{pBp2R+ znw-;rNBoX)JY$Y`BdZ0HN`1!zPd#wg>OPoriC)09c2LBsW!-t(4nuU-%KmCtbAlgMSR|8wyz=ZES9azb^2YnvzZ7_55?o;PNm z`)*>eNvq$lL=Ulb+%C|M7iFU3>ac$3>v-6F6kcz)6xX}VNx|YxHV~zxDDSdvByemr zcbzn7?vI+E?lIJ+&^qO2w}&jUwh^VAb;G0{Q97QrEEKd~q23Pj(+%7jK&5%!{B-8? z+)2=pV@zkRX?L1lfT@@Y)-v{D)UR*u{YK)vmn^YPTFD$=HpcrWULRF};}y%W81^St z$KB?qJJl~4d(~~9Of&5!dMm9ZkU?wq&uf9PFd8opieF88)MhO-ZS>Cv8u3_VY-v|oohao zA@h?(RR`r*)I}Koyc)<(n2z7f?|SSPja@c&18j$}-(ea~Dadoid<37S98(!9T@b2#!DKf|^J8rC=u}-k{#_C5|mUk=gqOD2|%&{{}zmBwGr5Wpj-Ods>G{)N!-mS(O z!Bp1Ukj}=Mz|?`aBdv`!v$Wk{>AKmjIewGDJFssy$L5x}JFJbdmgdLX30^B>H=AEi z*e%9d8@m(M-q?W8B*xp!Q>|%7eTss)M3tNwXbaXV< z5Boh@RSnz8Sbyw~tfg-?R%rn6eS@714ur+-U3YQkW-v^;TGSw-9!7NBW^6F_1_6%S zjSazGiRk`&H)BJwtFbX@Ex*IqFzoU$t+?Hd4acqw)6pZ!vb+%l`l%&2dYa=%?BT}l zgsH1Dkijsm?7huz6n1A@i24{C4eM&Gudy+(8l1Q?`YvOc*t-)LCR$U#>Qi7r4}1#Yy$QWPErkhH%x_|h;)Z(V(BtOOX?(~o3SCWVKNzbo57*x zI0e=TRs%iE*i`HmuZ_t2TPH zInpTJFSb04F*XyHrQ&mB8k>bZ)7V&Jvte_MjWad}Hrv>EV{>7%eD+Lkg28#fDdsrQ z*uAjv#wHn?4;y7{vatoQcE+X{TL{ZEHr3c7*c@Zif>c%ZVq}uRd(3eOY`U@O#+Jec z8=GNl8El5JSa;qByT{l}^IHxZZETjY6>91U24@@8^crhyjde{kLE9@j2V4uUZmuDM$1p965 zboBj}b|dz?##Wo(qp)`@zcuE!N#pNbgKG_L#xb(_q8>2z7i@okYee*eFxB`m?`cH=$&Yto`MqR*FT?&}>}6xGz|KY({r`%=UBK5VOgr>0W4p0`3iBVw zZkSH;DpDkYog2N!{Ptk`&F?j1uff)`dv`#;ZtQjJ2Vgqh5aayb8;D+S=!kyP9N)x# z!W`c+_7+SJj&DW(4OU7T`5C6;u=(x9*2@LDu=?ECKJ1IIuIMAi-p0<#Z;8JEs&?-n zk&6q|QFDA3`xi^B=GBzlkN7ODx{aIXow2{e^d^E9wA02u!Pd(My2|@L!f5}0 z5SV6AgG@C!h%~bdHBiJ3A*ryQ=yS$C#nwADccOnV_D^iRgV76p-q>f@Rbjo+KN>rX zU0*NW+=X$$;OE$%;@BVkld&V%U&3@;glX!0fgFPkK>q?$Yki4)2OEg~&C(vlK5c0) z8#@N$ovz4{^}E5Z2u=l#KwmL-96JLxQm(88YK5}+KIVHB{$=dc@FhK0=W2bg0C)=}8ldF(^RiWvJ5_NlR=#xB5q zG*%3z^8bW{X<;2Dv&``#fiKOml(9=N-JsBs2vfO!M*0hIlrg_wux}@F4Z5tcU$I|g zFr^`jj`*D1!1*}RW+6mmTIh;u@G#g zRj#_RFzj(_{u=qRqP0r^s)SEbm>bM7KkQy=@-!^PSOM4qV>OKxge@{w%UB^;7EDKN zV})T;VB27+#)@eF)rqxb)-hNV))mL+VRelagEg@X>lrH!YYt-@_39fd0V`sD4UCn9 z=^QWOmu9RKtRZ&ROF$X%=tSUK*e_!@HpkMix3P6JF;)hq^+4NbQ?y#REbOrDfH#?6 zIoP+@uM*$f{E}eP@Y@4xnUDP+?j-{^uoAwG-Odu1hZSPw(k|8BSOr)`*gLQe#wx;I zBdv~(Fio*auvcO4VRtsa$}qhgsH2OqDw_X#J4_o*SA*B!xEZERSkC#U-@0GmSMR zT+o_uo@Lk!cF7i+`7o9LCRhtwR2IPqdi=?ymt<_QrELLAHnvo0)&DJlVfrte?;t zgxUkv8EdZ$t;P=<>i~Pg3bWoa>~w!D`9Mlv97Rpu(dEfZtOPf{n$F5Fm^jktDY9CCyjN(){h!3Oix7^?SBV> zn{DOV3RJ1P!`c{o##j$nTVp$n^@O!I_Pnt>VI7R^G}a4ttFf111ijw;_lB@n6KHk@@q>%(it24Ih{wIb_v zpa#f5V1~gr&GBy7Xk*$J)G~u$zsca(XKXO+im`Vs?GV_9#?&=3hr%8<_CeGy%Nqt< z0Mz1gz!DF~zTX@_G&TaZ64CLIv5|y#BU&FmhH0i|U~4nd`f$+vbgxLepx7Z}qp?e9 z{nw)LsU_C!6eZRI@wqwb29gqs9Wgc*TYg$IzA!cpTYh3+8q1y- zg0WM^vhD$@nKXsIL95lLV{2#A2t93%y2GV)L4)V}SYqr<#)rnq8DlfC#~M3pY!+;j zb>}&l#@lSz<~Xgt9On(r0X}Aq7hp2y!qn92>r0m5JZv?!*w4o9#g?D?^%rCFvE?WB ztFZ;x-;=Mp;Wu6Xp}mFtw~N}T8!nsUB5d81P&fQ;Y%z9OYr-qWmcTR>)oOZ7C$*IS zc4I4FpRo<|$c$sl=BQ3+JfUtKH*9sVoSV5ic(*&bI=G>W-ZHL2Sa@?i#%FcOFOkq{D(_>cgi zM-NvDx^`=VQ~d`Dy4ThOD~DfTInkdL?R2Nt1grR-&MCPzc;27&HLhPFCy?Wap8Y(G z=#M+}$Vaca>y7Yo$Tdh3QUyswu0@iO;z)Uv64}{GAtew! zZK;dsX^I}6=wV4A_##HM{%R;cQlL;(E($OTA*GN+q%@*G_bH4NL5d+oUC(vFme=af znDnPl|3vO!Ea=ax{)X&Do<(*bZ|3Y-7rYqA(!HUrh;IGpwoXTW7{{aYAz>r|2_d>` z6Nl(djqbweu8Z!d=myFu(4cUY|hU`Fe zDgGofgBCb~>zBxU!tD|LRZ0foQOIay3^EoOhm1!iAQO>E$Yf*+G8LJIe8~oK965*R zugcyn=;gF|JecfHev?@Js?=YQ%;KzH5I&0NZ)V=hne}9_oR9Z{);$$`)K|kb-x};y zU4KWQN1f?--Gu1jo*vv4K;KJFGmy_Y$8$MvZVkTU%UX-iW~40fy342EIA;hKz}B6+ zpO9aWmyvBqEqH5u7NYeBGP>8Ldt17drQ1~cD<9pO(ygefh;G=*zdlS_cbRktNq3A0 z=M;Y?_@*ywgT+3L_EY-&NF0)Y{ElYoyZz{w&;?!dv%#ua)ksW`XX|wEs z(u+0ZdsigGN2r$WT`R+ri0+H6B)lB?5c><{2=X~{7}*d14E;|;H{f(*?jZ6H0oUnVaMbGE!B969N`>-g-@^A5q_LqMO(9 zKac1NO}|wXUQJu-nUY@dDTJ*ywW8e_8B;$Kp~mIo2$90*2WtY38gIW~|BC2cAieve z#H!=f?R`k;EMwJI1HkVJo)4A_$c+~)&of*^3n2Ls4TS`>Zf(YfgC9E|qT&b90Ypuq z;n4>jpfrkWj(v4iHB`hy_?IV6;qH_|c_}@gTk!hvgZHEJ!7^E~u2L5j!$H+ikyTZV zV--*ZDvGELV_jLAuzdBCI_9q?)412~c8y>4Wo(R>gOx?fASDp1t}c{(V}8o0DFxSwRkK)}ZW8E3nm7mC{vU&h$(nmAPertD4X6=Smq<4`-v#!{ z|2}1}vm~m5a*OrLwb<7nRgnK)@>unPlt}#(Yu>8(Ufq0r;~}#Ds%Eu`WB56DAy_o4 z1_7-hDvT1x)(p*w4ovOpaGhA?*P&^p>Z@)lU@aupM>VlkzZA4ibOSopbeeu@CaqEG zD^11g^+fsVnriKc3=9%0VXQVfaqJ{IxmqZdjAo)WVVhH?I)t^}$krm&3sL-Rq#$uh zUmvZ4#n#(;!9*?;fLeG}bJa*E<2~n}=pjYjh%`dlkbVo5YlxkOG(d*XKu?gLcw@q= z$VTUBg00El6g?0hEhfFOdm(os&5^g@Ezm8Ic;b7adm!DBJCJT_oZB&OL%Jeekj}`h zNGGHt(gA6Yv_sk=w;*kh)<`SlW~2`V>Pf-IVUI;Jkuk_&8Y#<*Ea`oO`Js{**ws?N|OTnD-m-3w*kjNGA%ooG0J2jv1;!Bl% zee2Y0STi-vJMEhF3U%Z!0G9R&4e=Fl7ZjT4j`R+dalLzo%K9%HcawXEI`G2(wmzXU z7Uf&we(oLmtHE;XPDEct9@KbX)vQ97EBJl2Yu2tgnG!^E@@;XK`{d<&ci&K1-*qml zZ|Ff^Q&;Y;P+i|5ck5lDA?0FU^Ydo!+OoIx*th(?zV*qeHiOq4xQo+&=DtNG#l8lZ z`Ip1q#HwY6;#Vi?_xrc5V!u#n{W(HED%8V0(vK5=SruXhw zp0d`Ztaa%6OVKkvys_BCZLbU~Rl?U4QjUHaGh&E7SLf}&yua5QtuGxTO}(1bkWKAU z&T}I|FZkm4pqm?=5lT#seF^S^@}1_ctnmE@YKulS({Sdab?(aCP+}Y(lXG3}<K5pn{t;FEVcdpqe48AhxiY~|CD}HYMy%>CD&n+d&PgN_f z3YCuI%ZP4|eEBY)>$4hzPYk*PIs;$qbA?BT64PQ|QJlXZIP8VFNAJrk-oLMYZee04 zaLY!AI>qr_Lw96!Xl)!HI&@!7~geI`aV&(^7j=dm9Ut)%;U>G zx1Ypue89$?$PA^X#lGeEedq4WP8J<`Tf{fDHrd3!_SoajId{C^`|G!Px$r$mck5V+ z5c@!6%`R#C-fY=vU!F_s6O!Ll>$+#dl=I8-vgIR_ZXJn}V_%&t_tBaGjkm0dp1EO7 zO38OA-2u78K2*6g<9wACYj)lo&81$=`m7wjoWQtHmAF_NC;n?2yNMG+L;n>g$G$T8 zagz&M>-f(fCRfH^1I`@lCf9mWXm)b!Ym?&=T8k9j}=W4-8po5X^%HD6BY z$sxZlZ`|dMf=*LH_52@&+$aX0dx$_%?2D7@wq5$s_50_mWZEFQ5VQ`v@1`*1dxTwZ zD${aw*xfia)WQE**e#eEdcYrxeF7(XW;b?Tu&M znyG5@eTCikc>e3mjTIp&_U+8)r%gXqVQ&G|SL+t_rOC!n$fVd;HsemEb=>5gnu3Ql zK6frQ%?i~?{5MnFJ#ssB49pHy@LOK4>1=v4_8rj=zV7qnrOX$fB}1yE;^MY!HoX{g z|8#a}sQ=T#IXBPINXc#Z+>tbOZfJH~Z2e3A_YD?XxLmt=WcXuo_v!*l)_G~Dtos@g zTR9VxV;|@&)uF(d=XOooPtk0Z&0VZw>tw9;^M+7D3HKe#Z&K{5pM{Uze_iF;-8EHG zqfJ_)gsV88j!Q4$3N8GzZn?UQopMJ=QtZ2H*qLJCI{y8oT!)qTSxMYy3qxgdcLi5u5$&0~HNq@)wOR3LQecCCOEY{5K!=-E&|1x^~N6Waz%RaUEk%D&&uhRFK4*L>X}M@%8lwKRIyk&aF@6^_g{j?#Xk9SDbB^ ziH>3% zH+p^O+MG4(Lo@w}3zkLW+UO6UIC$LAuxZ6qSNl@1^!zp(Lpg0WhQInMr_ua`wEP7} z4jV8ob7U>P$IHiib4IUB7@jX@!uo`!jS?1E@-4gFD+d$!44&scKbTNC;jHmzUUdnF l5`ItkLa*)X*c0({^*>Fhl5mTCJACLKIhAwMKTY`P{{Yx6!1n+E delta 27379 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%0DWC~<5WD+C`G7&NXG9Ho%$uOxZH%&hNJk8BU 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 diff --git a/curate.config.json b/curate.config.json index 530acbe..3d8a3e8 100644 --- a/curate.config.json +++ b/curate.config.json @@ -14,7 +14,7 @@ }, "@curatedotfun/gpt-transform": { "type": "transformer", - "url": "./external/gpt-transofrm" + "url": "./external/gpt-transform" } }, "feeds": [