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.

Recurring commands!

+289 -10
+2 -1
TODO.md
··· 5 5 - [ ] refactoring/cleaner file structure 6 6 - [X] README.md 7 7 - [ ] VIP & event badges 8 - - [ ] recurring automatic commands 8 + - [X] recurring automatic commands 9 + - [ ] cooldowns on commands (global and user-specific) 9 10 - [ ] deleting messages -> overlay 10 11 - [ ] respecting blocks, threadgates etc. 11 12 - [ ] event-driven overlay for follows, sounds, clips
+39
lexicons/online/timtinkers/bot/recurring.json
··· 1 + { 2 + "$type": "com.atproto.lexicon.schema", 3 + "lexicon": 1, 4 + "id": "online.timtinkers.bot.recurring", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "Record describing a timer for a recurring bot command.", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["trigger", "createdAt"], 13 + "properties": { 14 + "trigger": { 15 + "type": "string", 16 + "description": "The command trigger without prefix (e.g., 'socials', 'hug', 'd6')", 17 + "maxLength": 50, 18 + "minLength": 1 19 + }, 20 + "minMessages": { 21 + "type": "integer", 22 + "description": "The minimum amount of messages in chat since the last recurring command of this trigger." 23 + }, 24 + "intervalSeconds": { 25 + "type": "integer", 26 + "description": "The interval between recurring commands (maximum 12 hours).", 27 + "minimum": 60, 28 + "maximum": 43200 29 + }, 30 + "createdAt": { 31 + "type": "string", 32 + "format": "datetime", 33 + "description": "Timestamp when the recurring command was created" 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+23
utils/eventHandler.ts
··· 7 7 import { didResolver } from "./didResolver.ts"; 8 8 import { 9 9 OnlineTimtinkersBotCommand, 10 + OnlineTimtinkersBotRecurring, 10 11 OnlineTimtinkersBotShoutout, 11 12 PlaceStreamChatMessage, 12 13 PlaceStreamChatProfile, ··· 53 54 break; 54 55 case "online.timtinkers.bot.command": 55 56 this.handleNewCommand(event); 57 + break; 58 + case "online.timtinkers.bot.recurring": 59 + this.handleRecurringCommand(event); 56 60 break; 57 61 case "online.timtinkers.bot.shoutout": 58 62 this.handleNewShoutout(event); ··· 268 272 .record as PlaceStreamLivestream.Main; 269 273 const bot = botInstances.get(event.did); 270 274 bot!.livestream = record; 275 + } 276 + 277 + private handleRecurringCommand(event: CommitEvent): void { 278 + const uri = 279 + `at://${event.did}/${event.commit.collection}/${event.commit.rkey}` as ResourceUri; 280 + 281 + if (!botInstances.get(event.did)) return; 282 + const bot = botInstances.get(event.did)!; 283 + 284 + if ( 285 + event.commit.operation === "create" || 286 + event.commit.operation === "update" 287 + ) { 288 + const record = event.commit 289 + .record as OnlineTimtinkersBotRecurring.Main; 290 + bot.getTimedCommandHandler().handleUpsert(uri, record); 291 + } else if (event.commit.operation === "delete") { 292 + bot.getTimedCommandHandler().handleDelete(uri); 293 + } 271 294 } 272 295 273 296 private async enrichMessage(
+1
utils/lexicons/index.ts
··· 1 1 export * as OnlineTimtinkersBotCommand from "./types/online/timtinkers/bot/command.ts"; 2 + export * as OnlineTimtinkersBotRecurring from "./types/online/timtinkers/bot/recurring.ts"; 2 3 export * as OnlineTimtinkersBotShoutout from "./types/online/timtinkers/bot/shoutout.ts"; 3 4 export * as PlaceStreamBadgeDefs from "./types/place/stream/badge/defs.ts"; 4 5 export * as PlaceStreamChatDefs from "./types/place/stream/chat/defs.ts";
+50
utils/lexicons/types/online/timtinkers/bot/recurring.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("online.timtinkers.bot.recurring"), 9 + /** 10 + * Timestamp when the shoutout was created 11 + */ 12 + createdAt: /*#__PURE__*/ v.datetimeString(), 13 + /** 14 + * The interval between recurring commands (maximum 12 hours). 15 + * @minimum 60 16 + * @maximum 43200 17 + */ 18 + intervalSeconds: /*#__PURE__*/ v.optional( 19 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 20 + /*#__PURE__*/ v.integerRange(60, 43200), 21 + ]), 22 + ), 23 + /** 24 + * The minimum amount of messages in chat since the last recurring command of this trigger. 25 + */ 26 + minMessages: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 27 + /** 28 + * The command trigger without prefix (e.g., 'socials', 'hug', 'd6') 29 + * @minLength 1 30 + * @maxLength 50 31 + */ 32 + trigger: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(1, 50), 34 + ]), 35 + }), 36 + ); 37 + 38 + type main$schematype = typeof _mainSchema; 39 + 40 + export interface mainSchema extends main$schematype {} 41 + 42 + export const mainSchema = _mainSchema as mainSchema; 43 + 44 + export interface Main extends v.InferInput<typeof mainSchema> {} 45 + 46 + declare module "@atcute/lexicons/ambient" { 47 + interface Records { 48 + "online.timtinkers.bot.recurring": mainSchema; 49 + } 50 + }
+160
utils/recurringCommandHandler.ts
··· 1 + import { is, ResourceUri } from "@atcute/lexicons"; 2 + import { listRecords } from "./atcuteUtils.ts"; 3 + import { OnlineTimtinkersBotRecurring } from "./lexicons/index.ts"; 4 + import StreamplaceBot from "./streamplaceBot.ts"; 5 + import { MessageCreateEvent } from "./commandHandler.ts"; 6 + 7 + interface ActiveTimer { 8 + trigger: string; 9 + intervalSeconds: number; 10 + minMessages: number; 11 + intervalId: number; // from setInterval 12 + messagesSinceFire: number; 13 + } 14 + 15 + export class RecurringCommandHandler { 16 + private bot: StreamplaceBot; 17 + private recurring: Map<ResourceUri, ActiveTimer> = new Map(); 18 + private loaded = false; 19 + 20 + constructor(bot: StreamplaceBot) { 21 + this.bot = bot; 22 + } 23 + 24 + async init(): Promise<void> { 25 + if (this.loaded) return; 26 + await this.loadRecurringCommands(); 27 + this.loaded = true; 28 + } 29 + 30 + private async loadRecurringCommands(): Promise<void> { 31 + const streamer = await this.bot.getUserProfile( 32 + this.bot.getStreamerDid(), 33 + ); 34 + try { 35 + const { records } = await listRecords(streamer.pdsEndpoint, { 36 + repo: this.bot.getStreamerDid(), 37 + collection: "online.timtinkers.bot.recurring", 38 + }); 39 + 40 + for (const record of records) { 41 + if ( 42 + !is(OnlineTimtinkersBotRecurring.mainSchema, record.value) 43 + ) continue; 44 + this.scheduleTimer(record.uri, record.value); 45 + } 46 + 47 + console.log(`Loaded ${this.recurring.size} recurring commands`); 48 + } catch (error) { 49 + console.error("Error loading recurring commands:", error); 50 + } 51 + } 52 + 53 + private scheduleTimer( 54 + uri: ResourceUri, 55 + record: OnlineTimtinkersBotRecurring.Main, 56 + ): void { 57 + // Clear any existing timer for this URI first (handles updates) 58 + this.clearTimer(uri); 59 + 60 + const trigger = record.trigger.toLowerCase(); 61 + const intervalMs = record.intervalSeconds 62 + ? record.intervalSeconds * 1000 63 + : 60 * 1000; 64 + 65 + const intervalId = setInterval(async () => { 66 + const timer = this.recurring.get(uri); 67 + if (!timer) return; 68 + if (!this.bot.streamerIsLive()) return; 69 + 70 + // Enforce minimum message threshold 71 + if (timer.messagesSinceFire < timer.minMessages) return; 72 + 73 + const command = this.bot.getCommandHandler().getCommand(trigger); 74 + if (!command) return; 75 + 76 + // Fire with a synthetic event — recurring commands have no real sender 77 + try { 78 + await command(this.makeSyntheticEvent(), [], this.bot); 79 + timer.messagesSinceFire = 0; 80 + } catch (error) { 81 + console.error( 82 + `Error firing recurring command "${trigger}":`, 83 + error, 84 + ); 85 + } 86 + }, intervalMs); 87 + 88 + this.recurring.set(uri, { 89 + trigger, 90 + intervalSeconds: record.intervalSeconds || 60, 91 + minMessages: record.minMessages || 0, 92 + intervalId, 93 + messagesSinceFire: 0, 94 + }); 95 + } 96 + 97 + private clearTimer(uri: ResourceUri): void { 98 + const existing = this.recurring.get(uri); 99 + if (existing) { 100 + clearInterval(existing.intervalId); 101 + this.recurring.delete(uri); 102 + } 103 + } 104 + 105 + // Called by StreamplaceBot.processMessage on every incoming message 106 + incrementMessageCount(): void { 107 + for (const timer of this.recurring.values()) { 108 + timer.messagesSinceFire++; 109 + } 110 + } 111 + 112 + // Called from EventHandler for live create/update 113 + handleUpsert( 114 + uri: ResourceUri, 115 + record: OnlineTimtinkersBotRecurring.Main, 116 + ): void { 117 + if (!is(OnlineTimtinkersBotRecurring.mainSchema, record)) return; 118 + this.scheduleTimer(uri, record); 119 + // Reset greeting timer on update so it fires fresh 120 + console.log( 121 + `Recurring command "${record.trigger}" scheduled (${record.intervalSeconds}s, min ${record.minMessages} msgs)`, 122 + ); 123 + } 124 + 125 + // Called from EventHandler for live delete 126 + handleDelete(uri: ResourceUri): void { 127 + this.clearTimer(uri); 128 + console.log(`Recurring command removed: ${uri}`); 129 + } 130 + 131 + destroy(): void { 132 + for (const uri of this.recurring.keys()) { 133 + this.clearTimer(uri); 134 + } 135 + } 136 + 137 + // Recurring commands fire "from" the bot itself — no real chatter event exists. 138 + // Commands that reference event.did (e.g. parameterized commands using the 139 + // sender's handle as a default) will get the bot's own DID here. 140 + private makeSyntheticEvent(): MessageCreateEvent { 141 + return { 142 + did: this.bot.getBotDid()!, 143 + time_us: 0, 144 + kind: "commit", 145 + commit: { 146 + rev: "", 147 + operation: "create", 148 + collection: "place.stream.chat.message", 149 + rkey: "", 150 + record: { 151 + $type: "place.stream.chat.message", 152 + text: "", 153 + streamer: this.bot.getStreamerDid(), 154 + createdAt: "", 155 + }, 156 + cid: "", 157 + }, 158 + }; 159 + } 160 + }
+14 -9
utils/streamplaceBot.ts
··· 12 12 PlaceStreamRichtextFacet, 13 13 } from "./lexicons/index.ts"; 14 14 import { buildRichtext } from "./richtextUtils.ts"; 15 + import { RecurringCommandHandler } from "./recurringCommandHandler.ts"; 15 16 16 17 class StreamplaceBot { 17 18 private streamerDid: Did; ··· 19 20 private enabled: boolean; 20 21 private client: AtprotoClient; 21 22 private commandHandler: CommandHandler = new CommandHandler(this); 23 + private recurringCommandHandler: RecurringCommandHandler = 24 + new RecurringCommandHandler(this); 22 25 23 26 // Caching 24 27 private moderators: Did[] = []; ··· 49 52 50 53 // Initialize the bot - must be called before use 51 54 async init(): Promise<void> { 52 - // Initialize the AT Protocol client 55 + // Initialization 53 56 await this.client.init(); 54 - 55 - // Load moderators, blocks & shoutouts into cache 56 57 await this.loadModerators(); 57 58 await this.loadShoutouts(); 58 - 59 - // Initialize the command handler 60 59 await this.commandHandler.init(); 60 + await this.recurringCommandHandler.init(); 61 61 62 62 const streamer = await this.getUserProfile(this.streamerDid); 63 63 console.log( ··· 131 131 // Process an incoming chat message and respond if it's a command 132 132 async processMessage(event: CommitEvent): Promise<void> { 133 133 if (!this.enabled) return; 134 - if (event.commit.operation !== "create") { 135 - return; 136 - } 134 + if (event.commit.operation !== "create") return; 137 135 138 136 const streamer = await this.getUserProfile(this.streamerDid); 139 - // TODO: process follows, teleports as well 140 137 const record = event.commit.record as PlaceStreamChatMessage.Main; 141 138 const text = record.text.trim(); 142 139 143 140 // Get or cache chatter information 144 141 const chatter = await this.getUserProfile(event.did); 142 + 143 + // Increment timed command counters on every real message 144 + this.recurringCommandHandler.incrementMessageCount(); 145 145 146 146 // Auto-greet first-time chatters with shoutout if they have one 147 147 const lastGreeting = this.hasBeenGreeted.get(event.did); ··· 252 252 return this.moderators; 253 253 } 254 254 255 + getTimedCommandHandler(): RecurringCommandHandler { 256 + return this.recurringCommandHandler; 257 + } 258 + 255 259 getShoutout(did: Did): OnlineTimtinkersBotShoutout.Main | undefined { 256 260 return this.shoutouts.get(did); 257 261 } ··· 278 282 } 279 283 280 284 setEnabled(enabled: boolean): void { 285 + if (!enabled) this.recurringCommandHandler.destroy(); 281 286 this.enabled = enabled; 282 287 } 283 288