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.

Richtext facets in custom commands

+134 -93
+1
deno.json
··· 14 14 "@atcute/atproto": "npm:@atcute/atproto@^3.1.4", 15 15 "@atcute/bluesky": "npm:@atcute/bluesky@^3.2.16", 16 16 "@atcute/bluesky-richtext-builder": "npm:@atcute/bluesky-richtext-builder@^2.0.4", 17 + "@atcute/bluesky-richtext-parser": "npm:@atcute/bluesky-richtext-parser@^2.1.1", 17 18 "@atcute/client": "npm:@atcute/client@^4.0.3", 18 19 "@atcute/identity": "npm:@atcute/identity@^1.1.3", 19 20 "@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3",
+5
deno.lock
··· 23 23 "jsr:@std/uuid@^1.0.9": "1.0.9", 24 24 "npm:@atcute/atproto@^3.1.4": "3.1.10", 25 25 "npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4", 26 + "npm:@atcute/bluesky-richtext-parser@^2.1.1": "2.1.1", 26 27 "npm:@atcute/bluesky@^3.2.16": "3.2.16", 27 28 "npm:@atcute/client@^4.0.3": "4.0.3", 28 29 "npm:@atcute/identity-resolver@^1.1.3": "1.2.2_@atcute+identity@1.1.3", ··· 154 155 "@atcute/bluesky", 155 156 "@atcute/lexicons" 156 157 ] 158 + }, 159 + "@atcute/bluesky-richtext-parser@2.1.1": { 160 + "integrity": "sha512-2CJiZ1oLAxQEz6BL5r1m/p+m89bb02959dFEvMvYI7CbHgIzbZsDOp3JB2XVu49DjPNtd9Mz5VnF5OBBpTgWdg==" 157 161 }, 158 162 "@atcute/bluesky@3.2.16": { 159 163 "integrity": "sha512-phFAJNE+SCkIbCcgzjFxntS2KpGvzkLw0JA9qKIXlueF4wNreEt/D5HjnB5eRR9pV1/kcD94II9f7ZAwarf0lQ==", ··· 581 585 "jsr:@std/dotenv@~0.225.5", 582 586 "npm:@atcute/atproto@^3.1.4", 583 587 "npm:@atcute/bluesky-richtext-builder@^2.0.4", 588 + "npm:@atcute/bluesky-richtext-parser@^2.1.1", 584 589 "npm:@atcute/bluesky@^3.2.16", 585 590 "npm:@atcute/client@^4.0.3", 586 591 "npm:@atcute/identity-resolver@^1.1.3",
+75 -93
utils/commandHandler.ts
··· 6 6 PlaceStreamChatMessage, 7 7 } from "./lexicons/index.ts"; 8 8 import { defaultCommands } from "./commands/defaultCommands.ts"; 9 + import { buildRichtext } from "./richtextUtils.ts"; 9 10 import StreamplaceBot from "./streamplaceBot.ts"; 11 + import { didResolver } from "./didResolver.ts"; 10 12 11 13 export interface MessageCreateEvent extends CommitEvent { 12 14 commit: MessageCreateCommit; ··· 39 41 await this.registerCustomCommands(); 40 42 } 41 43 42 - // Register a command 43 44 private registerCommand( 44 45 commandName: string, 45 46 handler: CommandWrapper, ··· 47 48 this.commands.set(commandName.toLowerCase(), handler); 48 49 } 49 50 50 - // Remove a command 51 51 private unregisterCommand(commandName: string): boolean { 52 52 return this.commands.delete(commandName.toLowerCase()); 53 53 } 54 54 55 - // Register the default set of commands 56 55 private registerDefaultCommands(): void { 57 56 for (const [name, handler] of defaultCommands) { 58 57 this.registerCommand( ··· 62 61 } 63 62 } 64 63 65 - // Register custom commands from the repository 64 + private buildCommandFromRecord( 65 + commandRecord: OnlineTimtinkersBotCommand.Main, 66 + ): CommandWrapper | null { 67 + const placeholderPattern = (name: string) => 68 + new RegExp(`\\{${name}\\}`, "g"); 69 + const anyPlaceholder = /\{([^}]+)\}/g; 70 + 71 + switch (commandRecord.commandType) { 72 + case "online.timtinkers.bot.command#simpleCommand": 73 + return async (_event, _params, bot) => { 74 + const { text, facets } = await buildRichtext( 75 + commandRecord.response!, 76 + ); 77 + await bot.sendMessage(text, facets); 78 + }; 79 + 80 + case "online.timtinkers.bot.command#parameterizedCommand": 81 + return async (_event, params, bot) => { 82 + const placeholders = 83 + commandRecord.responseTemplate!.match(anyPlaceholder) ?? 84 + []; 85 + const response = placeholders.reduce( 86 + (template, placeholder, i) => 87 + template.replace(placeholder, params[i] || ""), 88 + commandRecord.responseTemplate!, 89 + ); 90 + const { text, facets } = await buildRichtext(response); 91 + await bot.sendMessage(text, facets); 92 + }; 93 + 94 + case "online.timtinkers.bot.command#rngCommand": 95 + return async (event, params, bot) => { 96 + const result = Math.floor( 97 + Math.random() * 98 + (commandRecord.rngMax! - commandRecord.rngMin! + 1), 99 + ) + commandRecord.rngMin!; 100 + 101 + let response = commandRecord.rngTemplate!.replace( 102 + placeholderPattern("result"), 103 + result.toString(), 104 + ); 105 + 106 + if (commandRecord.rngParameter) { 107 + response = response.replace( 108 + placeholderPattern(commandRecord.rngParameter.name), 109 + params[0] || 110 + `@${ 111 + (await didResolver.resolve(event.did)) 112 + .handle 113 + }`, 114 + ); 115 + } 116 + 117 + const { text, facets } = await buildRichtext(response); 118 + await bot.sendMessage(text, facets); 119 + }; 120 + 121 + default: 122 + return null; 123 + } 124 + } 125 + 66 126 private async registerCustomCommands(): Promise<void> { 67 127 if (this.customCommandsLoaded) return; 68 128 69 129 const streamer = await this.bot.getUserProfile( 70 130 this.bot.getStreamerDid(), 71 131 ); 132 + 72 133 try { 73 - const customCommandsData = await listRecords( 74 - streamer.pdsEndpoint, 75 - { 76 - repo: this.bot.getStreamerDid(), 77 - collection: "online.timtinkers.bot.command", 78 - }, 79 - ); 134 + const { records } = await listRecords(streamer.pdsEndpoint, { 135 + repo: this.bot.getStreamerDid(), 136 + collection: "online.timtinkers.bot.command", 137 + }); 80 138 81 - // Register custom commands 82 - for (const record of customCommandsData.records) { 139 + for (const record of records) { 83 140 const commandRecord = record.value; 84 141 if (!is(OnlineTimtinkersBotCommand.mainSchema, commandRecord)) { 85 142 continue; 86 143 } 87 - let command: CommandWrapper; 88 - if ( 89 - commandRecord.commandType === 90 - "online.timtinkers.bot.command#simpleCommand" 91 - ) { 92 - command = async ( 93 - _event, 94 - _params, 95 - bot, 96 - ) => { 97 - await bot.sendMessage(commandRecord.response!); 98 - }; 99 - } else if ( 100 - commandRecord.commandType === 101 - "online.timtinkers.bot.command#parameterizedCommand" 102 - ) { 103 - command = async ( 104 - _event, 105 - params, 106 - bot, 107 - ) => { 108 - const responseTemplate = commandRecord 109 - .responseTemplate!; 110 - const placeholders = 111 - responseTemplate.match(/\{([^}]+)\}/g) || 112 - []; 113 - const response = placeholders.reduce( 114 - (responseTemplate, placeholder, i) => { 115 - return responseTemplate.replace( 116 - placeholder, 117 - params[i] || "", 118 - ); 119 - }, 120 - responseTemplate, 121 - ); 122 - await bot.sendMessage(response); 123 - }; 124 - } else if ( 125 - commandRecord.commandType === 126 - "online.timtinkers.bot.command#rngCommand" 127 - ) { 128 - command = async ( 129 - _event, 130 - params, 131 - bot, 132 - ) => { 133 - const result = Math.floor( 134 - Math.random() * 135 - (commandRecord.rngMax! - commandRecord.rngMin! + 136 - 1), 137 - ) + commandRecord.rngMin!; 138 - let response = commandRecord.rngTemplate!; 139 144 140 - // Replace {result} placeholder 141 - response = response.replace( 142 - /{result}/g, 143 - result.toString(), 144 - ); 145 + const command = this.buildCommandFromRecord(commandRecord); 146 + if (!command) continue; 145 147 146 - // If there's a parameter, parse and substitute it 147 - if (commandRecord.rngParameter) { 148 - response = response.replace( 149 - new RegExp( 150 - `{${commandRecord.rngParameter.name}}`, 151 - "g", 152 - ), 153 - params[0], 154 - ); 155 - } 156 - await bot.sendMessage(response); 157 - }; 158 - } else { 159 - continue; 160 - } 161 - this.registerCommand( 162 - commandRecord.trigger.toLowerCase(), 163 - command, 164 - ); 165 - this.customCommands.set( 166 - commandRecord.trigger.toLowerCase(), 167 - command, 168 - ); 148 + const trigger = commandRecord.trigger.toLowerCase(); 149 + this.registerCommand(trigger, command); 150 + this.customCommands.set(trigger, command); 169 151 } 170 152 171 153 this.customCommandsLoaded = true;
+53
utils/richtextUtils.ts
··· 1 + import { AppBskyRichtextFacet } from "@atcute/bluesky"; 2 + import { type Token, tokenize } from "@atcute/bluesky-richtext-parser"; 3 + import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 4 + import { resolveHandle } from "./atcuteUtils.ts"; 5 + 6 + export async function buildRichtext( 7 + text: string, 8 + ): Promise<{ text: string; facets: AppBskyRichtextFacet.Main[] }> { 9 + const tokens = tokenize(text); 10 + const rt = new RichtextBuilder(); 11 + 12 + for (const token of tokens) { 13 + switch (token.type) { 14 + case "mention": { 15 + const did = await resolveHandle(token.handle as Handle); 16 + if (did) { 17 + rt.addMention(token.raw, did); 18 + } else { 19 + rt.addText(token.raw); 20 + } 21 + break; 22 + } 23 + case "autolink": 24 + rt.addLink(token.url, token.url as `${string}:${string}`); 25 + break; 26 + case "link": 27 + rt.addLink( 28 + flattenToText(token.children), 29 + token.url as `${string}:${string}`, 30 + ); 31 + break; 32 + default: 33 + rt.addText(token.raw); 34 + break; 35 + } 36 + } 37 + 38 + return rt; 39 + } 40 + 41 + const flattenToText = (tokens: Token[]): string => { 42 + return tokens 43 + .map((t) => { 44 + if ("content" in t) { 45 + return t.content; 46 + } 47 + if ("children" in t) { 48 + return flattenToText(t.children); 49 + } 50 + return t.raw; 51 + }) 52 + .join(""); 53 + };