Aethel Bot OSS repository!
aethel.xyz
bot
fun
ai
discord
discord-bot
aethel
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}