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.

Hot-loading custom commands & shoutouts

+145 -18
+43 -3
utils/commandHandler.ts
··· 1 1 import { CommitEvent, CreateCommit } from "@atcute/jetstream"; 2 - import { is } from "@atcute/lexicons"; 2 + import { is, ResourceUri } from "@atcute/lexicons"; 3 3 import { listRecords } from "./atcuteUtils.ts"; 4 4 import { 5 5 OnlineTimtinkersBotCommand, ··· 30 30 private bot: StreamplaceBot; 31 31 private commands: Map<string, CommandWrapper> = new Map(); 32 32 private customCommands: Map<string, CommandWrapper> = new Map(); 33 + private customCommandsByAtUri: Map<ResourceUri, string> = new Map(); 33 34 private customCommandsLoaded: boolean = false; 34 35 35 36 constructor(bot: StreamplaceBot) { ··· 48 49 this.commands.set(commandName.toLowerCase(), handler); 49 50 } 50 51 51 - private unregisterCommand(commandName: string): boolean { 52 - return this.commands.delete(commandName.toLowerCase()); 52 + unregisterCommand(commandName: string, uri: ResourceUri): boolean { 53 + const delFromCommands = this.commands.delete(commandName.toLowerCase()); 54 + const delFromCustomCommands = this.customCommands.delete( 55 + commandName.toLowerCase(), 56 + ); 57 + const delFromCustomCommandsByAtUri = this.customCommandsByAtUri.delete( 58 + uri, 59 + ); 60 + 61 + return (delFromCommands && delFromCustomCommands && 62 + delFromCustomCommandsByAtUri); 53 63 } 54 64 55 65 private registerDefaultCommands(): void { ··· 123 133 } 124 134 } 125 135 136 + buildAndRegisterCustomCommand( 137 + commandRecord: OnlineTimtinkersBotCommand.Main, 138 + uri: ResourceUri, 139 + ): void { 140 + try { 141 + const command = this.buildCommandFromRecord(commandRecord); 142 + const trigger = commandRecord.trigger.toLowerCase(); 143 + if (!command) { 144 + throw new Error("Failed to build custom command from record."); 145 + } 146 + this.registerCommand(trigger, command); 147 + this.customCommands.set(trigger, command); 148 + this.customCommandsByAtUri.set(uri, trigger); 149 + } catch (error) { 150 + console.error( 151 + "Error building and registering custom command:", 152 + error, 153 + ); 154 + } 155 + } 156 + 126 157 private async registerCustomCommands(): Promise<void> { 127 158 if (this.customCommandsLoaded) return; 128 159 ··· 148 179 const trigger = commandRecord.trigger.toLowerCase(); 149 180 this.registerCommand(trigger, command); 150 181 this.customCommands.set(trigger, command); 182 + this.customCommandsByAtUri.set(record.uri, trigger); 151 183 } 152 184 153 185 this.customCommandsLoaded = true; ··· 159 191 } 160 192 } 161 193 194 + getCommand(trigger: string): CommandWrapper | undefined { 195 + return this.commands.get(trigger); 196 + } 197 + 162 198 getCommands(): Map<string, CommandWrapper> { 163 199 return this.commands; 200 + } 201 + 202 + getCustomCommandByAtUri(uri: ResourceUri): string | undefined { 203 + return this.customCommandsByAtUri.get(uri); 164 204 } 165 205 }
+3 -1
utils/commands/defaultCommands.ts
··· 8 8 import { buildRichtext } from "../richtextUtils.ts"; 9 9 10 10 const commandsCommand: CommandWrapper = async (_message, _params, bot) => { 11 - const commandsList = Array.from(bot.getCommands().keys()) 11 + const commandsList = Array.from( 12 + bot.getCommandHandler().getCommands().keys(), 13 + ) 12 14 .map((cmd) => `${bot.getCommandPrefix()}${cmd}`) 13 15 .join(", "); 14 16
+75 -5
utils/eventHandler.ts
··· 1 1 import { AppBskyGraphFollow } from "@atcute/bluesky"; 2 2 import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 3 3 import { CommitEvent, JetstreamEvent } from "@atcute/jetstream"; 4 - import { is } from "@atcute/lexicons"; 4 + import { is, ResourceUri } from "@atcute/lexicons"; 5 5 import { atprotoClient, botInstances, followerAtUris } from "../main.ts"; 6 6 import { listRecords } from "./atcuteUtils.ts"; 7 7 import { didResolver } from "./didResolver.ts"; 8 8 import { 9 + OnlineTimtinkersBotCommand, 10 + OnlineTimtinkersBotShoutout, 9 11 PlaceStreamChatMessage, 10 12 PlaceStreamLivestream, 11 13 PlaceStreamLiveTeleport, ··· 38 40 break; 39 41 case "place.stream.live.teleport": 40 42 this.handleTeleport(event); 43 + break; 44 + case "online.timtinkers.bot.command": 45 + this.handleNewCommand(event); 46 + break; 47 + case "online.timtinkers.bot.shoutout": 48 + this.handleNewShoutout(event); 41 49 break; 42 50 default: 43 51 } ··· 131 139 .addText(" last stream was titled "); 132 140 if ( 133 141 livestreams.records && 134 - is(PlaceStreamLivestream.mainSchema, livestreams.records[0].value) 142 + is( 143 + PlaceStreamLivestream.mainSchema, 144 + livestreams.records[0].value, 145 + ) 135 146 ) { 136 - const lastTitle = 137 - (livestreams.records[0].value as PlaceStreamLivestream.Main) 138 - .title; 147 + const lastTitle = (livestreams.records[0] 148 + .value as PlaceStreamLivestream.Main) 149 + .title; 139 150 richtextBuilder.addText(`"${lastTitle}"!`); 140 151 } 141 152 142 153 const { text, facets } = richtextBuilder; 143 154 botInstances.get(record.streamer)!.sendMessage(text, facets); 144 155 } 156 + } 157 + } 158 + 159 + // Custom commands and shoutouts 160 + private handleNewCommand(event: CommitEvent): void { 161 + const uri = 162 + `at://${event.did}/${event.commit.collection}/${event.commit.rkey}` as ResourceUri; 163 + // Case new command 164 + if ( 165 + event.commit.operation === "create" || 166 + event.commit.operation === "update" 167 + ) { 168 + const record = event.commit 169 + .record as OnlineTimtinkersBotCommand.Main; 170 + if (!botInstances.get(event.did)) return; 171 + const bot = botInstances.get(event.did); 172 + bot!.getCommandHandler().buildAndRegisterCustomCommand( 173 + record, 174 + `at://${event.did}/${event.commit.collection}/${event.commit.rkey}`, 175 + ); 176 + } 177 + // Case deleted command 178 + if (event.commit.operation === "delete") { 179 + if (!botInstances.get(event.did)) return; 180 + const bot = botInstances.get(event.did); 181 + const trigger = bot!.getCommandHandler() 182 + .getCustomCommandByAtUri(uri); 183 + if (!trigger) return; 184 + bot!.getCommandHandler().unregisterCommand(trigger, uri); 185 + } 186 + } 187 + 188 + private handleNewShoutout(event: CommitEvent): void { 189 + const uri = 190 + `at://${event.did}/${event.commit.collection}/${event.commit.rkey}` as ResourceUri; 191 + // Case new shoutout 192 + if ( 193 + event.commit.operation === "create" || 194 + event.commit.operation === "update" 195 + ) { 196 + const record = event.commit 197 + .record as OnlineTimtinkersBotShoutout.Main; 198 + if (!botInstances.get(event.did)) return; 199 + const bot = botInstances.get(event.did); 200 + bot!.getShoutouts().set(record.user, record); 201 + bot!.getShoutoutsByAtUri().set(uri, record.user); 202 + if (event.commit.operation === "update") { 203 + bot!.getGreeted().set(record.user, false); 204 + } 205 + } 206 + // Case deleted shoutout 207 + if (event.commit.operation === "delete") { 208 + if (!botInstances.get(event.did)) return; 209 + const bot = botInstances.get(event.did); 210 + const did = bot!.getShoutoutByAtUri(uri); 211 + if (!did) return; 212 + bot!.getShoutouts().delete(did); 213 + bot!.getShoutoutsByAtUri().delete(uri); 214 + bot!.getGreeted().delete(did); 145 215 } 146 216 } 147 217
+24 -9
utils/streamplaceBot.ts
··· 1 1 import { AppBskyRichtextFacet } from "@atcute/bluesky"; 2 2 import { CommitEvent } from "@atcute/jetstream"; 3 - import { is } from "@atcute/lexicons"; 3 + import { is, ResourceUri } from "@atcute/lexicons"; 4 4 import { atprotoClient } from "../main.ts"; 5 5 import { AtprotoClient, listRecords } from "./atcuteUtils.ts"; 6 - import { 7 - CommandHandler, 8 - type CommandWrapper, 9 - MessageCreateEvent, 10 - } from "./commandHandler.ts"; 6 + import { CommandHandler, MessageCreateEvent } from "./commandHandler.ts"; 11 7 import { didResolver } from "./didResolver.ts"; 12 8 import { 13 9 OnlineTimtinkersBotShoutout, ··· 27 23 private moderators: Did[] = []; 28 24 private moderatorsLoaded: boolean = false; 29 25 private shoutouts: Map<Did, OnlineTimtinkersBotShoutout.Main> = new Map(); 26 + private shoutoutsByAtUri: Map<ResourceUri, Did> = new Map(); 30 27 private hasBeenGreeted: Map<Did, boolean> = new Map(); 31 28 private shoutoutsLoaded: boolean = false; 32 29 ··· 114 111 const userDid = record.value.user as Did; 115 112 if (is(OnlineTimtinkersBotShoutout.mainSchema, record.value)) { 116 113 this.shoutouts.set(userDid, record.value); 114 + this.shoutoutsByAtUri.set(record.uri, userDid); 117 115 } 118 116 119 117 // Also resolve and cache shoutoutees ··· 164 162 const params = parts.slice(1); 165 163 166 164 // Look up and execute command handler 167 - const commandWrapper = this.getCommands().get(commandName); 165 + const commandWrapper = this.commandHandler.getCommand(commandName); 168 166 if (commandWrapper) { 169 167 console.log( 170 168 `Executing command: ${commandName} in: ${streamer.handle} from user: ${chatter.handle}`, ··· 206 204 async reloadShoutouts(): Promise<void> { 207 205 this.shoutoutsLoaded = false; 208 206 this.shoutouts.clear(); 207 + this.shoutoutsByAtUri.clear(); 209 208 await this.loadShoutouts(); 210 209 } 211 210 ··· 218 217 return this.commandPrefix; 219 218 } 220 219 221 - getCommands(): Map<string, CommandWrapper> { 222 - return this.commandHandler.getCommands(); 220 + getCommandHandler(): CommandHandler { 221 + return this.commandHandler; 222 + } 223 + 224 + getGreeted(): Map<Did, boolean> { 225 + return this.hasBeenGreeted; 223 226 } 224 227 225 228 getModerators(): Did[] { ··· 228 231 229 232 getShoutout(did: Did): OnlineTimtinkersBotShoutout.Main | undefined { 230 233 return this.shoutouts.get(did); 234 + } 235 + 236 + getShoutoutByAtUri(uri: ResourceUri): Did | undefined { 237 + return this.shoutoutsByAtUri.get(uri); 238 + } 239 + 240 + getShoutouts(): Map<Did, OnlineTimtinkersBotShoutout.Main> { 241 + return this.shoutouts; 242 + } 243 + 244 + getShoutoutsByAtUri(): Map<ResourceUri, Did> { 245 + return this.shoutoutsByAtUri; 231 246 } 232 247 233 248 getStreamerDid(): Did {