diff --git a/docs/parser/args-parser-complete.yml b/docs/parser/args-parser-complete.yml index e441124cd..558aaed6d 100644 --- a/docs/parser/args-parser-complete.yml +++ b/docs/parser/args-parser-complete.yml @@ -14,4 +14,4 @@ data: - key: body operator: every values: - - string: "!!c" \ No newline at end of file + - regexp: !regexp /!!c/ \ No newline at end of file diff --git a/docs/style.md b/docs/style.md index de8a26fff..7d88b4d7c 100644 --- a/docs/style.md +++ b/docs/style.md @@ -14,6 +14,7 @@ This document covers Typescript and YAML style, explains some lint rules, and ma - [Typescript](#typescript) - [Arrays](#arrays) - [Arrow Functions ("lambdas")](#arrow-functions-%22lambdas%22) + - [Async](#async) - [Constructors](#constructors) - [Destructuring](#destructuring) - [Entities](#entities) @@ -27,7 +28,7 @@ This document covers Typescript and YAML style, explains some lint rules, and ma - [Return Types](#return-types) - [Ternaries](#ternaries) - [Tests](#tests) - - [Async](#async) + - [Async Tests](#async-tests) - [Assertions](#assertions) - [YAML](#yaml) @@ -111,6 +112,13 @@ If the body is a single statement, a function call, or otherwise fits well on a If the body returns an object literal or needs more than one line (excluding nested object literals), braces MUST be used. +### Async + +Async code MUST use promises. Callbacks MUST be wrapped to create and resolve (or reject) a promise. + +Functions returning a promise SHOULD be `async` and `await` MAY be used inside them, but MAY also return plain promises +when no `await` is needed. + ### Constructors Classes should have a constructor if it contains more than a `super(options)` call. @@ -198,7 +206,7 @@ Ternaries MUST NOT be nested. Typescript tests (small, unit tests) are run using Mocha and Chai. -#### Async +#### Async Tests Wrap any tests using async resources (promises, observables, the bot, services, pretty much anything) in the `describeAsync` and `itAsync` helpers. These will track and report leaking async resources. diff --git a/src/controller/AuthController.ts b/src/controller/AuthController.ts index e4a7c3060..38c5a5bbd 100644 --- a/src/controller/AuthController.ts +++ b/src/controller/AuthController.ts @@ -7,8 +7,7 @@ import { Token } from 'src/entity/auth/Token'; import { User } from 'src/entity/auth/User'; import { UserRepository } from 'src/entity/auth/UserRepository'; import { Command, CommandVerb } from 'src/entity/Command'; -import { Message } from 'src/entity/Message'; -import { TYPE_JSON, TYPE_TEXT } from 'src/utils/Mime'; +import { InvalidArgumentError } from 'src/error/InvalidArgumentError'; import { BaseController } from './BaseController'; import { NOUN_FRAGMENT } from './CompletionController'; @@ -62,7 +61,7 @@ export class AuthController extends BaseController implement case NOUN_USER: return this.handleUser(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported noun: ${cmd.noun}`)); + return this.reply(cmd.context, `unsupported noun: ${cmd.noun}`); } } @@ -73,7 +72,7 @@ export class AuthController extends BaseController implement case CommandVerb.List: return this.listPermissions(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported verb: ${cmd.verb}`)); + return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`); } } @@ -86,7 +85,7 @@ export class AuthController extends BaseController implement case CommandVerb.List: return this.listRoles(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported verb: ${cmd.verb}`)); + return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`); } } @@ -97,7 +96,7 @@ export class AuthController extends BaseController implement case CommandVerb.Get: return this.getSession(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported verb: ${cmd.verb}`)); + return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`); } } @@ -112,7 +111,7 @@ export class AuthController extends BaseController implement case CommandVerb.List: return this.listTokens(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported verb: ${cmd.verb}`)); + return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`); } } @@ -125,7 +124,7 @@ export class AuthController extends BaseController implement case CommandVerb.Update: return this.updateUser(cmd); default: - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `unsupported verb: ${cmd.verb}`)); + return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`); } } @@ -134,7 +133,7 @@ export class AuthController extends BaseController implement const results = permissions.map((p) => { return `${p}: \`${cmd.context.checkGrants([p])}\``; }).join('\n'); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, results)); + return this.reply(cmd.context, results); } public async listPermissions(cmd: Command): Promise { @@ -142,7 +141,7 @@ export class AuthController extends BaseController implement const results = permissions.map((p) => { return `${p}: \`${cmd.context.listGrants([p])}\``; }).join('\n'); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, results)); + return this.reply(cmd.context, results); } public async createRole(cmd: Command): Promise { @@ -152,7 +151,7 @@ export class AuthController extends BaseController implement grants, name, }); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(role))); + return this.reply(cmd.context, role.toString()); } public async getRole(cmd: Command): Promise { @@ -162,18 +161,22 @@ export class AuthController extends BaseController implement name, }, }); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(role))); + if (role) { + return this.reply(cmd.context, role.toString()); + } else { + return this.reply(cmd.context, 'role not found'); + } } public async listRoles(cmd: Command): Promise { const roles = await this.roleRepository.createQueryBuilder('role').getMany(); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(roles))); + const roleText = roles.map((r) => r.toString()).join('\n'); + return this.reply(cmd.context, roleText); } public async createToken(cmd: Command): Promise { if (!cmd.context.user) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, MSG_SESSION_REQUIRED)); - return; + return this.reply(cmd.context, MSG_SESSION_REQUIRED); } const grants = cmd.getOrDefault('grants', []); @@ -191,25 +194,17 @@ export class AuthController extends BaseController implement })); const jwt = token.sign(this.data.token.secret); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(token))); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, jwt)); + await this.reply(cmd.context, token.toString()); + return this.reply(cmd.context, jwt); } public async deleteTokens(cmd: Command): Promise { if (!cmd.context.user) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, MSG_SESSION_REQUIRED)); - return; + return this.reply(cmd.context, MSG_SESSION_REQUIRED); } if (cmd.getHeadOrDefault('confirm', 'no') !== 'yes') { - await this.bot.emitCommand(new Command({ - context: cmd.context, - data: {}, - labels: {}, - noun: NOUN_FRAGMENT, - verb: CommandVerb.Create, - })); - return; + return this.requestCompletion(cmd, 'confirm', `please confirm deleting all tokens for ${cmd.context.user.id}`); } const results = await this.tokenRepository.delete({ @@ -217,9 +212,9 @@ export class AuthController extends BaseController implement }); if (results.affected) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `deleted ${results.affected} tokens`)); + return this.reply(cmd.context, `deleted ${results.affected} tokens`); } else { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `no tokens deleted`)); + return this.reply(cmd.context, `no tokens deleted`); } } @@ -230,24 +225,22 @@ export class AuthController extends BaseController implement audience: this.data.token.audience, issuer: this.data.token.issuer, }); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(data))); + return this.reply(cmd.context, JSON.stringify(data)); } catch (err) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `error verifying token: ${err.message}`)); + return this.reply(cmd.context, `error verifying token: ${err.message}`); } } else { if (!cmd.context.token) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, 'session must be provided by a token')); - return; + return this.reply(cmd.context, 'session must be provided by a token'); + } else { + return this.reply(cmd.context, cmd.context.token.toString()); } - - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(cmd.context.token))); } } public async listTokens(cmd: Command): Promise { if (!cmd.context.user) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, MSG_SESSION_REQUIRED)); - return; + return this.reply(cmd.context, MSG_SESSION_REQUIRED); } const tokens = await this.tokenRepository.find({ @@ -256,7 +249,7 @@ export class AuthController extends BaseController implement }, }); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(tokens))); + return this.reply(cmd.context, JSON.stringify(tokens)); } public async createUser(cmd: Command): Promise { @@ -277,7 +270,7 @@ export class AuthController extends BaseController implement })); this.logger.debug({ user }, 'created user'); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, `created user: ${user.id}`)); + return this.reply(cmd.context, user.toString()); } public async getUser(cmd: Command): Promise { @@ -288,7 +281,7 @@ export class AuthController extends BaseController implement }, }); await this.userRepository.loadRoles(user); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_JSON, JSON.stringify(user))); + return this.reply(cmd.context, user.toString()); } public async updateUser(cmd: Command): Promise { @@ -307,7 +300,7 @@ export class AuthController extends BaseController implement }); user.roles = roles; const updatedUser = await this.userRepository.save(user); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, JSON.stringify(updatedUser))); + return this.reply(cmd.context, updatedUser.toString()); } public async createSession(cmd: Command): Promise { @@ -320,16 +313,35 @@ export class AuthController extends BaseController implement const session = await cmd.context.source.createSession(cmd.context.uid, user); this.logger.debug({ session, user, userName: name }, 'created session'); - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, 'created session')); + return this.reply(cmd.context, 'created session'); } public async getSession(cmd: Command): Promise { const session = cmd.context.source.getSession(cmd.context.uid); if (isNil(session)) { - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_TEXT, 'cannot get sessions unless logged in')); - return; + return this.reply(cmd.context, 'cannot get sessions unless logged in'); + } + + return this.reply(cmd.context, session.toString()); + } + + protected async requestCompletion(cmd: Command, key: string, msg: string): Promise { + if (!cmd.context.parser) { + throw new InvalidArgumentError('command has no parser to prompt for completion'); } - await this.bot.sendMessage(Message.reply(cmd.context, TYPE_JSON, session.toString())); + await this.bot.emitCommand(new Command({ + context: cmd.context, + data: { + key: [key], + msg: [msg], + noun: [cmd.noun], + parser: [cmd.context.parser.id], + verb: [cmd.verb], + }, + labels: {}, + noun: NOUN_FRAGMENT, + verb: CommandVerb.Create, + })); } } diff --git a/src/controller/BaseController.ts b/src/controller/BaseController.ts index e7ce9c69e..4c7ca579e 100644 --- a/src/controller/BaseController.ts +++ b/src/controller/BaseController.ts @@ -1,11 +1,14 @@ import { ChildService } from 'src/ChildService'; import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller'; -import { Command } from 'src/entity/Command'; +import { Command, CommandVerb } from 'src/entity/Command'; +import { Context } from 'src/entity/Context'; import { Message } from 'src/entity/Message'; +import { InvalidArgumentError } from 'src/error/InvalidArgumentError'; import { checkFilter, Filter } from 'src/filter/Filter'; import { ServiceModule } from 'src/module/ServiceModule'; import { getLogInfo, ServiceDefinition } from 'src/Service'; import { Transform, TransformData } from 'src/transform/Transform'; +import { TYPE_TEXT } from 'src/utils/Mime'; export type BaseControllerOptions = ControllerOptions; @@ -80,4 +83,8 @@ export abstract class BaseController extends Child } return batch; } + + protected async reply(ctx: Context, body: string): Promise { + await this.bot.sendMessage(Message.reply(ctx, TYPE_TEXT, body)); + } } diff --git a/src/controller/CompletionController.ts b/src/controller/CompletionController.ts index d5b85a9be..8162933cd 100644 --- a/src/controller/CompletionController.ts +++ b/src/controller/CompletionController.ts @@ -17,7 +17,7 @@ export const NOUN_FRAGMENT = 'fragment'; export type CompletionControllerData = any; export type CompletionControllerOptions = ControllerOptions; -@Inject('storage') +@Inject('bot', 'services', 'storage') export class CompletionController extends BaseController implements Controller { protected storage: Connection; protected fragmentRepository: Repository; diff --git a/src/entity/Context.ts b/src/entity/Context.ts index 4b7206cde..1d7266c44 100644 --- a/src/entity/Context.ts +++ b/src/entity/Context.ts @@ -22,8 +22,12 @@ export interface ContextData { */ name: string; + parser?: Parser; + source: Listener; + target?: Listener; + token?: Token; /** @@ -73,7 +77,9 @@ export class Context implements ContextData { thread: options.channel.thread, }; this.name = options.name; + this.parser = options.parser; this.source = options.source; + this.target = options.target; this.token = options.token; this.uid = options.uid; this.user = options.user; @@ -82,6 +88,12 @@ export class Context implements ContextData { public extend(options: Partial): Context { const ctx = new Context(this); + if (options.parser) { + ctx.parser = options.parser; + } + if (options.target) { + ctx.target = options.target; + } if (options.token) { ctx.token = options.token; } diff --git a/src/module/MigrationModule.ts b/src/module/MigrationModule.ts index 53b66774a..73025a191 100644 --- a/src/module/MigrationModule.ts +++ b/src/module/MigrationModule.ts @@ -6,6 +6,7 @@ import { CreateCommand0001544311565 } from 'src/migration/0001544311565-CreateCo import { CreateMessage0001544311687 } from 'src/migration/0001544311687-CreateMessage'; import { CreateKeyword0001544311784 } from 'src/migration/0001544311784-CreateKeyword'; import { CreateCounter0001544311799 } from 'src/migration/0001544311799-CreateCounter'; +import { CreateFragment0001544311954 } from 'src/migration/0001544311954-CreateFragment'; import { CreateRole0001544312069 } from 'src/migration/0001544312069-CreateRole'; import { CreateUser0001544312112 } from 'src/migration/0001544312112-CreateUser'; import { CreateToken0001544317462 } from 'src/migration/0001544317462-CreateToken'; @@ -20,6 +21,7 @@ export class MigrationModule extends Module { CreateMessage0001544311687, CreateKeyword0001544311784, CreateCounter0001544311799, + CreateFragment0001544311954, CreateRole0001544312069, CreateUser0001544312112, CreateToken0001544317462, diff --git a/src/parser/ArgsParser.ts b/src/parser/ArgsParser.ts index bd4eae5b1..6a23a0a30 100644 --- a/src/parser/ArgsParser.ts +++ b/src/parser/ArgsParser.ts @@ -6,7 +6,7 @@ import { Command, CommandDataValue, CommandVerb } from 'src/entity/Command'; import { Context } from 'src/entity/Context'; import { Fragment } from 'src/entity/Fragment'; import { Message } from 'src/entity/Message'; -import { Dict, dictToMap, dictValuesToArrays, getHeadOrDefault, mergeMap } from 'src/utils/Map'; +import { Dict, dictToMap, dictValuesToArrays, getHeadOrDefault, mergeMap, getHead } from 'src/utils/Map'; import { BaseParser } from './BaseParser'; import { Parser, ParserData, ParserOptions } from './Parser'; @@ -60,7 +60,6 @@ export class ArgsParser extends BaseParser implements Parser { this.logger.debug({ missing }, 'missing required arguments, emitting completion'); return this.emitCompletion(context, data, missing); } else { - this.logger.debug('required arguments present, emitting command'); return this.emitCommand(context, data); } } @@ -92,12 +91,16 @@ export class ArgsParser extends BaseParser implements Parser { return []; } - protected async emitCommand(context: Context, data: Map>): Promise> { - const noun = getHeadOrDefault(data, 'noun', this.data.emit.noun); - const verb = getHeadOrDefault(data, 'verb', this.data.emit.verb) as CommandVerb; + protected async emitCommand(msgCtx: Context, data: Map>): Promise> { + const ctx = msgCtx.extend({ + parser: this, + }); + const noun = getHead(data, 'noun'); //OrDefault(data, 'noun', this.data.emit.noun); + const verb = getHead(data, 'verb') as CommandVerb; //OrDefault(data, 'verb', this.data.emit.verb) as CommandVerb; + this.logger.debug({ ctx, noun, verb }, 'emit command'); return [new Command({ - context, + context: ctx, data, labels: this.data.emit.labels, noun, diff --git a/src/parser/EchoParser.ts b/src/parser/EchoParser.ts index a8be53e8d..84aca3f87 100644 --- a/src/parser/EchoParser.ts +++ b/src/parser/EchoParser.ts @@ -28,7 +28,9 @@ export class EchoParser extends BaseParser implements Parser { public async parse(msg: Message): Promise> { const parsed = await this.decode(msg); return [new Command({ - context: msg.context, + context: msg.context.extend({ + parser: this, + }), data: { [this.data.args.field]: [parsed], }, diff --git a/src/parser/LexParser.ts b/src/parser/LexParser.ts index 542f569a2..1446be1bf 100644 --- a/src/parser/LexParser.ts +++ b/src/parser/LexParser.ts @@ -60,7 +60,9 @@ export class LexParser extends BaseParser implements Parser { public async parse(msg: Message): Promise> { const { data, noun } = await this.decode(msg); const cmdOptions: CommandOptions = { - context: msg.context, + context: msg.context.extend({ + parser: this, + }), data, labels: this.data.emit.labels, noun, diff --git a/src/parser/RegexParser.ts b/src/parser/RegexParser.ts index 7b0543841..a34673f50 100644 --- a/src/parser/RegexParser.ts +++ b/src/parser/RegexParser.ts @@ -24,7 +24,9 @@ export class RegexParser extends BaseParser implements Parser { const data = await this.decode(msg); return [new Command({ - context: msg.context, + context: msg.context.extend({ + parser: this, + }), data: { data }, // @TODO: double data doesn't seem right labels: this.data.emit.labels, noun: this.data.emit.noun, diff --git a/src/parser/SplitParser.ts b/src/parser/SplitParser.ts index 63d8d9ed6..1fc724837 100644 --- a/src/parser/SplitParser.ts +++ b/src/parser/SplitParser.ts @@ -26,7 +26,9 @@ export class SplitParser extends BaseParser implements Parser { public async parse(msg: Message): Promise> { const args = await this.decode(msg); this.logger.debug({ args }, 'splitting string'); - return [Command.emit(this.data.emit, msg.context, { args })]; + return [Command.emit(this.data.emit, msg.context.extend({ + parser: this, + }), { args })]; } public async decode(msg: Message): Promise { diff --git a/src/parser/YamlParser.ts b/src/parser/YamlParser.ts index 33ac4e26e..fe405892f 100644 --- a/src/parser/YamlParser.ts +++ b/src/parser/YamlParser.ts @@ -19,7 +19,9 @@ export class YamlParser extends BaseParser implements Parser { public async parse(msg: Message): Promise> { const data = await this.decode(msg); - return [Command.emit(this.data.emit, msg.context, data)]; + return [Command.emit(this.data.emit, msg.context.extend({ + parser: this, + }), data)]; } public async decode(msg: Message): Promise { diff --git a/src/utils/Map.ts b/src/utils/Map.ts index 241c8d1c7..fa1168185 100644 --- a/src/utils/Map.ts +++ b/src/utils/Map.ts @@ -37,6 +37,14 @@ export function getOrDefault(map: Map, key: TKey, defaul } } +export function getHead(map: Map>, key: TKey): TVal { + const value = map.get(key); + if (isNil(value) || !value.length) { + throw new NotFoundError(); + } + return value[0]; +} + export function getHeadOrDefault(map: Map>, key: TKey, defaultValue: TVal): TVal { if (map.has(key)) { const data = map.get(key);