diff --git a/.env b/.env index fb8630b..cee86cc 100644 --- a/.env +++ b/.env @@ -1,4 +1,10 @@ -DISCORD_TOKEN=token -CLIENT_ID=discord_client_id -BLUESKY_IDENTIFIER=bot_account_handle # for example apfelteesaft.com or apfelbot.bsky.social -BLUESKY_PASSWORD=app_password # the application password, refer to documentation \ No newline at end of file +DISCORD_TOKEN= +CLIENT_ID= +# deprecated, implemented in per user auth +# BLUESKY_IDENTIFIER=bot_account_handle # for example apfelteesaft.com or apfelbot.bsky.social +# BLUESKY_PASSWORD=app_password # the application password, refer to documentation +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_DATABASE_ID= +APPWRITE_COLLECTION_ID= +APPWRITE_PROJECT_ID= +APPWRITE_API_KEY= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d0e431b..a1906f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cron": "^3.2.1", "discord.js": "^14.16.3", "dotenv": "^16.4.5", + "node-appwrite": "^14.1.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7" }, @@ -1445,6 +1446,14 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" }, + "node_modules/node-appwrite": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.1.0.tgz", + "integrity": "sha512-kuKAZrdaAcGYOMUXtxNb1j+uIy+FIMiiU1dFkgwTXLsMLeLvC6HJ8/FH/kN9JyrWR2a2zcGN7gWfyQgWYoLMTA==", + "dependencies": { + "node-fetch-native-with-agent": "1.7.2" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1464,6 +1473,11 @@ } } }, + "node_modules/node-fetch-native-with-agent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", + "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==" + }, "node_modules/node-gyp": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", diff --git a/package.json b/package.json index c0e08d3..054b4d5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cron": "^3.2.1", "discord.js": "^14.16.3", "dotenv": "^16.4.5", + "node-appwrite": "^14.1.0", "sqlite": "^5.1.1", "sqlite3": "^5.1.7" }, diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index 33f1a62..d90f9e9 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -1,13 +1,21 @@ import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; -import { BskyAgent } from '@atproto/api'; +import { getBlueskyAgent } from '../helpers/bluesky'; export async function handleAnalyticsCommand( interaction: ChatInputCommandInteraction, - handle: string, - agent: BskyAgent + handle: string ) { const startTime = Date.now(); - await interaction.deferReply(); + await interaction.deferReply({ ephemeral: true }); + + const agent = await getBlueskyAgent(interaction.user.id); + + if (!agent) { + await interaction.editReply({ + content: 'You need to log in using `/login` before using this command.', + }); + return; + } try { const profile = await agent.getProfile({ actor: handle }); @@ -29,7 +37,7 @@ export async function handleAnalyticsCommand( await interaction.editReply({ embeds: [embed] }); } catch (error) { - await interaction.editReply('Failed to fetch analytics. Please check the handle and try again.'); console.error('Error fetching analytics:', error); + await interaction.editReply('Failed to fetch analytics. Please check the handle and try again.'); } } \ No newline at end of file diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..62f59df --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,38 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { AppwriteService } from '../services/appwrite'; +import { BskyAgent } from '@atproto/api'; + +export async function handleLoginCommand(interaction: ChatInputCommandInteraction) { + const handle = interaction.options.getString('handle', true); + const appPassword = interaction.options.getString('app-password', true); + + await interaction.deferReply({ ephemeral: true }); + + const agent = new BskyAgent({ service: 'https://bsky.social' }); + + try { + console.log(`Attempting login for handle: ${handle}`); + + const loginPayload = { identifier: handle, password: appPassword }; + + await agent.login(loginPayload); + + const bearerToken = agent.session?.accessJwt; + if (!bearerToken) { + throw new Error('Failed to retrieve bearer token.'); + } + + const appwriteService = new AppwriteService(); + await appwriteService.setUserBearer(interaction.user.id, bearerToken, handle, appPassword); + + await interaction.editReply({ + content: `Successfully logged into Bluesky as **${handle}**!`, + }); + } catch (error) { + console.error('Login failed:', error); + + await interaction.editReply({ + content: `Login failed. Please check your handle or app password and try again.`, + }); + } +} \ No newline at end of file diff --git a/src/commands/watcher.ts b/src/commands/watcher.ts index 13bef24..79d8457 100644 --- a/src/commands/watcher.ts +++ b/src/commands/watcher.ts @@ -1,171 +1,148 @@ -import { Client, EmbedBuilder, TextChannel } from 'discord.js'; +import { Client, EmbedBuilder, TextChannel, ChatInputCommandInteraction } from 'discord.js'; import { BskyAgent } from '@atproto/api'; -import sqlite3 from 'sqlite3'; -import { open, Database as SqliteDatabase } from 'sqlite'; +import { AppwriteService } from '../services/appwrite'; import fs from 'fs'; import path from 'path'; const WATCHED_PROFILES_FILE = path.resolve('./watchedProfiles.json'); -const roleId = '1305987062847242280'; // role id from cutiecord +const roleId = '1305987062847242280'; // TODO: implement into watchbluesky command -let db: SqliteDatabase; +let watchedProfiles: { [handle: string]: string[] } = {}; -async function initDatabase() { - db = await open({ - filename: './posts.db', - driver: sqlite3.Database, - }); - - await db.exec(` - CREATE TABLE IF NOT EXISTS announced_posts ( - uri TEXT PRIMARY KEY, - handle TEXT NOT NULL, - createdAt TEXT NOT NULL - ) - `); -} - -async function isPostAnnounced(uri: string): Promise { - const result = await db.get('SELECT 1 FROM announced_posts WHERE uri = ?', uri); - return !!result; -} - -async function markPostAsAnnounced(uri: string, handle: string, createdAt: string) { - await db.run( - 'INSERT INTO announced_posts (uri, handle, createdAt) VALUES (?, ?, ?)', - uri, - handle, - createdAt - ); -} - -async function getWatchedProfiles(): Promise> { +async function loadWatchedProfiles(): Promise { if (!fs.existsSync(WATCHED_PROFILES_FILE)) { fs.writeFileSync(WATCHED_PROFILES_FILE, JSON.stringify({})); } - const data = fs.readFileSync(WATCHED_PROFILES_FILE, 'utf-8'); - return JSON.parse(data); + watchedProfiles = JSON.parse(data); } -async function saveWatchedProfiles(watchedProfiles: Record) { +async function saveWatchedProfiles(): Promise { fs.writeFileSync(WATCHED_PROFILES_FILE, JSON.stringify(watchedProfiles, null, 2)); } -async function processFeedItem(post: any, client: Client, handle: string, channels: string[]) { - try { - if (!post || !post.record || !post.author || !post.record.text || !post.uri) { - console.warn(`Invalid feed item structure for handle: ${handle}`); - return; - } +async function processFeedItem( + client: Client, + post: any, + handle: string, + channels: string[] +) { + if (!post?.record?.text || !post.uri) return; - const postUri = post.uri; + const postLink = `https://bsky.app/profile/${handle}/post/${post.uri.split('/').pop()}`; + const embed = new EmbedBuilder() + .setAuthor({ name: handle }) + .setDescription(post.record.text) + .setColor('Blue') + .setFooter({ text: `New post from ${handle}` }) + .setTimestamp(new Date(post.record.createdAt)); - if (await isPostAnnounced(postUri)) { - console.log(`Skipping already announced post for handle: ${handle}`); - return; - } - - const postLink = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`; - - const embed = new EmbedBuilder() - .setAuthor({ - name: post.author.displayName || post.author.handle, - iconURL: post.author.avatar || undefined, - }) - .setDescription(post.record.text) - .setTimestamp(new Date(post.record.createdAt)) - .setFooter({ text: `From ${handle}` }) - .setColor('#1DA1F2'); - - for (const channelId of channels) { - const channel = client.channels.cache.get(channelId) as TextChannel; - if (!channel) { - console.warn(`Channel with ID ${channelId} not found.`); - continue; - } + for (const channelId of channels) { + const channel = client.channels.cache.get(channelId) as TextChannel; + if (channel) { await channel.send({ content: `<@&${roleId}> ${postLink}`, embeds: [embed], }); } - - await markPostAsAnnounced(postUri, handle, post.record.createdAt); - } catch (error) { - console.error(`Error processing feed item for handle: ${handle}`, error); } } -async function checkFeeds(client: Client, agent: BskyAgent) { - console.log('Running scheduled check for watched profiles...'); - const watchedProfiles = await getWatchedProfiles(); - - for (const handle of Object.keys(watchedProfiles)) { +async function checkFeeds(client: Client, appwriteService: AppwriteService) { + for (const handle in watchedProfiles) { const channels = watchedProfiles[handle]; - console.log(`Checking feed for handle: ${handle}`); + const agent = new BskyAgent({ service: 'https://bsky.social' }); - try { - const response = await agent.getAuthorFeed({ actor: handle }); + const bearer = await appwriteService.getUserBearer(handle); + if (!bearer) continue; - if (!response.data.feed || response.data.feed.length === 0) { - console.log(`No posts found for handle: ${handle}`); - continue; - } + await agent.resumeSession({ + accessJwt: bearer, + refreshJwt: '', + handle, + did: '', + active: true, + }); - for (const feedItem of response.data.feed.reverse()) { - await processFeedItem(feedItem.post, client, handle, channels); - } - } catch (error) { - console.error(`Error fetching feed for handle: ${handle}`, error); + const response = await agent.getAuthorFeed({ actor: handle }); + for (const post of response.data.feed || []) { + await processFeedItem(client, post, handle, channels); } } } -export async function startWatcher(client: Client, agent: BskyAgent) { - await initDatabase(); - console.log('Database initialized.'); - - console.log('Running initial check for watched profiles...'); - await checkFeeds(client, agent); - +export async function startWatcher(client: Client) { + await loadWatchedProfiles(); + const appwriteService = new AppwriteService(); setInterval(async () => { - await checkFeeds(client, agent); - }, 120000); // Check every 2 minutes + await checkFeeds(client, appwriteService); + }, 120000); } -export async function handleWatchCommand(interaction: any, handle: string) { - const watchedProfiles = await getWatchedProfiles(); - - if (!watchedProfiles[handle]) { - watchedProfiles[handle] = []; - } - +export async function handleWatchCommand(interaction: ChatInputCommandInteraction, handle: string) { const channelId = interaction.channelId; + const appwriteService = new AppwriteService(); - if (!watchedProfiles[handle].includes(channelId)) { - watchedProfiles[handle].push(channelId); - await saveWatchedProfiles(watchedProfiles); - await interaction.reply(`Now watching posts from \`${handle}\` in this channel.`); - } else { - await interaction.reply(`Already watching posts from \`${handle}\` in this channel.`); + await interaction.deferReply({ ephemeral: true }); + + try { + let bearer = await appwriteService.getUserBearer(interaction.user.id); + + if (!bearer) { + const userDoc = await appwriteService.getUserDocument(interaction.user.id); + + if (!userDoc) { + await interaction.editReply( + `You need to log in using \`/login\` before you can watch a profile.` + ); + return; + } + + const { handle: savedHandle, appPassword } = userDoc; + + const agent = new BskyAgent({ service: 'https://bsky.social' }); + await agent.login({ identifier: savedHandle, password: appPassword }); + bearer = agent.session?.accessJwt ?? null; + + if (!bearer) { + await interaction.editReply( + `Failed to authenticate with Bluesky. Please check your credentials and try again using \`/login\`.` + ); + return; + } + + await appwriteService.setUserBearer(interaction.user.id, bearer, savedHandle, appPassword); + } + + if (!watchedProfiles[handle]) { + watchedProfiles[handle] = []; + } + + if (!watchedProfiles[handle].includes(channelId)) { + watchedProfiles[handle].push(channelId); + await saveWatchedProfiles(); + await interaction.editReply(`Now watching posts from ${handle} in this channel.`); + } else { + await interaction.editReply(`Already watching posts from ${handle} in this channel.`); + } + } catch (error) { + console.error(`Error in handleWatchCommand:`, error); + await interaction.editReply(`An error occurred while processing your request.`); } } export async function handleUnwatchCommand(interaction: any, handle: string) { - const watchedProfiles = await getWatchedProfiles(); + const channelId = interaction.channelId; + await interaction.deferReply({ ephemeral: true }); if (watchedProfiles[handle]) { - const channelId = interaction.channelId; - watchedProfiles[handle] = watchedProfiles[handle].filter((id) => id !== channelId); - if (watchedProfiles[handle].length === 0) { delete watchedProfiles[handle]; } - - await saveWatchedProfiles(watchedProfiles); - await interaction.reply(`Stopped watching posts from \`${handle}\` in this channel.`); + await saveWatchedProfiles(); + await interaction.editReply(`Stopped watching posts from ${handle} in this channel.`); } else { - await interaction.reply(`No active watch for \`${handle}\` in this channel.`); + await interaction.editReply(`No active watch for ${handle} in this channel.`); } } \ No newline at end of file diff --git a/src/helpers/bluesky.ts b/src/helpers/bluesky.ts new file mode 100644 index 0000000..6512e65 --- /dev/null +++ b/src/helpers/bluesky.ts @@ -0,0 +1,27 @@ +import { BskyAgent } from '@atproto/api'; +import { AppwriteService } from '../services/appwrite'; + +const appwriteService = new AppwriteService(); + +export async function getBlueskyAgent(userId: string): Promise { + const bearer = await appwriteService.getUserBearer(userId); + if (!bearer) { + console.warn(`Bearer token not found for user ${userId}`); + return null; + } + + const agent = new BskyAgent({ service: 'https://bsky.social' }); + try { + await agent.resumeSession({ + accessJwt: bearer, + refreshJwt: '', + handle: '', + did: '', + active: true, + }); + return agent; + } catch (error) { + console.error(`Bearer validation failed for user ${userId}:`, error); + return null; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c88c8a2..958308e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,13 @@ import { Client, GatewayIntentBits, REST, Routes } from 'discord.js'; import dotenv from 'dotenv'; -import { BskyAgent } from '@atproto/api'; import { handleAnalyticsCommand } from './commands/analytics'; import { handleWatchCommand, handleUnwatchCommand, startWatcher } from './commands/watcher'; +import { handleLoginCommand } from './commands/login'; dotenv.config(); const TOKEN = process.env.DISCORD_TOKEN!; const CLIENT_ID = process.env.CLIENT_ID!; -const agent = new BskyAgent({ service: 'https://bsky.social' }); const client = new Client({ intents: [GatewayIntentBits.Guilds] }); @@ -25,6 +24,24 @@ const commands = [ }, ], }, + { + name: 'login', + description: 'Authenticate with Bluesky', + options: [ + { + name: 'handle', + type: 3, + description: 'Your Bluesky handle', + required: true, + }, + { + name: 'app-password', + type: 3, + description: 'Your Bluesky app password', + required: true, + }, + ], + }, { name: 'watchbluesky', description: 'Start watching a Bluesky profile for new posts', @@ -53,7 +70,6 @@ const commands = [ async function registerCommands() { const rest = new REST({ version: '10' }).setToken(TOKEN); - try { console.log('Registering application commands...'); await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands }); @@ -63,29 +79,10 @@ async function registerCommands() { } } -async function authenticateBluesky() { - const identifier = process.env.BLUESKY_IDENTIFIER!; - const password = process.env.BLUESKY_PASSWORD!; - - if (!identifier || !password) { - console.error('Bluesky credentials are not properly configured in the environment variables.'); - process.exit(1); - } - - try { - await agent.login({ identifier, password }); - console.log('Successfully authenticated with Bluesky!'); - } catch (error) { - console.error('Failed to authenticate with Bluesky:', error); - process.exit(1); - } -} - client.once('ready', async () => { console.log(`Logged in as ${client.user?.tag}!`); await registerCommands(); - await authenticateBluesky(); - startWatcher(client, agent); + startWatcher(client); }); client.on('interactionCreate', async (interaction) => { @@ -93,29 +90,22 @@ client.on('interactionCreate', async (interaction) => { const { commandName, options } = interaction; - if (commandName === 'analytics') { - const handle = options.getString('handle', true); - await handleAnalyticsCommand(interaction, handle, agent); - } - - if (commandName === 'watchbluesky') { - const handle = options.getString('handle', true); - try { + try { + if (commandName === 'login') { + await handleLoginCommand(interaction); + } else if (commandName === 'analytics') { + const handle = options.getString('handle', true); + await handleAnalyticsCommand(interaction, handle); + } else if (commandName === 'watchbluesky') { + const handle = options.getString('handle', true); await handleWatchCommand(interaction, handle); - } catch (error) { - console.error('Error handling watchbluesky command:', error); - await interaction.reply('Failed to watch Bluesky handle. Please try again later.'); - } - } - - if (commandName === 'unwatchbluesky') { - const handle = options.getString('handle', true); - try { + } else if (commandName === 'unwatchbluesky') { + const handle = options.getString('handle', true); await handleUnwatchCommand(interaction, handle); - } catch (error) { - console.error('Error handling unwatchbluesky command:', error); - await interaction.reply('Failed to unwatch Bluesky handle. Please try again later.'); } + } catch (error) { + console.error(`Error handling command ${commandName}:`, error); + await interaction.reply({ content: 'An error occurred while processing your command.', ephemeral: true }); } }); diff --git a/src/services/appwrite.ts b/src/services/appwrite.ts new file mode 100644 index 0000000..82c9418 --- /dev/null +++ b/src/services/appwrite.ts @@ -0,0 +1,64 @@ +import { Client, Databases } from 'node-appwrite'; + +export class AppwriteService { + private client: Client; + private database: Databases; + + constructor() { + this.client = new Client(); + this.database = new Databases(this.client); + + this.client + .setEndpoint(process.env.APPWRITE_ENDPOINT!) + .setProject(process.env.APPWRITE_PROJECT_ID!) + .setKey(process.env.APPWRITE_API_KEY!); + } + + async getUserDocument(userId: string): Promise<{ handle: string; appPassword: string } | null> { + try { + const document = await this.database.getDocument( + process.env.APPWRITE_DATABASE_ID!, + process.env.APPWRITE_COLLECTION_ID!, + userId + ); + + const handle = document.handle || null; + const appPassword = document.appPassword || null; + + if (!handle || !appPassword) { + return null; + } + + return { handle, appPassword }; + } catch (error) { + console.error(`Failed to get user document for ${userId}:`, error); + return null; + } + } + + async setUserBearer(userId: string, bearer: string, handle: string, appPassword: string): Promise { + try { + await this.database.createDocument( + process.env.APPWRITE_DATABASE_ID!, + process.env.APPWRITE_COLLECTION_ID!, + userId, + { bearer, handle, appPassword } + ); + } catch (error) { + console.error('Failed to set user bearer:', error); + } + } + + async getUserBearer(userId: string): Promise { + try { + const document = await this.database.getDocument( + process.env.APPWRITE_DATABASE_ID!, + process.env.APPWRITE_COLLECTION_ID!, + userId + ); + return document.bearer; + } catch { + return null; + } + } +} \ No newline at end of file diff --git a/watchedProfiles.json b/watchedProfiles.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/watchedProfiles.json @@ -0,0 +1 @@ +{} \ No newline at end of file