diff --git a/src/services/db/index.ts b/src/services/db/index.ts index f1a49f2..5d1af48 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -32,6 +32,8 @@ export class DatabaseService { category TEXT, description TEXT, status TEXT NOT NULL DEFAULT 'pending', + acknowledgment_tweet_id TEXT, + moderation_response_tweet_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); @@ -71,13 +73,19 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_submission_counts_date ON submission_counts(last_reset_date) `); + + // Add index on acknowledgment_tweet_id for faster lookups + this.db.run(` + CREATE INDEX IF NOT EXISTS idx_acknowledgment_tweet_id + ON submissions(acknowledgment_tweet_id) + `); } saveSubmission(submission: TwitterSubmission): void { const stmt = this.db.prepare(` INSERT INTO submissions ( - tweet_id, user_id, content, hashtags, category, description, status - ) VALUES (?, ?, ?, ?, ?, ?, ?) + tweet_id, user_id, content, hashtags, category, description, status, acknowledgment_tweet_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -87,7 +95,8 @@ export class DatabaseService { JSON.stringify(submission.hashtags), submission.category || null, submission.description || null, - submission.status + submission.status, + submission.acknowledgmentTweetId || null ); } @@ -104,13 +113,15 @@ export class DatabaseService { moderation.action, moderation.timestamp.toISOString() ); + } - // Update submission status + updateSubmissionStatus(tweetId: string, status: TwitterSubmission['status'], moderationResponseTweetId: string): void { this.db.prepare(` UPDATE submissions - SET status = ? + SET status = ?, + moderation_response_tweet_id = ? WHERE tweet_id = ? - `).run(moderation.action === 'approve' ? 'approved' : 'rejected', moderation.tweetId); + `).run(status, moderationResponseTweetId, tweetId); } getSubmission(tweetId: string): TwitterSubmission | null { @@ -139,6 +150,45 @@ export class DatabaseService { category: submission.category, description: submission.description, status: submission.status, + acknowledgmentTweetId: submission.acknowledgment_tweet_id, + moderationResponseTweetId: submission.moderation_response_tweet_id, + moderationHistory: submission.moderation_history + ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ + ...m, + timestamp: new Date(m.timestamp) + })) + : [] + }; + } + + getSubmissionByAcknowledgmentTweetId(acknowledgmentTweetId: string): TwitterSubmission | null { + const submission = this.db.prepare(` + SELECT s.*, GROUP_CONCAT( + json_object( + 'adminId', m.admin_id, + 'action', m.action, + 'timestamp', m.timestamp, + 'tweetId', m.tweet_id + ) + ) as moderation_history + FROM submissions s + LEFT JOIN moderation_history m ON s.tweet_id = m.tweet_id + WHERE s.acknowledgment_tweet_id = ? + GROUP BY s.tweet_id + `).get(acknowledgmentTweetId) as any; + + if (!submission) return null; + + return { + tweetId: submission.tweet_id, + userId: submission.user_id, + content: submission.content, + hashtags: JSON.parse(submission.hashtags), + category: submission.category, + description: submission.description, + status: submission.status, + acknowledgmentTweetId: submission.acknowledgment_tweet_id, + moderationResponseTweetId: submission.moderation_response_tweet_id, moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, @@ -171,6 +221,8 @@ export class DatabaseService { category: submission.category, description: submission.description, status: submission.status, + acknowledgmentTweetId: submission.acknowledgment_tweet_id, + moderationResponseTweetId: submission.moderation_response_tweet_id, moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, @@ -204,6 +256,8 @@ export class DatabaseService { category: submission.category, description: submission.description, status: submission.status, + acknowledgmentTweetId: submission.acknowledgment_tweet_id, + moderationResponseTweetId: submission.moderation_response_tweet_id, moderationHistory: submission.moderation_history ? JSON.parse(`[${submission.moderation_history}]`).map((m: any) => ({ ...m, @@ -267,6 +321,14 @@ export class DatabaseService { updated_at = CURRENT_TIMESTAMP `).run(tweetId, tweetId); } + + updateSubmissionAcknowledgment(tweetId: string, acknowledgmentTweetId: string): void { + this.db.prepare(` + UPDATE submissions + SET acknowledgment_tweet_id = ? + WHERE tweet_id = ? + `).run(acknowledgmentTweetId, tweetId); + } } // Export a singleton instance diff --git a/src/services/twitter/client.ts b/src/services/twitter/client.ts index 2ca8497..f3726c1 100644 --- a/src/services/twitter/client.ts +++ b/src/services/twitter/client.ts @@ -40,7 +40,7 @@ export class TwitterService { for (const handle of ADMIN_ACCOUNTS) { try { const userId = await this.client.getUserIdByScreenName(handle); - this.adminIdCache.set(handle, userId); + 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); @@ -49,7 +49,7 @@ export class TwitterService { } private isAdmin(userId: string): boolean { - return Array.from(this.adminIdCache.values()).includes(userId); + return this.adminIdCache.has(userId); } async initialize() { @@ -172,10 +172,10 @@ export class TwitterService { try { if (this.isSubmission(tweet)) { - logger.info("Received new submission."); + 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."); + logger.info(`Received new moderation: ${this.getTweetLink(tweet.id, tweet.username)}`); await this.handleModeration(tweet); } } catch (error) { @@ -235,45 +235,47 @@ export class TwitterService { // Increment submission count in database db.incrementDailySubmissionCount(userId); - await this.replyToTweet( + // Send acknowledgment and save its ID + const acknowledgmentTweetId = await this.replyToTweet( tweet.id, "Successfully submitted to publicgoods.news!" ); - logger.info(`Successfully submitted. Replied to User: ${userId}.`) + + if (acknowledgmentTweetId) { + db.updateSubmissionAcknowledgment(tweet.id, acknowledgmentTweetId); + logger.info(`Successfully submitted. Sent reply: ${this.getTweetLink(acknowledgmentTweetId)}`) + } else { + logger.error(`Failed to acknowledge submission: ${this.getTweetLink(tweet.id, tweet.username)}`) + } } private async handleModeration(tweet: Tweet): Promise { const userId = tweet.userId; if (!userId || !tweet.id) return; - logger.info(`Handling moderation for ${JSON.stringify(tweet)}`); - // 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 original submission tweet this is in response to + // Get the tweet this is in response to (should be our acknowledgment tweet) const inReplyToId = tweet.inReplyToStatusId; - logger.info(`It was a reply to ${tweet.inReplyToStatusId}`); if (!inReplyToId) return; - // Get submission from database - const submission = db.getSubmission(inReplyToId); - logger.info(`Got the original submission: ${JSON.stringify(submission)}`); + // 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.moderationHistory.length > 0) { + logger.info(`Submission ${submission.tweetId} has already been moderated, ignoring new moderation attempt.`); + return; + } + const action = this.getModerationAction(tweet); - logger.info(`Determined the action: ${action}`); if (!action) return; - // Check if this admin has already moderated this submission - const hasModerated = submission.moderationHistory.some( - (mod) => mod.adminId === userId - ); - if (hasModerated) return; - // Add moderation to database const moderation: Moderation = { adminId: userId, @@ -285,27 +287,33 @@ export class TwitterService { // Process the moderation action if (action === "approve") { - logger.info(`Received review from User ${userId}, processing approval.`) + logger.info(`Received review from Admin ${this.adminIdCache.get(userId)}, processing approval.`) await this.processApproval(submission); } else { - logger.info(`Received review from User ${userId}, processing rejection.`) + logger.info(`Received review from Admin ${this.adminIdCache.get(userId)}, processing rejection.`) await this.processRejection(submission); } } private async processApproval(submission: TwitterSubmission): Promise { // TODO: Add NEAR integration here for approved submissions - await this.replyToTweet( + const responseTweetId = await this.replyToTweet( submission.tweetId, "Your submission has been approved and will be added to the public goods news feed!" ); + if (responseTweetId) { + db.updateSubmissionStatus(submission.tweetId, "approved", responseTweetId); + } } private async processRejection(submission: TwitterSubmission): Promise { - await this.replyToTweet( + const responseTweetId = await this.replyToTweet( submission.tweetId, "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 { @@ -323,12 +331,20 @@ export class TwitterService { return tweet.text?.toLowerCase().includes("!submit") || false; } - private async replyToTweet(tweetId: string, message: string): Promise { + private async replyToTweet(tweetId: string, message: string): Promise { try { - await this.client.sendTweet(message, tweetId); // Second parameter is the tweet to reply to + 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); - throw error; + return null; } } + + private getTweetLink(tweetId: string, username: string = this.twitterUsername): string { + return `https://x.com/${username}/status/${tweetId}`; + } } diff --git a/src/types/twitter.ts b/src/types/twitter.ts index 1f5c03c..ec93ded 100644 --- a/src/types/twitter.ts +++ b/src/types/twitter.ts @@ -7,6 +7,8 @@ export interface TwitterSubmission { description?: string; status: "pending" | "approved" | "rejected"; moderationHistory: Moderation[]; + acknowledgmentTweetId?: string; + moderationResponseTweetId?: string; } export interface Moderation {