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.

Initial working custom command support!

+407 -24
+124
lexicons/online/timtinkers/bot/command.json
··· 1 + { 2 + "$type": "com.atproto.lexicon.schema", 3 + "lexicon": 1, 4 + "id": "online.timtinkers.bot.command", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "Record representing a chat bot command. Commands can be simple (static response) or parameterized (template-based with arguments).", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["trigger", "commandType", "createdAt"], 13 + "properties": { 14 + "trigger": { 15 + "type": "string", 16 + "description": "The command trigger without the ! prefix (e.g., 'socials', 'hug')", 17 + "maxLength": 50, 18 + "minLength": 1 19 + }, 20 + "commandType": { 21 + "type": "ref", 22 + "description": "The type of command (simple or parameterized)", 23 + "ref": "#commandType" 24 + }, 25 + "response": { 26 + "type": "string", 27 + "description": "Static response text (required for simpleCommand type)", 28 + "maxLength": 3000, 29 + "maxGraphemes": 300 30 + }, 31 + "responseFacets": { 32 + "type": "array", 33 + "description": "Annotations of response text (mentions, URLs, etc)", 34 + "items": { 35 + "type": "ref", 36 + "ref": "place.stream.richtext.facet" 37 + } 38 + }, 39 + "responseTemplate": { 40 + "type": "string", 41 + "description": "Template with {paramName} placeholders (required for parameterizedCommand type)", 42 + "maxLength": 3000, 43 + "maxGraphemes": 300 44 + }, 45 + "responseTemplateFacets": { 46 + "type": "array", 47 + "description": "Annotations for template text (mentions, URLs, etc). Indices should reference the template string before substitution.", 48 + "items": { 49 + "type": "ref", 50 + "ref": "place.stream.richtext.facet" 51 + } 52 + }, 53 + "parameters": { 54 + "type": "array", 55 + "description": "Parameter definitions (required for parameterizedCommand type)", 56 + "items": { 57 + "type": "ref", 58 + "ref": "#parameter" 59 + }, 60 + "minLength": 1, 61 + "maxLength": 10 62 + }, 63 + "triggerMessage": { 64 + "type": "ref", 65 + "ref": "com.atproto.repo.strongRef", 66 + "description": "Optional reference to the chat message that triggered the creation of this command" 67 + }, 68 + "description": { 69 + "type": "string", 70 + "description": "Optional help text describing what the command does", 71 + "maxLength": 300 72 + }, 73 + "createdAt": { 74 + "type": "string", 75 + "format": "datetime" 76 + } 77 + } 78 + } 79 + }, 80 + "commandType": { 81 + "type": "string", 82 + "knownValues": [ 83 + "online.timtinkers.bot.command#simpleCommand", 84 + "online.timtinkers.bot.command#parameterizedCommand" 85 + ] 86 + }, 87 + "simpleCommand": { 88 + "type": "token", 89 + "description": "A command that returns a static response with no parameters" 90 + }, 91 + "parameterizedCommand": { 92 + "type": "token", 93 + "description": "A command that accepts one or more parameters and uses a response template" 94 + }, 95 + "parameter": { 96 + "type": "object", 97 + "description": "Definition of a command parameter", 98 + "required": ["name", "type"], 99 + "properties": { 100 + "name": { 101 + "type": "string", 102 + "description": "Parameter name used in template (e.g., 'target' for {target})", 103 + "maxLength": 30, 104 + "minLength": 1 105 + }, 106 + "type": { 107 + "type": "string", 108 + "enum": ["string", "handle", "number"], 109 + "description": "Expected parameter type for validation" 110 + }, 111 + "required": { 112 + "type": "boolean", 113 + "description": "Whether this parameter must be provided", 114 + "default": true 115 + }, 116 + "description": { 117 + "type": "string", 118 + "description": "Help text for this parameter", 119 + "maxLength": 200 120 + } 121 + } 122 + } 123 + } 124 + }
+12 -12
utils/commands/defaultCommands.ts
··· 5 5 import { AppBskyRichtextFacet } from "@atcute/bluesky"; 6 6 import { is } from "@atcute/lexicons"; 7 7 8 - const commandsCommand: CommandHandler = async (_message, _args, bot) => { 8 + const commandsCommand: CommandHandler = async (_message, _params, bot) => { 9 9 const commandsList = Array.from(bot.getCommands().keys()) 10 10 .map((cmd) => `${bot.getCommandPrefix()}${cmd}`) 11 11 .join(", "); ··· 13 13 await bot.sendMessage(`Available commands: ${commandsList}`); 14 14 }; 15 15 16 - const shoutoutCommand: CommandHandler = async (message, args, bot) => { 16 + const shoutoutCommand: CommandHandler = async (message, params, bot) => { 17 17 const senderProfile = await didResolver.resolve(message.did); 18 18 let mention: AppBskyRichtextFacet.Mention | string; 19 19 let receiverDid, receiverProfile, customShoutout; ··· 28 28 receiverDid = mention.did as Did; 29 29 receiverProfile = await didResolver.resolve(receiverDid); 30 30 customShoutout = bot.getShoutout(receiverDid); 31 - } else if (args.length > 0) { 32 - mention = args[0]; 31 + } else if (params.length > 0) { 32 + mention = params[0]; 33 33 } else { 34 34 mention = "everyone"; 35 35 } ··· 54 54 }; 55 55 56 56 // Hug command 57 - const hugCommand: CommandHandler = async (message, args, bot) => { 57 + const hugCommand: CommandHandler = async (message, params, bot) => { 58 58 const senderProfile = await didResolver.resolve(message.did); 59 59 let mention: AppBskyRichtextFacet.Mention | string; 60 60 let receiverDid, receiverProfile; ··· 68 68 mention = message.commit.record?.facets?.[0]?.features?.[0]; 69 69 receiverDid = mention.did as Did; 70 70 receiverProfile = await didResolver.resolve(receiverDid); 71 - } else if (args.length > 0) { 72 - mention = args[0]; 71 + } else if (params.length > 0) { 72 + mention = params[0]; 73 73 } else { 74 74 mention = "everyone"; 75 75 } ··· 91 91 } 92 92 }; 93 93 94 - const linkatCommand: CommandHandler = async (_message, _args, bot) => { 94 + const linkatCommand: CommandHandler = async (_message, _params, bot) => { 95 95 const streamerDid = bot.getStreamerDid(); 96 96 const streamer = await bot.getUserProfile(streamerDid); 97 97 const linkat = await getRecord(streamer.pdsEndpoint, { ··· 126 126 await bot.sendMessage(text, facets); 127 127 }; 128 128 129 - const pronounsCommand: CommandHandler = async (message, _args, bot) => { 129 + const pronounsCommand: CommandHandler = async (message, _params, bot) => { 130 130 const streamer = await bot.getUserProfile(bot.getStreamerDid()); 131 131 const actorProfileURL: `${string}:${string}` = 132 132 `https://pdsls.dev/at://${message.did}/app.bsky.actor.profile/self`; ··· 157 157 await bot.sendMessage(text, facets); 158 158 }; 159 159 160 - const lurkCommand: CommandHandler = async (message, _args, bot) => { 160 + const lurkCommand: CommandHandler = async (message, _params, bot) => { 161 161 const senderChatter = await bot.getUserProfile(message.did); 162 162 163 163 const { text, facets } = new RichtextBuilder() ··· 168 168 await bot.sendMessage(text, facets); 169 169 }; 170 170 171 - const unlurkCommand: CommandHandler = async (message, _args, bot) => { 171 + const unlurkCommand: CommandHandler = async (message, _params, bot) => { 172 172 const senderChatter = await bot.getUserProfile(message.did); 173 173 174 174 const { text, facets } = new RichtextBuilder() ··· 179 179 await bot.sendMessage(text, facets); 180 180 }; 181 181 182 - const songCommand: CommandHandler = async (_message, _args, bot) => { 182 + const songCommand: CommandHandler = async (_message, _params, bot) => { 183 183 const streamerDid = bot.getStreamerDid(); 184 184 const streamer = await bot.getUserProfile(streamerDid); 185 185 const tealStatus = await getRecord(streamer.pdsEndpoint, {
+1
utils/lexicons/index.ts
··· 1 + export * as OnlineTimtinkersBotCommand from "./types/online/timtinkers/bot/command.ts"; 1 2 export * as PlaceStreamBadgeDefs from "./types/place/stream/badge/defs.ts"; 2 3 export * as PlaceStreamChatDefs from "./types/place/stream/chat/defs.ts"; 3 4 export * as PlaceStreamChatMessage from "./types/place/stream/chat/message.ts";
+167
utils/lexicons/types/online/timtinkers/bot/command.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 5 + import * as PlaceStreamRichtextFacet from "../../../place/stream/richtext/facet.ts"; 6 + 7 + const _commandTypeSchema = /*#__PURE__*/ v.string< 8 + | "online.timtinkers.bot.command#parameterizedCommand" 9 + | "online.timtinkers.bot.command#simpleCommand" 10 + | (string & {}) 11 + >(); 12 + const _mainSchema = /*#__PURE__*/ v.record( 13 + /*#__PURE__*/ v.tidString(), 14 + /*#__PURE__*/ v.object({ 15 + $type: /*#__PURE__*/ v.literal("online.timtinkers.bot.command"), 16 + /** 17 + * The type of command (simple or parameterized) 18 + */ 19 + get commandType() { 20 + return commandTypeSchema; 21 + }, 22 + createdAt: /*#__PURE__*/ v.datetimeString(), 23 + /** 24 + * Optional help text describing what the command does 25 + * @maxLength 300 26 + */ 27 + description: /*#__PURE__*/ v.optional( 28 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 29 + /*#__PURE__*/ v.stringLength(0, 300), 30 + ]), 31 + ), 32 + /** 33 + * Parameter definitions (required for parameterizedCommand type) 34 + * @minLength 1 35 + * @maxLength 10 36 + */ 37 + get parameters() { 38 + return /*#__PURE__*/ v.optional( 39 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(parameterSchema), [ 40 + /*#__PURE__*/ v.arrayLength(1, 10), 41 + ]), 42 + ); 43 + }, 44 + /** 45 + * Static response text (required for simpleCommand type) 46 + * @maxLength 3000 47 + * @maxGraphemes 300 48 + */ 49 + response: /*#__PURE__*/ v.optional( 50 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 + /*#__PURE__*/ v.stringLength(0, 3000), 52 + /*#__PURE__*/ v.stringGraphemes(0, 300), 53 + ]), 54 + ), 55 + /** 56 + * Annotations of response text (mentions, URLs, etc) 57 + */ 58 + get responseFacets() { 59 + return /*#__PURE__*/ v.optional( 60 + /*#__PURE__*/ v.array(PlaceStreamRichtextFacet.mainSchema), 61 + ); 62 + }, 63 + /** 64 + * Template with {paramName} placeholders (required for parameterizedCommand type) 65 + * @maxLength 3000 66 + * @maxGraphemes 300 67 + */ 68 + responseTemplate: /*#__PURE__*/ v.optional( 69 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 70 + /*#__PURE__*/ v.stringLength(0, 3000), 71 + /*#__PURE__*/ v.stringGraphemes(0, 300), 72 + ]), 73 + ), 74 + /** 75 + * Annotations for template text (mentions, URLs, etc). Indices should reference the template string before substitution. 76 + */ 77 + get responseTemplateFacets() { 78 + return /*#__PURE__*/ v.optional( 79 + /*#__PURE__*/ v.array(PlaceStreamRichtextFacet.mainSchema), 80 + ); 81 + }, 82 + /** 83 + * The command trigger without the ! prefix (e.g., 'socials', 'hug') 84 + * @minLength 1 85 + * @maxLength 50 86 + */ 87 + trigger: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 88 + /*#__PURE__*/ v.stringLength(1, 50), 89 + ]), 90 + /** 91 + * Optional reference to the chat message that triggered the creation of this command 92 + */ 93 + get triggerMessage() { 94 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 95 + }, 96 + }), 97 + ); 98 + const _parameterSchema = /*#__PURE__*/ v.object({ 99 + $type: /*#__PURE__*/ v.optional( 100 + /*#__PURE__*/ v.literal("online.timtinkers.bot.command#parameter"), 101 + ), 102 + /** 103 + * Help text for this parameter 104 + * @maxLength 200 105 + */ 106 + description: /*#__PURE__*/ v.optional( 107 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 108 + /*#__PURE__*/ v.stringLength(0, 200), 109 + ]), 110 + ), 111 + /** 112 + * Parameter name used in template (e.g., 'target' for {target}) 113 + * @minLength 1 114 + * @maxLength 30 115 + */ 116 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 117 + /*#__PURE__*/ v.stringLength(1, 30), 118 + ]), 119 + /** 120 + * Whether this parameter must be provided 121 + * @default true 122 + */ 123 + required: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean(), true), 124 + /** 125 + * Expected parameter type for validation 126 + */ 127 + type: /*#__PURE__*/ v.literalEnum(["handle", "number", "string"]), 128 + }); 129 + const _parameterizedCommandSchema = /*#__PURE__*/ v.literal( 130 + "online.timtinkers.bot.command#parameterizedCommand", 131 + ); 132 + const _simpleCommandSchema = /*#__PURE__*/ v.literal( 133 + "online.timtinkers.bot.command#simpleCommand", 134 + ); 135 + 136 + type commandType$schematype = typeof _commandTypeSchema; 137 + type main$schematype = typeof _mainSchema; 138 + type parameter$schematype = typeof _parameterSchema; 139 + type parameterizedCommand$schematype = typeof _parameterizedCommandSchema; 140 + type simpleCommand$schematype = typeof _simpleCommandSchema; 141 + 142 + export interface commandTypeSchema extends commandType$schematype {} 143 + export interface mainSchema extends main$schematype {} 144 + export interface parameterSchema extends parameter$schematype {} 145 + export interface parameterizedCommandSchema extends parameterizedCommand$schematype {} 146 + export interface simpleCommandSchema extends simpleCommand$schematype {} 147 + 148 + export const commandTypeSchema = _commandTypeSchema as commandTypeSchema; 149 + export const mainSchema = _mainSchema as mainSchema; 150 + export const parameterSchema = _parameterSchema as parameterSchema; 151 + export const parameterizedCommandSchema = 152 + _parameterizedCommandSchema as parameterizedCommandSchema; 153 + export const simpleCommandSchema = _simpleCommandSchema as simpleCommandSchema; 154 + 155 + export type CommandType = v.InferInput<typeof commandTypeSchema>; 156 + export interface Main extends v.InferInput<typeof mainSchema> {} 157 + export interface Parameter extends v.InferInput<typeof parameterSchema> {} 158 + export type ParameterizedCommand = v.InferInput< 159 + typeof parameterizedCommandSchema 160 + >; 161 + export type SimpleCommand = v.InferInput<typeof simpleCommandSchema>; 162 + 163 + declare module "@atcute/lexicons/ambient" { 164 + interface Records { 165 + "online.timtinkers.bot.command": mainSchema; 166 + } 167 + }
+103 -12
utils/streamplaceBot.ts
··· 1 1 import { AuthLoginOptions } from "@atcute/client"; 2 2 import { CommitEvent } from "@atcute/jetstream"; 3 + import { is } from "@atcute/lexicons"; 3 4 import { AtprotoClient, listRecords } from "./atcuteUtils.ts"; 4 5 import { didResolver } from "./didResolver.ts"; 5 6 import { defaultCommands } from "./commands/defaultCommands.ts"; 6 - import { PlaceStreamChatMessage } from "./lexicons/index.ts"; 7 + import { 8 + OnlineTimtinkersBotCommand, 9 + PlaceStreamChatMessage, 10 + } from "./lexicons/index.ts"; 7 11 8 12 export interface CommandHandler { 9 13 ( 10 14 event: CommitEvent, 11 - args: string[], 15 + params: string[], 12 16 bot: StreamplaceBot, 13 17 ): Promise<void> | void; 14 18 } ··· 28 32 private client: AtprotoClient; 29 33 30 34 // Caching 35 + private customCommands: Map<string, CommandHandler> = new Map(); 36 + private customCommandsLoaded: boolean = false; 31 37 private moderators: Did[] = []; 32 38 private moderatorsLoaded: boolean = false; 33 39 private shoutouts: Map<Did, ShoutoutRecord> = new Map(); ··· 63 69 await this.loadModerators(); 64 70 await this.loadShoutouts(); 65 71 66 - // Register default commands 72 + // Register commands 67 73 this.registerDefaultCommands(); 74 + await this.registerCustomCommands(); 68 75 69 76 const streamer = await this.getUserProfile(this.streamerDid); 70 77 console.log( ··· 170 177 // Extract command name and arguments 171 178 const parts = text.slice(this.commandPrefix.length).split(/\s+/); 172 179 const commandName = parts[0].toLowerCase(); 173 - const args = parts.slice(1); 180 + const params = parts.slice(1); 174 181 175 182 // Look up and execute command handler 176 183 const handler = this.commands.get(commandName); ··· 179 186 `Executing command: ${commandName} in: ${streamer.handle} from user: ${chatter.handle}`, 180 187 ); 181 188 try { 182 - await handler(event, args, this); 189 + await handler(event, params, this); 183 190 } catch (error) { 184 191 console.error(`Error executing command ${commandName}:`, error); 185 192 } ··· 212 219 } 213 220 } 214 221 222 + // Register the default set of commands 223 + private registerDefaultCommands(): void { 224 + for (const [name, handler] of defaultCommands) { 225 + this.registerCommand( 226 + name, 227 + (msg, params) => handler(msg, params, this), 228 + ); 229 + } 230 + } 231 + 232 + // Register custom commands from the repository 233 + private async registerCustomCommands(): Promise<void> { 234 + if (this.customCommandsLoaded) return; 235 + 236 + const streamer = await this.getUserProfile(this.streamerDid); 237 + try { 238 + const customCommandsData = await listRecords( 239 + streamer.pdsEndpoint, 240 + { 241 + repo: this.streamerDid, 242 + collection: "online.timtinkers.bot.command", 243 + }, 244 + ); 245 + 246 + // Register custom commands 247 + for (const record of customCommandsData.records) { 248 + const commandRecord = record.value; 249 + if (!is(OnlineTimtinkersBotCommand.mainSchema, commandRecord)) { 250 + continue; 251 + } 252 + let command: CommandHandler; 253 + if ( 254 + commandRecord.commandType === 255 + "online.timtinkers.bot.command#simpleCommand" 256 + ) { 257 + if (!commandRecord.response) continue; 258 + command = async ( 259 + _event, 260 + _params, 261 + bot, 262 + ) => { 263 + await bot.sendMessage(commandRecord.response!); 264 + }; 265 + } else if ( 266 + commandRecord.commandType === 267 + "online.timtinkers.bot.command#parameterizedCommand" 268 + ) { 269 + if (!commandRecord.responseTemplate) continue; 270 + command = async ( 271 + _event, 272 + params, 273 + bot, 274 + ) => { 275 + const responseTemplate = commandRecord 276 + .responseTemplate!; 277 + const placeholders = 278 + responseTemplate.match(/\{([^}]+)\}/g) || 279 + []; 280 + const response = placeholders.reduce( 281 + (responseTemplate, placeholder, i) => { 282 + return responseTemplate.replace( 283 + placeholder, 284 + params[i] || "", 285 + ); 286 + }, 287 + responseTemplate, 288 + ); 289 + await bot.sendMessage(response); 290 + }; 291 + } else { 292 + continue; 293 + } 294 + this.registerCommand( 295 + commandRecord.trigger.toLowerCase(), 296 + command, 297 + ); 298 + this.customCommands.set( 299 + commandRecord.trigger.toLowerCase(), 300 + command, 301 + ); 302 + } 303 + 304 + this.customCommandsLoaded = true; 305 + console.log( 306 + `Loaded ${this.customCommands.size} custom commands into cache`, 307 + ); 308 + } catch (error) { 309 + console.error("Error loading custom commands:", error); 310 + } 311 + } 312 + 215 313 // Reload moderators from the repository 216 314 async reloadModerators(): Promise<void> { 217 315 this.moderatorsLoaded = false; ··· 224 322 this.shoutoutsLoaded = false; 225 323 this.shoutouts.clear(); 226 324 await this.loadShoutouts(); 227 - } 228 - 229 - // Register the default set of commands 230 - private registerDefaultCommands(): void { 231 - for (const [name, handler] of defaultCommands) { 232 - this.registerCommand(name, (msg, args) => handler(msg, args, this)); 233 - } 234 325 } 235 326 236 327 // Getters, Setters (alphabetically)