A work-in-progress chat bot for Streamplace with chat overlay functionality
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Better env handling, .env.example

+110 -54
+11
.env.example
··· 1 + # Handle or DID of the account you want to use as bot 2 + BOT_IDENTIFIER=bot.timtinkers.online 3 + # App password for the account 4 + BOT_PASSWORD=xxxx-xxxx-xxxx-xxxx 5 + # URL for the Jetstream instance 6 + JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe 7 + # If set to true, this will fetch the followers of the bot account 8 + # and the bot will operate in their Streamplace chats as well. 9 + # The bot will always spin up for itself, but you can set this to 10 + # true if you want the bot to serve multiple streamers at once. 11 + FOLLOWER_MODE=false
+48 -6
env.ts
··· 1 1 import { load } from "@std/dotenv"; 2 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 3 + import { getDidDocument, getPDS, resolveHandle } from "./utils/atcuteUtils.ts"; 2 4 3 5 await load({ export: true }); 4 6 5 - export const PDS_HOST_URL = Deno.env.get("PDS_HOST_URL")!; 6 - export const ATPROTO_USERNAME = Deno.env.get("ATPROTO_USERNAME")! as 7 - | Did 8 - | Handle; 9 - export const ATPROTO_PASSWORD = Deno.env.get("ATPROTO_PASSWORD")!; 10 - export const JETSTREAM_URL = Deno.env.get("JETSTREAM_URL")!; 7 + function requiredEnv(key: string): string { 8 + const value = Deno.env.get(key); 9 + if (!value) { 10 + throw new Error( 11 + `❎ Environment variable ${key} is required but not set`, 12 + ); 13 + } 14 + return value; 15 + } 16 + 17 + function validateBotIdentifier(identifier: string): Did | Handle { 18 + if (isDid(identifier) || isHandle(identifier)) { 19 + return identifier as Did | Handle; 20 + } 21 + throw new Error( 22 + `❎ BOT_IDENTIFIER must be a valid DID or Handle, got: ${identifier}`, 23 + ); 24 + } 25 + 26 + async function getPDSfromIdentifier(identifier: Did | Handle) { 27 + const did = isDid(identifier) 28 + ? identifier 29 + : await resolveHandle(identifier); 30 + const doc = await getDidDocument(did); 31 + const pds = getPDS(doc); 32 + if (!pds) { 33 + throw new Error( 34 + `❎ No valid PDS endpoint found for given BOT_IDENTIFIER ${identifier}`, 35 + ); 36 + } 37 + return pds; 38 + } 39 + 40 + export const BOT_CREDENTIALS = { 41 + identifier: validateBotIdentifier(requiredEnv("BOT_IDENTIFIER")), 42 + password: requiredEnv("BOT_PASSWORD"), 43 + }; 44 + export const BOT_SERVICE = await getPDSfromIdentifier( 45 + BOT_CREDENTIALS.identifier, 46 + ); 47 + export const JETSTREAM_URL = (Deno.env.get("JETSTREAM_URL") || 48 + "wss://jetstream1.us-east.bsky.network/subscribe") + 49 + "?wantedCollections=place.stream.chat.message"; 50 + export const FOLLOWER_MODE = Deno.env.get("FOLLOWER_MODE") === "true" 51 + ? true 52 + : false;
+33 -21
main.ts
··· 2 2 import { type State } from "./utils.ts"; 3 3 import { isHandle } from "@atcute/lexicons/syntax"; 4 4 import { resolveHandle } from "./utils/atcuteUtils.ts"; 5 - import { filterByStreamplace, getBacklinks } from "./utils/constellationUtils.ts"; 5 + import { 6 + filterByStreamplace, 7 + getBacklinks, 8 + } from "./utils/constellationUtils.ts"; 6 9 import StreamplaceBot from "./utils/streamplaceBot.ts"; 7 10 import { streamplaceWS } from "./utils/websocket.ts"; 8 - import { ATPROTO_PASSWORD, ATPROTO_USERNAME, PDS_HOST_URL } from "./env.ts"; 11 + import { BOT_CREDENTIALS, BOT_SERVICE, FOLLOWER_MODE } from "./env.ts"; 9 12 10 13 export const app = new App<State>(); 11 14 12 - // Get bot following 13 - const botDid = isHandle(ATPROTO_USERNAME) 14 - ? await resolveHandle(ATPROTO_USERNAME) 15 - : ATPROTO_USERNAME; 16 15 export const botInstances: Map<Did, StreamplaceBot> = new Map(); 17 - const backlinks = await getBacklinks( 16 + 17 + // Initialize for self 18 + const botDid = isHandle(BOT_CREDENTIALS.identifier) 19 + ? await resolveHandle(BOT_CREDENTIALS.identifier) 20 + : BOT_CREDENTIALS.identifier; 21 + const streamplaceBot = new StreamplaceBot( 18 22 botDid, 19 - "app.bsky.graph.follow", 20 - ".subject", 21 - 100, 23 + BOT_CREDENTIALS, 24 + BOT_SERVICE, 22 25 ); 23 - const filteredBacklinks = await filterByStreamplace(backlinks); 24 - for (const backlink of filteredBacklinks) { 25 - const streamplaceBot = new StreamplaceBot( 26 - backlink, 27 - { 28 - username: ATPROTO_USERNAME!, 29 - password: ATPROTO_PASSWORD!, 30 - pdsHostUrl: PDS_HOST_URL!, 31 - }, 26 + streamplaceBot.init(); 27 + botInstances.set(botDid, streamplaceBot); 28 + 29 + if (FOLLOWER_MODE) { 30 + // Get bot followers 31 + const backlinks = await getBacklinks( 32 + botDid, 33 + "app.bsky.graph.follow", 34 + ".subject", 35 + 100, // I should fetch all of them, but I also don't know if I can handle that many 32 36 ); 33 - streamplaceBot.init(); 34 - botInstances.set(backlink, streamplaceBot); 37 + const filteredBacklinks = await filterByStreamplace(backlinks); 38 + for (const backlinkDid of filteredBacklinks) { 39 + const streamplaceBot = new StreamplaceBot( 40 + backlinkDid, 41 + BOT_CREDENTIALS, 42 + BOT_SERVICE, 43 + ); 44 + streamplaceBot.init(); 45 + botInstances.set(backlinkDid, streamplaceBot); 46 + } 35 47 } 36 48 streamplaceWS.start(); 37 49 app.use(staticFiles());
+7 -18
utils/atcuteUtils.ts
··· 1 1 import type {} from "@atcute/atproto"; 2 2 import { 3 + AuthLoginOptions, 3 4 Client, 4 5 CredentialManager, 5 6 ok, ··· 19 20 PlcDidDocumentResolver, 20 21 WellKnownHandleResolver, 21 22 } from "@atcute/identity-resolver"; 22 - import { Handle } from "@atcute/lexicons"; 23 23 import { isHandle } from "@atcute/lexicons/syntax"; 24 24 25 - // Configuration for initializing a Streamplace client 26 - export interface AtprotoClientConfig { 27 - username: string; 28 - password: string; 29 - pdsHostUrl: string; 30 - } 31 - 32 25 // Wrapper class for managing a single bot's credentials and RPC client 33 26 export class AtprotoClient { 34 27 private credentialManager: CredentialManager; 35 28 private rpcClient: Client; 36 - private config: AtprotoClientConfig; 29 + private credentials: AuthLoginOptions; 37 30 private initialized: boolean = false; 38 31 39 - constructor(config: AtprotoClientConfig) { 40 - this.config = config; 32 + constructor(credentials: AuthLoginOptions, service: string) { 33 + this.credentials = credentials; 41 34 this.credentialManager = new CredentialManager({ 42 - service: config.pdsHostUrl, 35 + service: service, 43 36 }); 44 37 this.rpcClient = new Client({ handler: this.credentialManager }); 45 38 } ··· 48 41 async init(): Promise<void> { 49 42 if (this.initialized) return; 50 43 51 - await this.credentialManager.login({ 52 - identifier: this.config.username, 53 - password: this.config.password, 54 - }); 44 + await this.credentialManager.login(this.credentials); 55 45 56 46 this.initialized = true; 57 47 console.log( 58 - `Atproto client initialized for ${this.config.username}`, 48 + `Atproto client initialized for ${this.credentials.identifier}`, 59 49 ); 60 50 } 61 51 ··· 200 190 }); 201 191 202 192 try { 203 - // TODO: did:web 204 193 doc = await resolver.resolve(did); 205 194 } catch (e) { 206 195 console.error(e);
+11 -9
utils/streamplaceBot.ts
··· 1 - import { 2 - AtprotoClient, 3 - AtprotoClientConfig, 4 - listRecords, 5 - } from "./atcuteUtils.ts"; 1 + import { AuthLoginOptions } from "@atcute/client"; 2 + import { AtprotoClient, listRecords } from "./atcuteUtils.ts"; 6 3 import { didResolver } from "./didResolver.ts"; 7 4 import { defaultCommands } from "./commands/defaultCommands.ts"; 8 5 ··· 36 33 /** 37 34 * Create a new StreamplaceBot 38 35 * @param streamerDid The DID of the streamer whose chat to respond in 39 - * @param clientConfig Configuration for the bot's AT Protocol client 36 + * @param clientCredentials Credentials for the bot's AT Protocol client 37 + * @param clientService Service endpoint for the bot 40 38 * @param commandPrefix The prefix that triggers bot commands (default: "!") 41 39 */ 42 40 constructor( 43 41 streamerDid: Did, 44 - clientConfig: AtprotoClientConfig, 42 + clientCredentials: AuthLoginOptions, 43 + clientService: string, 45 44 commandPrefix = "!", 46 45 ) { 47 46 this.streamerDid = streamerDid; 48 47 this.commandPrefix = commandPrefix; 49 48 this.commands = new Map(); 50 49 this.enabled = true; 51 - this.client = new AtprotoClient(clientConfig); 50 + this.client = new AtprotoClient(clientCredentials, clientService); 52 51 } 53 52 54 53 // Initialize the bot - must be called before use ··· 148 147 } 149 148 150 149 // @param commandName Name of the command (without prefix) 151 - private registerCommand(commandName: string, handler: CommandHandler): void { 150 + private registerCommand( 151 + commandName: string, 152 + handler: CommandHandler, 153 + ): void { 152 154 this.commands.set(commandName.toLowerCase(), handler); 153 155 } 154 156