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.

Split off default commands into own file, added commands, fixed strong typing

+227 -123
+1 -1
utils/atcuteUtils.ts
··· 117 117 } 118 118 119 119 // Get the current session DID 120 - getDid(): string | undefined { 120 + getDid(): Did | undefined { 121 121 return this.credentialManager.session?.did; 122 122 } 123 123 }
+191
utils/commands/defaultCommands.ts
··· 1 + import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 2 + import { getRecord } from "../atcuteUtils.ts"; 3 + import { didResolver } from "../didResolver.ts"; 4 + import type { CommandHandler } from "../streamplaceBot.ts"; 5 + import { AppBskyRichtextFacet } from "@atcute/bluesky"; 6 + import { is } from "@atcute/lexicons"; 7 + 8 + const commandsCommand: CommandHandler = async (_message, _args, bot) => { 9 + const commandsList = Array.from(bot.getCommands().keys()) 10 + .map((cmd) => `${bot.getCommandPrefix()}${cmd}`) 11 + .join(", "); 12 + 13 + await bot.sendMessage(`Available commands: ${commandsList}`); 14 + }; 15 + 16 + const shoutoutCommand: CommandHandler = async (message, args, bot) => { 17 + const senderProfile = await didResolver.resolve(message.did); 18 + let mention: AppBskyRichtextFacet.Mention | string; 19 + let receiverDid, receiverProfile, customShoutout; 20 + 21 + if ( 22 + is( 23 + AppBskyRichtextFacet.mentionSchema, 24 + message.commit.record?.facets?.[0]?.features?.[0], 25 + ) 26 + ) { 27 + mention = message.commit.record?.facets?.[0]?.features?.[0]; 28 + receiverDid = mention.did as Did; 29 + receiverProfile = await didResolver.resolve(receiverDid); 30 + customShoutout = bot.getShoutout(receiverDid); 31 + } else if (args.length > 0) { 32 + mention = args[0]; 33 + } else { 34 + mention = "everyone"; 35 + } 36 + 37 + if (customShoutout) { 38 + await bot.sendMessage(customShoutout.text, customShoutout.facets); 39 + } else if (receiverDid && receiverProfile) { 40 + const { text, facets } = new RichtextBuilder() 41 + .addMention(senderProfile.handle, message.did) 42 + .addText(" gives ") 43 + .addMention(receiverProfile.handle, receiverDid) 44 + .addText(" a shoutout!"); 45 + 46 + await bot.sendMessage(text, facets); 47 + } else { 48 + const { text, facets } = new RichtextBuilder() 49 + .addMention(senderProfile.handle, message.did) 50 + .addText(` gives ${mention} a shoutout!`); 51 + 52 + await bot.sendMessage(text, facets); 53 + } 54 + }; 55 + 56 + // Hug command 57 + const hugCommand: CommandHandler = async (message, args, bot) => { 58 + const senderProfile = await didResolver.resolve(message.did); 59 + let mention: AppBskyRichtextFacet.Mention | string; 60 + let receiverDid, receiverProfile; 61 + 62 + if ( 63 + is( 64 + AppBskyRichtextFacet.mentionSchema, 65 + message.commit.record?.facets?.[0]?.features?.[0], 66 + ) 67 + ) { 68 + mention = message.commit.record?.facets?.[0]?.features?.[0]; 69 + receiverDid = mention.did as Did; 70 + receiverProfile = await didResolver.resolve(receiverDid); 71 + } else if (args.length > 0) { 72 + mention = args[0]; 73 + } else { 74 + mention = "everyone"; 75 + } 76 + 77 + if (receiverDid && receiverProfile) { 78 + const { text, facets } = new RichtextBuilder() 79 + .addMention(senderProfile.handle, message.did) 80 + .addText(" gives ") 81 + .addMention(receiverProfile.handle, receiverDid) 82 + .addText(" a big hug!"); 83 + 84 + await bot.sendMessage(text, facets); 85 + } else { 86 + const { text, facets } = new RichtextBuilder() 87 + .addMention(senderProfile.handle, message.did) 88 + .addText(` gives ${mention} a big hug!`); 89 + 90 + await bot.sendMessage(text, facets); 91 + } 92 + }; 93 + 94 + const linkatCommand: CommandHandler = async (_message, _args, bot) => { 95 + const streamer = await bot.getUserProfile(bot.getStreamerDid()); 96 + const linkat = await getRecord(streamer.pdsEndpoint, { 97 + repo: bot.getStreamerDid(), 98 + collection: "blue.linkat.board", 99 + rkey: "self", 100 + }); 101 + if (!linkat) { 102 + const { text, facets } = new RichtextBuilder() 103 + .addText( 104 + "This user has no Linkat board. You can create one at ", 105 + ) 106 + .addLink("https://linkat.blue/", "https://linkat.blue/") 107 + .addText("!"); 108 + 109 + await bot.sendMessage(text, facets); 110 + return; 111 + } 112 + 113 + const links = linkat.value.cards as Array< 114 + { url: `${string}:${string}`; text?: string; emoji?: string } 115 + >; 116 + 117 + const richtextBuilder = new RichtextBuilder(); 118 + links.forEach((link, index) => { 119 + richtextBuilder.addLink(link.text || link.url, link.url); 120 + if (index < links.length - 1) { 121 + richtextBuilder.addText(" | "); 122 + } 123 + }); 124 + 125 + const { text, facets } = richtextBuilder; 126 + await bot.sendMessage(text, facets); 127 + }; 128 + 129 + const pronounsCommand: CommandHandler = async (message, _args, bot) => { 130 + const streamer = await bot.getUserProfile(bot.getStreamerDid()); 131 + const actorProfileURL: `${string}:${string}` = 132 + `https://pdsls.dev/at://${message.did}/app.bsky.actor.profile/self`; 133 + 134 + const richtextBuilder = new RichtextBuilder(); 135 + if (streamer.pronouns) { 136 + richtextBuilder 137 + .addMention(streamer.handle, bot.getStreamerDid()) 138 + .addText( 139 + `'s pronouns are ${streamer.pronouns}. `, 140 + ); 141 + } 142 + richtextBuilder.addText("You can set your pronouns and website in your ") 143 + .addLink("app.bsky.actor.profile", actorProfileURL) 144 + .addText( 145 + ". You can't currently set them in the Bluesky app, however you can use ", 146 + ) 147 + .addLink("Blacksky", "https://blacksky.community/") 148 + .addText(", ") 149 + .addLink("Witchsky", "https://witchsky.app/") 150 + .addText(", ") 151 + .addLink("Anisota", "https://anisota.net/") 152 + .addText(" or ") 153 + .addLink("PDSls", "https://pdsls.dev/") 154 + .addText("."); 155 + 156 + const { text, facets } = richtextBuilder; 157 + await bot.sendMessage(text, facets); 158 + }; 159 + 160 + const lurkCommand: CommandHandler = async (message, _args, bot) => { 161 + const senderChatter = await bot.getUserProfile(message.did); 162 + 163 + const { text, facets } = new RichtextBuilder() 164 + .addText("We hope you enjoy your lurk, ") 165 + .addMention(senderChatter.handle, message.did) 166 + .addText("! Thank you for being here!"); 167 + 168 + await bot.sendMessage(text, facets); 169 + }; 170 + 171 + const unlurkCommand: CommandHandler = async (message, _args, bot) => { 172 + const senderChatter = await bot.getUserProfile(message.did); 173 + 174 + const { text, facets } = new RichtextBuilder() 175 + .addText("Welcome back from your lurk, ") 176 + .addMention(senderChatter.handle, message.did) 177 + .addText("!"); 178 + 179 + await bot.sendMessage(text, facets); 180 + }; 181 + 182 + export const defaultCommands: Map<string, CommandHandler> = new Map([ 183 + ["commands", commandsCommand], 184 + ["shoutout", shoutoutCommand], 185 + ["so", shoutoutCommand], 186 + ["hug", hugCommand], 187 + ["linkat", linkatCommand], 188 + ["pronouns", pronounsCommand], 189 + ["lurk", lurkCommand], 190 + ["unlurk", unlurkCommand], 191 + ]);
+35 -122
utils/streamplaceBot.ts
··· 1 - import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 2 1 import { 3 2 AtprotoClient, 4 3 AtprotoClientConfig, 5 - getRecord, 6 4 listRecords, 7 5 } from "./atcuteUtils.ts"; 8 6 import { didResolver } from "./didResolver.ts"; 7 + import { defaultCommands } from "./commands/defaultCommands.ts"; 9 8 10 9 export interface CommandHandler { 11 - (message: JetstreamMessage, args: string[]): Promise<void> | void; 10 + ( 11 + message: JetstreamMessage, 12 + args: string[], 13 + bot: StreamplaceBot, 14 + ): Promise<void> | void; 12 15 } 13 16 14 17 // Shoutout record from the repository ··· 99 102 } 100 103 } 101 104 102 - // Get a user profile from didResolver cache 103 - private async getUserProfile(did: Did) { 104 - const chatter = await didResolver.resolve(did); 105 - return chatter; 106 - } 107 - 108 105 // Process an incoming chat message and respond if it's a command 109 106 async processMessage(message: JetstreamMessage): Promise<void> { 110 107 if (!this.enabled) return; ··· 142 139 `Executing command: ${commandName} from user: ${chatter.handle}`, 143 140 ); 144 141 try { 145 - await handler(message, args); 142 + await handler(message, args, this); 146 143 } catch (error) { 147 144 console.error(`Error executing command ${commandName}:`, error); 148 145 } ··· 150 147 } 151 148 152 149 // @param commandName Name of the command (without prefix) 153 - registerCommand(commandName: string, handler: CommandHandler): void { 150 + private registerCommand(commandName: string, handler: CommandHandler): void { 154 151 this.commands.set(commandName.toLowerCase(), handler); 155 152 } 156 153 157 154 // Remove a command 158 - unregisterCommand(commandName: string): boolean { 155 + private unregisterCommand(commandName: string): boolean { 159 156 return this.commands.delete(commandName.toLowerCase()); 160 - } 161 - 162 - // Enable or disable the bot 163 - setEnabled(enabled: boolean): void { 164 - this.enabled = enabled; 165 157 } 166 158 167 159 // Send a message to the chat ··· 177 169 } 178 170 } 179 171 180 - // Get the bot's DID 181 - getBotDid(): string | undefined { 182 - return this.client.getDid(); 183 - } 184 - 185 - // Get the streamer's DID 186 - getStreamerDid(): string { 187 - return this.streamerDid; 188 - } 189 - 190 172 // Reload shoutouts from the repository 191 173 async reloadShoutouts(): Promise<void> { 192 174 this.shoutoutsLoaded = false; ··· 196 178 197 179 // Register the default set of commands 198 180 private registerDefaultCommands(): void { 199 - // Help command 200 - this.registerCommand("commands", async (_message, _args) => { 201 - const commandsList = Array.from(this.commands.keys()) 202 - .map((cmd) => `${this.commandPrefix}${cmd}`) 203 - .join(", "); 181 + for (const [name, handler] of defaultCommands) { 182 + this.registerCommand(name, (msg, args) => handler(msg, args, this)); 183 + } 184 + } 204 185 205 - await this.sendMessage(`Available commands: ${commandsList}`); 206 - }); 186 + // Getters, Setters (alphabetically) 187 + getBotDid(): Did | undefined { 188 + return this.client.getDid(); 189 + } 207 190 208 - // Shoutout command 209 - this.registerCommand("shoutout", async (message, _args) => { 210 - if (!message.commit.record?.facets?.[0]?.features?.[0]?.did) { 211 - await this.sendMessage( 212 - "Please mention a user to give them a shoutout!", 213 - ); 214 - return; 215 - } 191 + getCommandPrefix(): string { 192 + return this.commandPrefix; 193 + } 216 194 217 - const senderChatter = await this.getUserProfile(message.did); 218 - const shoutouteeDid = message.commit.record.facets[0].features[0] 219 - .did as Did; 220 - const shoutouteeChatter = await this.getUserProfile( 221 - shoutouteeDid, 222 - ); 195 + getCommands(): Map<string, CommandHandler> { 196 + return this.commands; 197 + } 223 198 224 - // Check if there's a custom shoutout 225 - const customShoutout = this.shoutouts.get(shoutouteeDid); 199 + getShoutout(did: Did): ShoutoutRecord | undefined { 200 + return this.shoutouts.get(did); 201 + } 226 202 227 - if (customShoutout) { 228 - await this.sendMessage( 229 - customShoutout.text, 230 - customShoutout.facets, 231 - ); 232 - } else { 233 - // Generic shoutout 234 - const { text, facets } = new RichtextBuilder() 235 - .addMention(`@${senderChatter.handle}`, message.did) 236 - .addText(" gives ") 237 - .addMention(`@${shoutouteeChatter.handle}`, shoutouteeDid) 238 - .addText(" a shoutout!"); 239 - 240 - await this.sendMessage(text, facets); 241 - } 242 - }); 243 - 244 - // Hug command 245 - this.registerCommand("hug", async (message, _args) => { 246 - if (!message.commit.record?.facets?.[0]?.features?.[0]?.did) { 247 - await this.sendMessage( 248 - "Please mention a user to give them a hug!", 249 - ); 250 - return; 251 - } 252 - 253 - const senderChatter = await this.getUserProfile(message.did); 254 - const huggeeDid = message.commit.record.facets[0].features[0] 255 - .did as Did; 256 - const huggeeChatter = await this.getUserProfile(huggeeDid); 257 - 258 - const { text, facets } = new RichtextBuilder() 259 - .addMention(`@${senderChatter.handle}`, message.did) 260 - .addText(" gives ") 261 - .addMention(`@${huggeeChatter.handle}`, huggeeDid) 262 - .addText(" a big hug!"); 263 - 264 - await this.sendMessage(text, facets); 265 - }); 203 + getStreamerDid(): Did { 204 + return this.streamerDid; 205 + } 266 206 267 - this.registerCommand("linkat", async (_message, _args) => { 268 - const streamer = await this.getUserProfile(this.streamerDid); 269 - const linkat = await getRecord(streamer.pdsEndpoint, { 270 - repo: this.streamerDid, 271 - collection: "blue.linkat.board", 272 - rkey: "self", 273 - }); 274 - if (!linkat) { 275 - const { text, facets } = new RichtextBuilder() 276 - .addText( 277 - "This user has no Linkat board. You can create one at ", 278 - ) 279 - .addLink("https://linkat.blue/", "https://linkat.blue/") 280 - .addText("!"); 207 + async getUserProfile(did: Did): Promise<UserProfile> { 208 + const chatter = await didResolver.resolve(did); 209 + return chatter; 210 + } 281 211 282 - await this.sendMessage(text, facets); 283 - return; 284 - } 285 - 286 - const links = linkat.value.cards as Array< 287 - { url: `${string}:${string}`; text?: string; emoji?: string } 288 - >; 289 - 290 - const richtextBuilder = new RichtextBuilder(); 291 - links.forEach((link, index) => { 292 - richtextBuilder.addLink(link.text || link.url, link.url); 293 - if (index < links.length - 1) { 294 - richtextBuilder.addText(" | "); 295 - } 296 - }); 297 - 298 - const { text, facets } = richtextBuilder; 299 - await this.sendMessage(text, facets); 300 - }); 212 + setEnabled(enabled: boolean): void { 213 + this.enabled = enabled; 301 214 } 302 215 } 303 216