Aethel Bot OSS repository! aethel.xyz
bot fun ai discord discord-bot aethel
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at dev 312 lines 9.1 kB view raw
1import { Pool } from 'pg'; 2import { 3 SocialMediaSubscription, 4 SocialMediaPost, 5 SocialPlatform, 6 SocialMediaFetcher, 7} from '../../types/social'; 8 9export class SocialMediaService { 10 private pool: Pool; 11 private fetchers: Map<SocialPlatform, SocialMediaFetcher>; 12 private isPolling = false; 13 private pollInterval: number = 5 * 60 * 1000; 14 15 constructor(pool: Pool, fetchers: SocialMediaFetcher[]) { 16 this.pool = pool; 17 this.fetchers = new Map(fetchers.map((f) => [f.platform, f])); 18 } 19 20 async addSubscription( 21 guildId: string, 22 platform: SocialPlatform, 23 accountHandle: string, 24 channelId: string, 25 ): Promise<SocialMediaSubscription> { 26 const fetcher = this.fetchers.get(platform); 27 if (!fetcher) { 28 throw new Error(`Unsupported platform: ${platform}`); 29 } 30 31 if (!fetcher.isValidAccount(accountHandle)) { 32 throw new Error(`Invalid account handle format for ${platform}`); 33 } 34 35 const normalized = this.normalizeAccountHandle(platform, accountHandle); 36 37 const result = await this.pool.query( 38 `INSERT INTO server_social_subscriptions 39 (guild_id, platform, account_handle, channel_id) 40 VALUES ($1, $2::social_platform, $3, $4) 41 ON CONFLICT (guild_id, platform, lower(account_handle)) 42 DO UPDATE SET channel_id = $4 43 RETURNING *`, 44 [guildId, platform, normalized, channelId], 45 ); 46 47 return this.mapDbToSubscription(result.rows[0]); 48 } 49 50 async removeSubscription( 51 guildId: string, 52 platform: SocialPlatform, 53 accountHandle: string, 54 ): Promise<boolean> { 55 const normalizedHandle = this.normalizeAccountHandle(platform, accountHandle); 56 const result = await this.pool.query( 57 `DELETE FROM server_social_subscriptions 58 WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, 59 [guildId, platform, normalizedHandle], 60 ); 61 62 return (result.rowCount || 0) > 0; 63 } 64 65 async listSubscriptions(guildId: string): Promise<SocialMediaSubscription[]> { 66 const result = await this.pool.query( 67 `SELECT * FROM server_social_subscriptions WHERE guild_id = $1`, 68 [guildId], 69 ); 70 71 return result.rows.map(this.mapDbToSubscription); 72 } 73 74 async checkForUpdates(): Promise< 75 { post: SocialMediaPost; subscription: SocialMediaSubscription }[] 76 > { 77 const subscriptions = await this.getAllActiveSubscriptions(); 78 const newPosts: { post: SocialMediaPost; subscription: SocialMediaSubscription }[] = []; 79 const failedAccounts = new Set<string>(); 80 81 for (const sub of subscriptions) { 82 const accountKey = `${sub.platform}:${sub.accountHandle}`; 83 84 if (failedAccounts.has(accountKey)) { 85 continue; 86 } 87 88 try { 89 const fetcher = this.fetchers.get(sub.platform as SocialPlatform); 90 if (!fetcher) { 91 console.warn(`No fetcher found for platform: ${sub.platform}`); 92 continue; 93 } 94 95 const latestPost = await fetcher.fetchLatestPost(sub.accountHandle); 96 if (!latestPost) { 97 continue; 98 } 99 100 const normalizedUri = this.normalizeUri(latestPost.uri); 101 102 if (!sub.lastPostTimestamp) { 103 await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); 104 continue; 105 } 106 107 if (this.isNewerPostWithLogging(latestPost, sub, normalizedUri)) { 108 await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); 109 newPosts.push({ 110 post: { ...latestPost, uri: normalizedUri }, 111 subscription: sub, 112 }); 113 } 114 } catch (error) { 115 console.warn( 116 `Failed to check ${sub.platform} account ${sub.accountHandle}:`, 117 error instanceof Error ? error.message : 'Unknown error', 118 ); 119 120 failedAccounts.add(accountKey); 121 } 122 } 123 124 if (newPosts.length > 0) { 125 console.info( 126 `Found ${newPosts.length} new social media posts across ${subscriptions.length} subscriptions`, 127 ); 128 } 129 130 return newPosts; 131 } 132 133 startPolling(): void { 134 if (this.isPolling) return; 135 136 this.isPolling = true; 137 const poll = async () => { 138 if (!this.isPolling) return; 139 140 try { 141 await this.checkForUpdates(); 142 } catch (error) { 143 console.error('Error during social media polling:', error); 144 } finally { 145 if (this.isPolling) { 146 setTimeout(poll, this.pollInterval); 147 } 148 } 149 }; 150 151 poll(); 152 } 153 154 stopPolling(): void { 155 this.isPolling = false; 156 } 157 158 private async getAllActiveSubscriptions(): Promise<SocialMediaSubscription[]> { 159 const result = await this.pool.query(`SELECT * FROM server_social_subscriptions`); 160 return result.rows.map(this.mapDbToSubscription); 161 } 162 163 private async updateLastPost( 164 subscriptionId: number, 165 postUri: string, 166 postTimestamp: Date, 167 ): Promise<void> { 168 await this.pool.query( 169 `UPDATE server_social_subscriptions 170 SET last_post_uri = $1, last_post_timestamp = $2 171 WHERE id = $3`, 172 [postUri, postTimestamp, subscriptionId], 173 ); 174 } 175 176 private isNewerPost(post: SocialMediaPost, subscription: SocialMediaSubscription): boolean { 177 if (!subscription.lastPostTimestamp) return true; 178 if (post.timestamp > subscription.lastPostTimestamp) return true; 179 if (post.timestamp < subscription.lastPostTimestamp) return false; 180 if (!subscription.lastPostUri) return true; 181 return post.uri !== subscription.lastPostUri; 182 } 183 184 private isNewerPostWithLogging( 185 post: SocialMediaPost, 186 subscription: SocialMediaSubscription, 187 normalizedUri: string, 188 ): boolean { 189 if (!subscription.lastPostTimestamp) return false; 190 191 const isNewer = 192 post.timestamp > subscription.lastPostTimestamp || 193 (post.timestamp.getTime() === subscription.lastPostTimestamp?.getTime() && 194 normalizedUri !== subscription.lastPostUri); 195 196 return isNewer; 197 } 198 199 private normalizeUri(uri: string): string { 200 return uri.trim().toLowerCase(); 201 } 202 203 public async debugSubscription( 204 guildId: string, 205 platform: SocialPlatform, 206 accountHandle: string, 207 ): Promise<{ 208 subscription: SocialMediaSubscription | null; 209 latestPost: SocialMediaPost | null; 210 wouldAnnounce: boolean; 211 reason: string; 212 }> { 213 const normalizedHandle = this.normalizeAccountHandle(platform, accountHandle); 214 const result = await this.pool.query( 215 `SELECT * FROM server_social_subscriptions WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, 216 [guildId, platform, normalizedHandle], 217 ); 218 219 if (result.rows.length === 0) { 220 return { 221 subscription: null, 222 latestPost: null, 223 wouldAnnounce: false, 224 reason: 'No subscription found', 225 }; 226 } 227 228 const subscription = this.mapDbToSubscription(result.rows[0]); 229 const fetcher = this.fetchers.get(platform); 230 231 if (!fetcher) { 232 return { 233 subscription, 234 latestPost: null, 235 wouldAnnounce: false, 236 reason: 'No fetcher available', 237 }; 238 } 239 240 try { 241 const latestPost = await fetcher.fetchLatestPost(accountHandle); 242 if (!latestPost) { 243 return { 244 subscription, 245 latestPost: null, 246 wouldAnnounce: false, 247 reason: 'No posts found', 248 }; 249 } 250 251 const normalizedUri = this.normalizeUri(latestPost.uri); 252 const wouldAnnounce = this.isNewerPostWithLogging(latestPost, subscription, normalizedUri); 253 254 return { 255 subscription, 256 latestPost, 257 wouldAnnounce, 258 reason: wouldAnnounce ? 'New post detected' : 'Post already announced', 259 }; 260 } catch (error) { 261 return { 262 subscription, 263 latestPost: null, 264 wouldAnnounce: false, 265 reason: `Error fetching post: ${error}`, 266 }; 267 } 268 } 269 270 private mapDbToSubscription(row: { 271 id: number; 272 guild_id: string; 273 platform: SocialPlatform; 274 account_handle: string; 275 last_post_uri: string | null; 276 last_post_timestamp: string | Date | null; 277 channel_id: string; 278 created_at: Date; 279 updated_at: Date; 280 }): SocialMediaSubscription { 281 const lastPostTimestamp = row.last_post_timestamp ? new Date(row.last_post_timestamp) : null; 282 283 return { 284 id: row.id, 285 guildId: row.guild_id, 286 platform: row.platform as SocialPlatform, 287 accountHandle: row.account_handle, 288 lastPostUri: row.last_post_uri ?? undefined, 289 lastPostTimestamp: lastPostTimestamp ?? undefined, 290 channelId: row.channel_id, 291 createdAt: row.created_at, 292 updatedAt: row.updated_at, 293 }; 294 } 295 296 private normalizeAccountHandle(platform: SocialPlatform, handle: string): string { 297 let h = handle.trim(); 298 if (platform === 'bluesky') { 299 if (h.startsWith('did:')) { 300 return h; 301 } 302 h = h.startsWith('@') ? h.slice(1) : h; 303 h = h.toLowerCase(); 304 if (!h.includes('.')) { 305 h = `${h}.bsky.social`; 306 } 307 return h; 308 } 309 h = h.startsWith('@') ? h.slice(1) : h; 310 return h.toLowerCase(); 311 } 312}