Check Desc for details

Added a semi proper Login command, needs a lot of bugfixing tho
Auth to Bluesky broke?
overall improved messages with ephemeral type
This commit is contained in:
ApfelTeeSaft
2024-11-18 20:27:06 +01:00
parent af3284e883
commit de82f1202f
10 changed files with 297 additions and 171 deletions
+10 -4
View File
@@ -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
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=
+14
View File
@@ -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",
+1
View File
@@ -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"
},
+13 -5
View File
@@ -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.');
}
}
+38
View File
@@ -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.`,
});
}
}
+89 -112
View File
@@ -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<sqlite3.Database, sqlite3.Statement>;
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<boolean> {
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<Record<string, string[]>> {
async function loadWatchedProfiles(): Promise<void> {
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<string, string[]>) {
async function saveWatchedProfiles(): Promise<void> {
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;
}
const postUri = post.uri;
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()}`;
async function processFeedItem(
client: Client,
post: any,
handle: string,
channels: string[]
) {
if (!post?.record?.text || !post.uri) return;
const postLink = `https://bsky.app/profile/${handle}/post/${post.uri.split('/').pop()}`;
const embed = new EmbedBuilder()
.setAuthor({
name: post.author.displayName || post.author.handle,
iconURL: post.author.avatar || undefined,
})
.setAuthor({ name: handle })
.setDescription(post.record.text)
.setTimestamp(new Date(post.record.createdAt))
.setFooter({ text: `From ${handle}` })
.setColor('#1DA1F2');
.setColor('Blue')
.setFooter({ text: `New post from ${handle}` })
.setTimestamp(new Date(post.record.createdAt));
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;
}
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' });
const bearer = await appwriteService.getUserBearer(handle);
if (!bearer) continue;
await agent.resumeSession({
accessJwt: bearer,
refreshJwt: '',
handle,
did: '',
active: true,
});
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) {
await loadWatchedProfiles();
const appwriteService = new AppwriteService();
setInterval(async () => {
await checkFeeds(client, appwriteService);
}, 120000);
}
export async function handleWatchCommand(interaction: ChatInputCommandInteraction, handle: string) {
const channelId = interaction.channelId;
const appwriteService = new AppwriteService();
await interaction.deferReply({ ephemeral: true });
try {
const response = await agent.getAuthorFeed({ actor: handle });
let bearer = await appwriteService.getUserBearer(interaction.user.id);
if (!response.data.feed || response.data.feed.length === 0) {
console.log(`No posts found for handle: ${handle}`);
continue;
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;
}
for (const feedItem of response.data.feed.reverse()) {
await processFeedItem(feedItem.post, client, handle, channels);
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;
}
} catch (error) {
console.error(`Error fetching feed for handle: ${handle}`, error);
await appwriteService.setUserBearer(interaction.user.id, bearer, savedHandle, appPassword);
}
}
}
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);
setInterval(async () => {
await checkFeeds(client, agent);
}, 120000); // Check every 2 minutes
}
export async function handleWatchCommand(interaction: any, handle: string) {
const watchedProfiles = await getWatchedProfiles();
if (!watchedProfiles[handle]) {
watchedProfiles[handle] = [];
}
const channelId = interaction.channelId;
if (!watchedProfiles[handle].includes(channelId)) {
watchedProfiles[handle].push(channelId);
await saveWatchedProfiles(watchedProfiles);
await interaction.reply(`Now watching posts from \`${handle}\` in this channel.`);
await saveWatchedProfiles();
await interaction.editReply(`Now watching posts from ${handle} in this channel.`);
} else {
await interaction.reply(`Already watching posts from \`${handle}\` in this channel.`);
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.`);
}
}
+27
View File
@@ -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<BskyAgent | null> {
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;
}
}
+31 -41
View File
@@ -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 {
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') {
} else if (commandName === 'unwatchbluesky') {
const handle = options.getString('handle', true);
try {
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 });
}
});
+64
View File
@@ -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<void> {
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<string | null> {
try {
const document = await this.database.getDocument(
process.env.APPWRITE_DATABASE_ID!,
process.env.APPWRITE_COLLECTION_ID!,
userId
);
return document.bearer;
} catch {
return null;
}
}
}
+1
View File
@@ -0,0 +1 @@
{}