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