bluesky.ts

1import { BskyAgent } from '@atproto/api'
2import { EmbedBuilder, TextChannel, Message } from 'discord.js'
3import { discordConfig } from '@/configs/discord'
4import { Bot } from '@/types/bot'
5import { Client } from 'discord.js'
6import Logger from '@/classes/logger'
7
8export class BlueskyService {
9	private agent: BskyAgent
10	private client: Bot<Client>
11	private interval: NodeJS.Timeout | null = null
12	private lastPostId: string | null = null
13
14	constructor(client: Bot<Client>) {
15		this.client = client
16		this.agent = new BskyAgent({
17			service: 'https://bsky.social',
18		})
19	}
20
21	async init() {
22		try {
23			await this.agent.login({
24				identifier: process.env.BLUESKY_IDENTIFIER!,
25				password: process.env.BLUESKY_PASSWORD!,
26			})
27			Logger.log('info', 'Successfully logged into Bluesky', 'BlueskyService')
28			this.startPolling()
29		} catch (error: any) {
30			Logger.log(
31				'error',
32				`Failed to login to Bluesky: ${error.message}`,
33				'BlueskyService',
34			)
35		}
36	}
37
38	private async startPolling() {
39		const interval = Number(process.env.BLUESKY_FEED_INTERVAL) || 60000 // Default to 1 minute
40
41		this.interval = setInterval(async () => {
42			try {
43				await this.checkForNewPosts()
44			} catch (error: any) {
45				Logger.log(
46					'error',
47					`Error checking Bluesky posts: ${error.message}`,
48					'BlueskyService',
49				)
50			}
51		}, interval)
52
53		Logger.log(
54			'info',
55			`Started polling Bluesky feed every ${interval}ms`,
56			'BlueskyService',
57		)
58	}
59
60	private async checkForNewPosts() {
61		try {
62			const profile = await this.agent.getProfile({
63				actor: process.env.BLUESKY_IDENTIFIER!,
64			})
65
66			const feed = await this.agent.getAuthorFeed({
67				actor: profile.data.did,
68				limit: 1,
69			})
70
71			const latestPost = feed.data.feed[0]
72			if (
73				!latestPost ||
74				(this.lastPostId && latestPost.post.cid === this.lastPostId)
75			) {
76				return
77			}
78
79			this.lastPostId = latestPost.post.cid
80
81			// Only process if this isn't our first run
82			if (this.lastPostId) {
83				await this.sendDiscordEmbed(latestPost)
84			}
85		} catch (error: any) {
86			Logger.log(
87				'error',
88				`Error fetching Bluesky feed: ${error.message}`,
89				'BlueskyService',
90			)
91		}
92	}
93
94	private async isDuplicatePost(
95		channel: TextChannel,
96		postUrl: string,
97	): Promise<boolean> {
98		try {
99			// Fetch last 100 messages from the channel
100			const messages = await channel.messages.fetch({ limit: 100 })
101
102			// Check if any message contains this post URL
103			return messages.some((message) =>
104				message.embeds.some((embed) => embed.url === postUrl),
105			)
106		} catch (error: any) {
107			Logger.log(
108				'error',
109				`Error checking for duplicate posts: ${error.message}`,
110				'BlueskyService',
111			)
112			return false
113		}
114	}
115
116	private async sendDiscordEmbed(post: any) {
117		const channel = (await this.client.channels.fetch(
118			discordConfig.feedChannelId,
119		)) as TextChannel
120		if (!channel?.isTextBased()) {
121			Logger.log(
122				'error',
123				'Feed channel not found or is not a text channel',
124				'BlueskyService',
125			)
126			return
127		}
128
129		const postUrl = `https://bsky.app/profile/${post.post.author.handle}/post/${post.post.uri.split('/').pop()}`
130
131		// Check if this post has already been sent
132		if (await this.isDuplicatePost(channel, postUrl)) {
133			Logger.log(
134				'debug',
135				`Skipping duplicate post: ${postUrl}`,
136				'BlueskyService',
137			)
138			return
139		}
140
141		const embed = new EmbedBuilder()
142			.setColor('#0085ff')
143			.setAuthor({
144				name: post.post.author.displayName || post.post.author.handle,
145				iconURL: post.post.author.avatar || undefined,
146				url: `https://bsky.app/profile/${post.post.author.handle}`,
147			})
148			.setURL(postUrl)
149			.setTitle('New Bluesky Post! 🔵☁️')
150			.setDescription(post.post.record.text)
151			.setTimestamp(new Date(post.post.indexedAt))
152			.setFooter({
153				text: 'Posted on Bluesky',
154				iconURL: 'https://bsky.app/static/favicon-32x32.png',
155			})
156
157		// Add images if present
158		if (post.post.embed?.images?.length > 0) {
159			embed.setImage(post.post.embed.images[0].fullsize)
160		}
161
162		await channel.send({ embeds: [embed] })
163		Logger.log('debug', `Sent new Bluesky post: ${postUrl}`, 'BlueskyService')
164	}
165
166	stop() {
167		if (this.interval) {
168			clearInterval(this.interval)
169			this.interval = null
170			Logger.log('info', 'Stopped Bluesky feed polling', 'BlueskyService')
171		}
172	}
173}
174