Something went wrong. Try again.
A work-in-progress chat bot for Streamplace with chat overlay functionality
Something went wrong. Try again.
16 kB · 538 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539import { didResolver } from "./didResolver.ts";import { buildRichtext } from "./richtextUtils.ts";import type StreamplaceBot from "./streamplaceBot.ts";import type { MessageCreateEvent } from "./commandHandler.ts";import { Blob, ResourceUri } from "@atcute/lexicons";import { customVariablesAtUris } from "../main.ts";import { OnlineTimtinkersBotCommand } from "./lexicons/index.ts";import { streamplaceWS } from "./websocket.ts";import { getManyToManyCounts } from "./constellationUtils.ts";import { Cid } from "@atcute/lexicons/syntax";
// Mirrors the lexicon's permissionLevel tokensexport type PermissionLevel = "streamer" | "moderator" | "vip" | "default";
// A permission entry is either a role token or an individual user's DIDexport type PermissionEntry = PermissionLevel | Did;
export interface BotCommandOptions { trigger: string; // Additional triggers that invoke the same command (e.g. "so" for "shoutout") aliases?: string[]; // AT-URI of the online.timtinkers.bot.command record, if this is a custom // command. Undefined for built-in default commands. uri?: ResourceUri; // Either a response template (custom commands) or a handler (default commands) response?: string; handler?: ( event: MessageCreateEvent, args: string[], bot: StreamplaceBot, ) => Promise<void> | void; description?: string; isReply?: boolean; hidden?: boolean; // Whether the command is enabled at all. Defaults to true. Intended for // streamers to toggle default commands off via a settings page. enabled?: boolean; permissions?: PermissionEntry[]; userCooldown?: number; // seconds globalCooldown?: number; // seconds customVariables?: Map<string, ResourceUri>; media?: OnlineTimtinkersBotCommand.MediaAttachment;}
export class BotCommand { readonly trigger: string; readonly aliases: string[]; readonly uri: ResourceUri | undefined; readonly description: string | undefined; readonly isReply: boolean; readonly hidden: boolean; readonly enabled: boolean; readonly permissions: PermissionEntry[]; readonly userCooldown: number; readonly globalCooldown: number; readonly customVariables?: Map<string, ResourceUri>; readonly media: OnlineTimtinkersBotCommand.MediaAttachment | undefined;
private readonly response: string | undefined; private readonly handler: BotCommandOptions["handler"] | undefined;
// Cooldown tracking private lastGlobalFire: number = 0; private lastUserFire: Map<string, number> = new Map();
// Seeded lazily from Constellation on first execution of a command whose // response template references $globalCount or $userCount, then maintained // in-memory for the lifetime of the process. private totalCount: number = 0; private userCounts: Map<Did, number> = new Map(); // True once counters have been seeded from Constellation. private countersLoaded: boolean = false;
constructor(options: BotCommandOptions) { if (!options.response && !options.handler && !options.media) { throw new Error( `BotCommand "${options.trigger}" needs at least a response, a handler, or a media attachment.`, ); }
this.trigger = options.trigger.toLowerCase(); this.aliases = (options.aliases ?? []).map((alias) => alias.toLowerCase() ); this.uri = options.uri; this.response = options.response; this.handler = options.handler; this.description = options.description; this.isReply = options.isReply ?? false; this.hidden = options.hidden ?? false; this.enabled = options.enabled ?? true; this.permissions = options.permissions ?? ["streamer", "moderator", "vip", "default"]; this.userCooldown = options.userCooldown ?? 0; this.globalCooldown = options.globalCooldown ?? 0; this.customVariables = options.customVariables; this.media = options.media; }
// True when this command only plays media with no text response or handler. get isMediaOnly(): boolean { return this.media !== undefined && !this.response && !this.handler; }
// Whether the response template for this command references either counter // variable. Used to decide whether to lazily load counters from Constellation. private get usesCounters(): boolean { return !!this.response && (this.response.includes("$globalCount") || this.response.includes("$userCount")); }
getTotalCount(): number { return this.totalCount; }
getUserCount(did: Did): number { return this.userCounts.get(did) ?? 0; }
// Permission check isAllowed(userDid: Did, bot: StreamplaceBot): boolean { // If unrestricted, allow everyone if (this.permissions.includes("default")) return true;
// Individual user grants by DID if (this.permissions.includes(userDid)) return true;
const streamerDid = bot.getStreamerDid(); if (userDid === streamerDid && this.permissions.includes("streamer")) { return true; } if ( bot.getModerators().getByKey(userDid) && this.permissions.includes("moderator") ) return true; // VIP would need its own lookup — placeholder for now return false; }
// Cooldown check isOnCooldown( userDid: string, ): { onCooldown: boolean; remainingSeconds?: number } { const now = Date.now();
if (this.globalCooldown > 0) { const elapsed = (now - this.lastGlobalFire) / 1000; if (elapsed < this.globalCooldown) { return { onCooldown: true, remainingSeconds: Math.ceil(this.globalCooldown - elapsed), }; } }
if (this.userCooldown > 0) { const lastFire = this.lastUserFire.get(userDid) ?? 0; const elapsed = (now - lastFire) / 1000; if (elapsed < this.userCooldown) { return { onCooldown: true, remainingSeconds: Math.ceil(this.userCooldown - elapsed), }; } }
return { onCooldown: false }; }
private recordFire(userDid: string): void { const now = Date.now(); this.lastGlobalFire = now; if (this.userCooldown > 0) this.lastUserFire.set(userDid, now); }
/** * Lazily seeds totalCount and userCounts from Constellation on first use. * Subsequent calls are no-ops. Called automatically from run() for commands * whose response template references $globalCount/$userCount, and callable * directly (e.g. by the !count command) to seed counters for a command * on demand, regardless of whether its own template uses them. * * Only the bot's own repo is queried. Including the streamer's repo (and * any other known bot repos) as additional DID entries would give a * complete cross-repo count, but is intentionally omitted for now — the * bot is currently the sole writer of attestation records. */ async ensureCountersLoaded(botDid: Did): Promise<void> { if (this.countersLoaded || !this.uri) return;
try { const result = await getManyToManyCounts( this.uri, "online.timtinkers.bot.attestation:command", "chatter", [botDid], );
// Sum per-chatter totals to derive the global count. // Note: the response is paginated at up to 100 chatters. If a command // has been used by more than 100 distinct chatters, totalCount will be // an undercount until pagination support is added. let total = 0; for (const entry of result.counts_by_other_subject) { this.userCounts.set(entry.subject, entry.total); total += entry.total; } this.totalCount = total; } catch (error) { console.error( `Failed to load counters for command "${this.trigger}" from Constellation:`, error, ); // Leave counters at 0 rather than crashing — the command still fires, // the substituted count will just be wrong for this session. }
// Mark as loaded regardless of success so we don't hammer Constellation // on every invocation if it's having issues. this.countersLoaded = true; }
private incrementCounters(userDid: Did): void { this.totalCount++; this.userCounts.set(userDid, (this.userCounts.get(userDid) ?? 0) + 1); }
// Execution async execute( event: MessageCreateEvent, args: string[], bot: StreamplaceBot, ): Promise<{ cid: Cid; uri: ResourceUri } | undefined> { if (this.handler) { await this.handler(event, args, bot); return; }
// Seed slots up front, shared across text + media const slots: SlotMap = new Map();
let variableMap: Map<string, unknown> | undefined; if (this.customVariables) { variableMap = Array.from(this.customVariables.entries()) .reduce((result, [stringKey, resourceUri]) => { const variable = customVariablesAtUris.get(resourceUri); if (variable) result.set(stringKey, variable.value); return result; }, new Map<string, unknown>()); }
if (this.media?.visuals?.length) { getOrSeedIndex("@visual", this.media.visuals.length, slots); } if (this.media?.audio?.length) { getOrSeedIndex("@audio", this.media.audio.length, slots); }
let responseUriAndCid: { cid: Cid; uri: ResourceUri } | undefined; if (this.response) { const userProfile = await didResolver.resolve(event.did);
if (variableMap) preseedSlots(this.response, variableMap, slots);
const resolved = substituteVariables( this.response, { user: `@${userProfile.handle}`, args, resolvedVariable: variableMap, globalCount: this.totalCount, userCount: this.userCounts.get(event.did) ?? 0, }, slots, ); const { text, facets } = await buildRichtext(resolved); const response = await bot.sendMessage(text, facets); responseUriAndCid = response; }
if (this.media) { const visualIndex = getOrSeedIndex( "@visual", this.media.visuals?.length ?? 0, slots, ); const audioIndex = getOrSeedIndex( "@audio", this.media.audio?.length ?? 0, slots, );
const streamerDid = bot.getStreamerDid(); const pdsHostUrl = (await didResolver.resolve(streamerDid)).pdsEndpoint; streamplaceWS.eventHandler.sendMessageToClients( { $type: "online.timtinkers.bot.defs#mediaView", data: { visual: this.media.visuals?.[visualIndex] ? { url: mediaUrl( pdsHostUrl, streamerDid, (this.media.visuals[visualIndex] as Blob) .ref.$link, ), mimeType: this.media.visuals[visualIndex].mimeType, } : undefined, audio: this.media.audio?.[audioIndex] ? { url: mediaUrl( pdsHostUrl, streamerDid, (this.media.audio[audioIndex] as Blob).ref .$link, ), mimeType: this.media.audio[audioIndex].mimeType, } : undefined, durationMs: this.media.durationMs, }, }, bot.getStreamerDid(), ); } return responseUriAndCid; }
// Entry point called by CommandHandler — handles permission + cooldown gates, // counter tracking, and attestation writing for custom commands. async run( event: MessageCreateEvent, args: string[], bot: StreamplaceBot, ): Promise<void> { if (!this.enabled) return; if (!this.isAllowed(event.did, bot)) return;
const cooldown = this.isOnCooldown(event.did); if (cooldown.onCooldown) { // Silently ignore rather than spamming the chat return; }
this.recordFire(event.did);
// Seed counters from Constellation before execute() so that $globalCount // and $userCount resolve to correct values on first invocation. // Only runs when the response template actually references either variable, // and only for custom commands that have a backing record URI. if (this.uri && this.usesCounters) { const botDid = bot.getBotDid(); if (botDid) await this.ensureCountersLoaded(botDid); }
const response = await this.execute(event, args, bot);
// Increment in-memory counters and write an attestation record. if (this.uri) { this.incrementCounters(event.did); const callUri: ResourceUri = `at://${event.did}/${event.commit.collection}/${event.commit.rkey}`;
await bot.writeAttestiation( this.uri, event.did, callUri, response?.uri, ); } }}
// Variable substitutioninterface SubstitutionContext { user: Handle; args: string[]; resolvedVariable?: Map<string, unknown>; // unknown is the variable value from the online.timtinkers.bot.variable record globalCount: number; userCount: number;}
// Slot map: "@slotName" → array indextype SlotMap = Map<string, number>;
function resolveVariable( name: string, params: string[], ctx: SubstitutionContext, slots: SlotMap,): string | null { switch (name) { case "user": return ctx.user;
case "target": return ctx.args[0] ?? null;
case "arg": { const index = parseInt(params[0], 10); if (isNaN(index) || index < 1) return null; return ctx.args[index - 1] ?? null; }
case "randomNumber": { const min = parseInt(params[0], 10); const max = parseInt(params[1], 10); if (isNaN(min) || isNaN(max)) return null; return String(Math.floor(Math.random() * (max - min + 1)) + min); }
case "randomItem": { // params: [variableName, field?, @slot?] const [varName, field, slotParam] = params; if (!varName || !ctx.resolvedVariable) return null;
const value = ctx.resolvedVariable.get(varName); if (!Array.isArray(value) || value.length === 0) return null;
const slot = slotParam?.startsWith("@") ? slotParam : null;
// Always resolve to an index, then look up the item let index: number; if (slot && slots.has(slot)) { index = slots.get(slot)!; } else { index = Math.floor(Math.random() * value.length); if (slot) slots.set(slot, index); }
const item = value[index];
if (!field) { return typeof item === "string" ? item : null; }
if (typeof item === "object" && item !== null && field in item) { const val = (item as Record<string, unknown>)[field]; return typeof val === "string" ? val : null; }
return null; }
// The attestation is only written after a successful command execution and // only then are the counters incremented, so we optimistically add 1 here case "globalCount": return String(ctx.globalCount + 1);
case "userCount": return String(ctx.userCount + 1);
case "OR": { for (const param of params) { const inner = parseVariableRef(param.trim()); if (inner) { const resolved = resolveVariable( inner.name, inner.params, ctx, slots, ); if (resolved !== null) return resolved; } } return null; }
default: return null; }}
function parseVariableRef( token: string,): { name: string; params: string[] } | null { const match = token.match(/^\$([a-zA-Z]+)(?:\[([^\]]*)\])?$/); if (!match) return null; return { name: match[1], params: match[2] ? match[2].split(",").map((p) => p.trim()) : [], };}
function substituteVariables( template: string, ctx: SubstitutionContext, slots: SlotMap,): string { return template.replace( /\$([a-zA-Z]+)(?:\[([^\]]*)\])?/g, (match, name, rawParams) => { const params = rawParams ? rawParams.split(",").map((p: string) => p.trim()) : []; return resolveVariable(name, params, ctx, slots) ?? match; }, );}
function preseedSlots( template: string, resolvedVariables: Map<string, unknown>, slots: SlotMap,): void { const SLOT_PATTERN = /\$randomItem\[([^\]]*)\]/g; for (const match of template.matchAll(SLOT_PATTERN)) { const params = match[1].split(",").map((p) => p.trim()); const slotParam = params[2]; if (slotParam?.startsWith("@") && !slots.has(slotParam)) { const value = resolvedVariables.get(params[0]); if (Array.isArray(value) && value.length > 0) { slots.set(slotParam, Math.floor(Math.random() * value.length)); } } }}
function getOrSeedIndex(slot: string, length: number, slots: SlotMap): number { if (length === 0) return 0; if (slots.has(slot)) return slots.get(slot)!; const index = Math.floor(Math.random() * length); slots.set(slot, index); return index;}
// Helper function to construct media URLfunction mediaUrl( pdsEndpoint: `${string}:${string}`, did: Did, cid: string,): `${string}:${string}` { return `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;}