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.

New commandHandler class, rngCommand type

+270 -158
+34 -7
lexicons/online/timtinkers/bot/command.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "Record representing a chat bot command. Commands can be simple (static response) or parameterized (template-based with arguments).", 8 + "description": "Record representing a chat bot command. Commands can be simple (static response), parameterized (template-based with arguments), or RNG-based (random number generation).", 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", ··· 13 13 "properties": { 14 14 "trigger": { 15 15 "type": "string", 16 - "description": "The command trigger without the ! prefix (e.g., 'socials', 'hug')", 16 + "description": "The command trigger without prefix (e.g., 'socials', 'hug', 'd6')", 17 17 "maxLength": 50, 18 18 "minLength": 1 19 19 }, 20 20 "commandType": { 21 21 "type": "ref", 22 - "description": "The type of command (simple or parameterized)", 22 + "description": "The type of command (simple, parameterized, or rng)", 23 23 "ref": "#commandType" 24 24 }, 25 25 "response": { ··· 60 60 "minLength": 1, 61 61 "maxLength": 10 62 62 }, 63 - "triggerMessage": { 63 + "rngTemplate": { 64 + "type": "string", 65 + "description": "Template with {result} placeholder for RNG result and optional {paramName} for input (required for rngCommand type)", 66 + "maxLength": 3000, 67 + "maxGraphemes": 300 68 + }, 69 + "rngTemplateFacets": { 70 + "type": "array", 71 + "description": "Annotations for RNG template text", 72 + "items": { 73 + "type": "ref", 74 + "ref": "place.stream.richtext.facet" 75 + } 76 + }, 77 + "rngMin": { 78 + "type": "integer", 79 + "description": "Minimum value for RNG (inclusive, required for rngCommand type)" 80 + }, 81 + "rngMax": { 82 + "type": "integer", 83 + "description": "Maximum value for RNG (inclusive, required for rngCommand type)" 84 + }, 85 + "rngParameter": { 64 86 "type": "ref", 65 - "ref": "com.atproto.repo.strongRef", 66 - "description": "Optional reference to the chat message that triggered the creation of this command" 87 + "ref": "#parameter", 88 + "description": "Optional parameter definition for rngCommand type (e.g., target user for 'stinky' command)" 67 89 }, 68 90 "description": { 69 91 "type": "string", ··· 81 103 "type": "string", 82 104 "knownValues": [ 83 105 "online.timtinkers.bot.command#simpleCommand", 84 - "online.timtinkers.bot.command#parameterizedCommand" 106 + "online.timtinkers.bot.command#parameterizedCommand", 107 + "online.timtinkers.bot.command#rngCommand" 85 108 ] 86 109 }, 87 110 "simpleCommand": { ··· 91 114 "parameterizedCommand": { 92 115 "type": "token", 93 116 "description": "A command that accepts one or more parameters and uses a response template" 117 + }, 118 + "rngCommand": { 119 + "type": "token", 120 + "description": "A command that generates a random number within a specified range and formats it using a template" 94 121 }, 95 122 "parameter": { 96 123 "type": "object",
+172
utils/commandHandler.ts
··· 1 + import { CommitEvent } from "@atcute/jetstream"; 2 + import { is } from "@atcute/lexicons"; 3 + import { listRecords } from "./atcuteUtils.ts"; 4 + import { OnlineTimtinkersBotCommand } from "./lexicons/index.ts"; 5 + import { defaultCommands } from "./commands/defaultCommands.ts"; 6 + import StreamplaceBot from "./streamplaceBot.ts"; 7 + 8 + export interface CommandWrapper { 9 + ( 10 + event: CommitEvent, 11 + params: string[], 12 + bot: StreamplaceBot, 13 + ): Promise<void> | void; 14 + } 15 + 16 + export class CommandHandler { 17 + private bot: StreamplaceBot; 18 + private commands: Map<string, CommandWrapper> = new Map(); 19 + private customCommands: Map<string, CommandWrapper> = new Map(); 20 + private customCommandsLoaded: boolean = false; 21 + 22 + constructor(bot: StreamplaceBot) { 23 + this.bot = bot; 24 + } 25 + 26 + async init(): Promise<void> { 27 + this.registerDefaultCommands(); 28 + await this.registerCustomCommands(); 29 + } 30 + 31 + // Register a command 32 + private registerCommand( 33 + commandName: string, 34 + handler: CommandWrapper, 35 + ): void { 36 + this.commands.set(commandName.toLowerCase(), handler); 37 + } 38 + 39 + // Remove a command 40 + private unregisterCommand(commandName: string): boolean { 41 + return this.commands.delete(commandName.toLowerCase()); 42 + } 43 + 44 + // Register the default set of commands 45 + private registerDefaultCommands(): void { 46 + for (const [name, handler] of defaultCommands) { 47 + this.registerCommand( 48 + name, 49 + (msg, params) => handler(msg, params, this.bot), 50 + ); 51 + } 52 + } 53 + 54 + // Register custom commands from the repository 55 + private async registerCustomCommands(): Promise<void> { 56 + if (this.customCommandsLoaded) return; 57 + 58 + const streamer = await this.bot.getUserProfile( 59 + this.bot.getStreamerDid(), 60 + ); 61 + try { 62 + const customCommandsData = await listRecords( 63 + streamer.pdsEndpoint, 64 + { 65 + repo: this.bot.getStreamerDid(), 66 + collection: "online.timtinkers.bot.command", 67 + }, 68 + ); 69 + 70 + // Register custom commands 71 + for (const record of customCommandsData.records) { 72 + const commandRecord = record.value; 73 + if (!is(OnlineTimtinkersBotCommand.mainSchema, commandRecord)) { 74 + continue; 75 + } 76 + let command: CommandWrapper; 77 + if ( 78 + commandRecord.commandType === 79 + "online.timtinkers.bot.command#simpleCommand" 80 + ) { 81 + command = async ( 82 + _event, 83 + _params, 84 + bot, 85 + ) => { 86 + await bot.sendMessage(commandRecord.response!); 87 + }; 88 + } else if ( 89 + commandRecord.commandType === 90 + "online.timtinkers.bot.command#parameterizedCommand" 91 + ) { 92 + command = async ( 93 + _event, 94 + params, 95 + bot, 96 + ) => { 97 + const responseTemplate = commandRecord 98 + .responseTemplate!; 99 + const placeholders = 100 + responseTemplate.match(/\{([^}]+)\}/g) || 101 + []; 102 + const response = placeholders.reduce( 103 + (responseTemplate, placeholder, i) => { 104 + return responseTemplate.replace( 105 + placeholder, 106 + params[i] || "", 107 + ); 108 + }, 109 + responseTemplate, 110 + ); 111 + await bot.sendMessage(response); 112 + }; 113 + } else if ( 114 + commandRecord.commandType === 115 + "online.timtinkers.bot.command#rngCommand" 116 + ) { 117 + command = async ( 118 + _event, 119 + params, 120 + bot, 121 + ) => { 122 + const result = Math.floor( 123 + Math.random() * 124 + (commandRecord.rngMax! - commandRecord.rngMin! + 125 + 1), 126 + ) + commandRecord.rngMin!; 127 + let response = commandRecord.rngTemplate!; 128 + 129 + // Replace {result} placeholder 130 + response = response.replace( 131 + /{result}/g, 132 + result.toString(), 133 + ); 134 + 135 + // If there's a parameter, parse and substitute it 136 + if (commandRecord.rngParameter) { 137 + response = response.replace( 138 + new RegExp( 139 + `{${commandRecord.rngParameter.name}}`, 140 + "g", 141 + ), 142 + params[0], 143 + ); 144 + } 145 + await bot.sendMessage(response); 146 + }; 147 + } else { 148 + continue; 149 + } 150 + this.registerCommand( 151 + commandRecord.trigger.toLowerCase(), 152 + command, 153 + ); 154 + this.customCommands.set( 155 + commandRecord.trigger.toLowerCase(), 156 + command, 157 + ); 158 + } 159 + 160 + this.customCommandsLoaded = true; 161 + console.log( 162 + `Loaded ${this.customCommands.size} custom commands into cache`, 163 + ); 164 + } catch (error) { 165 + console.error("Error loading custom commands:", error); 166 + } 167 + } 168 + 169 + getCommands(): Map<string, CommandWrapper> { 170 + return this.commands; 171 + } 172 + }
+10 -10
utils/commands/defaultCommands.ts
··· 1 1 import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 2 2 import { getRecord } from "../atcuteUtils.ts"; 3 3 import { didResolver } from "../didResolver.ts"; 4 - import type { CommandHandler } from "../streamplaceBot.ts"; 4 + import type { CommandWrapper } from "../commandHandler.ts"; 5 5 import { AppBskyRichtextFacet } from "@atcute/bluesky"; 6 6 import { is } from "@atcute/lexicons"; 7 7 8 - const commandsCommand: CommandHandler = async (_message, _params, bot) => { 8 + const commandsCommand: CommandWrapper = 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, params, bot) => { 16 + const shoutoutCommand: CommandWrapper = 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; ··· 54 54 }; 55 55 56 56 // Hug command 57 - const hugCommand: CommandHandler = async (message, params, bot) => { 57 + const hugCommand: CommandWrapper = 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; ··· 91 91 } 92 92 }; 93 93 94 - const linkatCommand: CommandHandler = async (_message, _params, bot) => { 94 + const linkatCommand: CommandWrapper = 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, _params, bot) => { 129 + const pronounsCommand: CommandWrapper = 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, _params, bot) => { 160 + const lurkCommand: CommandWrapper = 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, _params, bot) => { 171 + const unlurkCommand: CommandWrapper = 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, _params, bot) => { 182 + const songCommand: CommandWrapper = 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, { ··· 233 233 await bot.sendMessage(text, facets); 234 234 }; 235 235 236 - export const defaultCommands: Map<string, CommandHandler> = new Map([ 236 + export const defaultCommands: Map<string, CommandWrapper> = new Map([ 237 237 ["commands", commandsCommand], 238 238 ["shoutout", shoutoutCommand], 239 239 ["so", shoutoutCommand],
+43 -9
utils/lexicons/types/online/timtinkers/bot/command.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 - import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 5 4 import * as PlaceStreamRichtextFacet from "../../../place/stream/richtext/facet.ts"; 6 5 7 6 const _commandTypeSchema = /*#__PURE__*/ v.string< 8 7 | "online.timtinkers.bot.command#parameterizedCommand" 8 + | "online.timtinkers.bot.command#rngCommand" 9 9 | "online.timtinkers.bot.command#simpleCommand" 10 10 | (string & {}) 11 11 >(); ··· 14 14 /*#__PURE__*/ v.object({ 15 15 $type: /*#__PURE__*/ v.literal("online.timtinkers.bot.command"), 16 16 /** 17 - * The type of command (simple or parameterized) 17 + * The type of command (simple, parameterized, or rng) 18 18 */ 19 19 get commandType() { 20 20 return commandTypeSchema; ··· 80 80 ); 81 81 }, 82 82 /** 83 - * The command trigger without the ! prefix (e.g., 'socials', 'hug') 83 + * Maximum value for RNG (inclusive, required for rngCommand type) 84 + */ 85 + rngMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 86 + /** 87 + * Minimum value for RNG (inclusive, required for rngCommand type) 88 + */ 89 + rngMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 90 + /** 91 + * Optional parameter definition for rngCommand type (e.g., target user for 'stinky' command) 92 + */ 93 + get rngParameter() { 94 + return /*#__PURE__*/ v.optional(parameterSchema); 95 + }, 96 + /** 97 + * Template with {result} placeholder for RNG result and optional {paramName} for input (required for rngCommand type) 98 + * @maxLength 3000 99 + * @maxGraphemes 300 100 + */ 101 + rngTemplate: /*#__PURE__*/ v.optional( 102 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 103 + /*#__PURE__*/ v.stringLength(0, 3000), 104 + /*#__PURE__*/ v.stringGraphemes(0, 300), 105 + ]), 106 + ), 107 + /** 108 + * Annotations for RNG template text 109 + */ 110 + get rngTemplateFacets() { 111 + return /*#__PURE__*/ v.optional( 112 + /*#__PURE__*/ v.array(PlaceStreamRichtextFacet.mainSchema), 113 + ); 114 + }, 115 + /** 116 + * The command trigger without prefix (e.g., 'socials', 'hug', 'd6') 84 117 * @minLength 1 85 118 * @maxLength 50 86 119 */ 87 120 trigger: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 88 121 /*#__PURE__*/ v.stringLength(1, 50), 89 122 ]), 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 123 }), 97 124 ); 98 125 const _parameterSchema = /*#__PURE__*/ v.object({ ··· 129 156 const _parameterizedCommandSchema = /*#__PURE__*/ v.literal( 130 157 "online.timtinkers.bot.command#parameterizedCommand", 131 158 ); 159 + const _rngCommandSchema = /*#__PURE__*/ v.literal( 160 + "online.timtinkers.bot.command#rngCommand", 161 + ); 132 162 const _simpleCommandSchema = /*#__PURE__*/ v.literal( 133 163 "online.timtinkers.bot.command#simpleCommand", 134 164 ); ··· 137 167 type main$schematype = typeof _mainSchema; 138 168 type parameter$schematype = typeof _parameterSchema; 139 169 type parameterizedCommand$schematype = typeof _parameterizedCommandSchema; 170 + type rngCommand$schematype = typeof _rngCommandSchema; 140 171 type simpleCommand$schematype = typeof _simpleCommandSchema; 141 172 142 173 export interface commandTypeSchema extends commandType$schematype {} 143 174 export interface mainSchema extends main$schematype {} 144 175 export interface parameterSchema extends parameter$schematype {} 145 176 export interface parameterizedCommandSchema extends parameterizedCommand$schematype {} 177 + export interface rngCommandSchema extends rngCommand$schematype {} 146 178 export interface simpleCommandSchema extends simpleCommand$schematype {} 147 179 148 180 export const commandTypeSchema = _commandTypeSchema as commandTypeSchema; ··· 150 182 export const parameterSchema = _parameterSchema as parameterSchema; 151 183 export const parameterizedCommandSchema = 152 184 _parameterizedCommandSchema as parameterizedCommandSchema; 185 + export const rngCommandSchema = _rngCommandSchema as rngCommandSchema; 153 186 export const simpleCommandSchema = _simpleCommandSchema as simpleCommandSchema; 154 187 155 188 export type CommandType = v.InferInput<typeof commandTypeSchema>; ··· 158 191 export type ParameterizedCommand = v.InferInput< 159 192 typeof parameterizedCommandSchema 160 193 >; 194 + export type RngCommand = v.InferInput<typeof rngCommandSchema>; 161 195 export type SimpleCommand = v.InferInput<typeof simpleCommandSchema>; 162 196 163 197 declare module "@atcute/lexicons/ambient" {
+11 -132
utils/streamplaceBot.ts
··· 1 - import { AuthLoginOptions } from "@atcute/client"; 2 1 import { CommitEvent } from "@atcute/jetstream"; 3 - import { is } from "@atcute/lexicons"; 2 + import { atprotoClient } from "../main.ts"; 4 3 import { AtprotoClient, listRecords } from "./atcuteUtils.ts"; 4 + import { CommandHandler, type CommandWrapper } from "./commandHandler.ts"; 5 5 import { didResolver } from "./didResolver.ts"; 6 - import { defaultCommands } from "./commands/defaultCommands.ts"; 7 - import { 8 - OnlineTimtinkersBotCommand, 9 - PlaceStreamChatMessage, 10 - } from "./lexicons/index.ts"; 11 - import { atprotoClient } from "../main.ts"; 12 - 13 - export interface CommandHandler { 14 - ( 15 - event: CommitEvent, 16 - params: string[], 17 - bot: StreamplaceBot, 18 - ): Promise<void> | void; 19 - } 6 + import { PlaceStreamChatMessage } from "./lexicons/index.ts"; 20 7 21 8 // Shoutout record from the repository 22 9 interface ShoutoutRecord { ··· 28 15 class StreamplaceBot { 29 16 private streamerDid: Did; 30 17 private commandPrefix: string; 31 - private commands: Map<string, CommandHandler>; 32 18 private enabled: boolean; 33 19 private client: AtprotoClient; 20 + private commandHandler: CommandHandler = new CommandHandler(this); 34 21 35 22 // Caching 36 - private customCommands: Map<string, CommandHandler> = new Map(); 37 - private customCommandsLoaded: boolean = false; 38 23 private moderators: Did[] = []; 39 24 private moderatorsLoaded: boolean = false; 40 25 private shoutouts: Map<Did, ShoutoutRecord> = new Map(); ··· 54 39 ) { 55 40 this.streamerDid = streamerDid; 56 41 this.commandPrefix = commandPrefix; 57 - this.commands = new Map(); 58 42 this.enabled = true; 59 43 this.client = atprotoClient; 60 44 } ··· 68 52 await this.loadModerators(); 69 53 await this.loadShoutouts(); 70 54 71 - // Register commands 72 - this.registerDefaultCommands(); 73 - await this.registerCustomCommands(); 55 + // Initialize the command handler 56 + await this.commandHandler.init(); 74 57 75 58 const streamer = await this.getUserProfile(this.streamerDid); 76 59 console.log( ··· 179 162 const params = parts.slice(1); 180 163 181 164 // Look up and execute command handler 182 - const handler = this.commands.get(commandName); 183 - if (handler) { 165 + const commandWrapper = this.getCommands().get(commandName); 166 + if (commandWrapper) { 184 167 console.log( 185 168 `Executing command: ${commandName} in: ${streamer.handle} from user: ${chatter.handle}`, 186 169 ); 187 170 try { 188 - await handler(event, params, this); 171 + await commandWrapper(event, params, this); 189 172 } catch (error) { 190 173 console.error(`Error executing command ${commandName}:`, error); 191 174 } 192 175 } 193 176 } 194 177 195 - // @param commandName Name of the command (without prefix) 196 - private registerCommand( 197 - commandName: string, 198 - handler: CommandHandler, 199 - ): void { 200 - this.commands.set(commandName.toLowerCase(), handler); 201 - } 202 - 203 - // Remove a command 204 - private unregisterCommand(commandName: string): boolean { 205 - return this.commands.delete(commandName.toLowerCase()); 206 - } 207 - 208 178 // Send a message to the chat 209 179 async sendMessage(text: string, facets?: Facet[]): Promise<void> { 210 180 try { ··· 218 188 } 219 189 } 220 190 221 - // Register the default set of commands 222 - private registerDefaultCommands(): void { 223 - for (const [name, handler] of defaultCommands) { 224 - this.registerCommand( 225 - name, 226 - (msg, params) => handler(msg, params, this), 227 - ); 228 - } 229 - } 230 - 231 - // Register custom commands from the repository 232 - private async registerCustomCommands(): Promise<void> { 233 - if (this.customCommandsLoaded) return; 234 - 235 - const streamer = await this.getUserProfile(this.streamerDid); 236 - try { 237 - const customCommandsData = await listRecords( 238 - streamer.pdsEndpoint, 239 - { 240 - repo: this.streamerDid, 241 - collection: "online.timtinkers.bot.command", 242 - }, 243 - ); 244 - 245 - // Register custom commands 246 - for (const record of customCommandsData.records) { 247 - const commandRecord = record.value; 248 - if (!is(OnlineTimtinkersBotCommand.mainSchema, commandRecord)) { 249 - continue; 250 - } 251 - let command: CommandHandler; 252 - if ( 253 - commandRecord.commandType === 254 - "online.timtinkers.bot.command#simpleCommand" 255 - ) { 256 - if (!commandRecord.response) continue; 257 - command = async ( 258 - _event, 259 - _params, 260 - bot, 261 - ) => { 262 - await bot.sendMessage(commandRecord.response!); 263 - }; 264 - } else if ( 265 - commandRecord.commandType === 266 - "online.timtinkers.bot.command#parameterizedCommand" 267 - ) { 268 - if (!commandRecord.responseTemplate) continue; 269 - command = async ( 270 - _event, 271 - params, 272 - bot, 273 - ) => { 274 - const responseTemplate = commandRecord 275 - .responseTemplate!; 276 - const placeholders = 277 - responseTemplate.match(/\{([^}]+)\}/g) || 278 - []; 279 - const response = placeholders.reduce( 280 - (responseTemplate, placeholder, i) => { 281 - return responseTemplate.replace( 282 - placeholder, 283 - params[i] || "", 284 - ); 285 - }, 286 - responseTemplate, 287 - ); 288 - await bot.sendMessage(response); 289 - }; 290 - } else { 291 - continue; 292 - } 293 - this.registerCommand( 294 - commandRecord.trigger.toLowerCase(), 295 - command, 296 - ); 297 - this.customCommands.set( 298 - commandRecord.trigger.toLowerCase(), 299 - command, 300 - ); 301 - } 302 - 303 - this.customCommandsLoaded = true; 304 - console.log( 305 - `Loaded ${this.customCommands.size} custom commands into cache`, 306 - ); 307 - } catch (error) { 308 - console.error("Error loading custom commands:", error); 309 - } 310 - } 311 - 312 191 // Reload moderators from the repository 313 192 async reloadModerators(): Promise<void> { 314 193 this.moderatorsLoaded = false; ··· 332 211 return this.commandPrefix; 333 212 } 334 213 335 - getCommands(): Map<string, CommandHandler> { 336 - return this.commands; 214 + getCommands(): Map<string, CommandWrapper> { 215 + return this.commandHandler.getCommands(); 337 216 } 338 217 339 218 getModerators(): Did[] {