diff --git a/Makefile b/Makefile index 2fc9346d1..3226ba0c5 100755 --- a/Makefile +++ b/Makefile @@ -63,8 +63,8 @@ help: ## print this help | awk 'BEGIN {FS = ":[^:]*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' git-push: ## push to both gitlab and github (this assumes you have both remotes set up) - git push gitlab ${GIT_BRANCH} git push github ${GIT_BRANCH} + git push gitlab ${GIT_BRANCH} # from https://gist.github.com/amitchhajer/4461043#gistcomment-2349917 git-stats: ## print git contributor line counts (approx, for fun) diff --git a/src/Bot.ts b/src/Bot.ts index d7e604fba..7f01c6260 100644 --- a/src/Bot.ts +++ b/src/Bot.ts @@ -375,9 +375,6 @@ export class Bot extends BaseService implements Service { /** * Log an otherwise-unhandled but non-fatal error (typically leaked from one of the observables). - * - * Note: this method is already bound, so it can be passed with `this.looseError`. Using that requires - * `tslint:disable:no-unbound-method` as well. */ protected looseError(err: Error) { this.logger.error(err, 'bot stream did not handle error'); diff --git a/src/controller/AccountController.ts b/src/controller/AccountController.ts index a54da2bd7..41d188c22 100644 --- a/src/controller/AccountController.ts +++ b/src/controller/AccountController.ts @@ -80,6 +80,11 @@ export class AccountController extends BaseController imp return this.reply(ctx, results); } + @Handler(NOUN_GRANT, CommandVerb.Help) + public async getGrantHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + @Handler(NOUN_ACCOUNT, CommandVerb.Create) public async createAccount(cmd: Command, ctx: Context): Promise { if (!this.data.join.allow && !this.checkGrants(ctx, 'account:create')) { @@ -89,7 +94,7 @@ export class AccountController extends BaseController imp const name = cmd.getHeadOrDefault('name', ctx.name); const existing = await this.userRepository.count({ name }); if (existing > 0) { - return this.reply(ctx, this.locale.translate('service.controller.account.account.create.exists', { + return this.reply(ctx, this.translate('account-create.exists', { name, })); } @@ -106,7 +111,7 @@ export class AccountController extends BaseController imp })); const jwt = await this.createToken(user); - return this.reply(ctx, this.locale.translate('service.controller.account.account.create.success', { + return this.reply(ctx, this.translate('account-create.success', { jwt, name, })); @@ -119,7 +124,7 @@ export class AccountController extends BaseController imp const name = user.name; if (cmd.getHeadOrDefault('confirm', 'no') !== 'yes') { - const completion = createCompletion(cmd, 'confirm', this.locale.translate('service.controller.account.account.delete.confirm', { + const completion = createCompletion(cmd, 'confirm', this.translate('account-delete.confirm', { name, })); await this.bot.executeCommand(completion); @@ -131,12 +136,17 @@ export class AccountController extends BaseController imp }); const jwt = await this.createToken(user); - return this.reply(ctx, this.locale.translate('service.controller.account.account.delete.success', { + return this.reply(ctx, this.translate('account-delete.success', { jwt, name, })); } + @Handler(NOUN_ACCOUNT, CommandVerb.Help) + public async getAccountHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + @Handler(NOUN_SESSION, CommandVerb.Create) public async createSession(cmd: Command, ctx: Context): Promise { const jwt = cmd.getHead('token'); @@ -155,7 +165,7 @@ export class AccountController extends BaseController imp const source = this.getSourceOrFail(ctx); const session = await source.createSession(ctx.uid, user); this.logger.debug({ session, user }, 'created session'); - return this.reply(ctx, this.locale.translate('service.controller.account.session.create.success')); + return this.reply(ctx, this.translate('session-create.success')); } @Handler(NOUN_SESSION, CommandVerb.Get) @@ -171,6 +181,11 @@ export class AccountController extends BaseController imp return this.reply(ctx, session.toString()); } + @Handler(NOUN_SESSION, CommandVerb.Help) + public async getSessionHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + protected async createToken(user: User): Promise { const issued = this.clock.getSeconds(); const expires = issued + this.data.token.duration; diff --git a/src/controller/BaseController.ts b/src/controller/BaseController.ts index d60c01de5..f6feecb83 100644 --- a/src/controller/BaseController.ts +++ b/src/controller/BaseController.ts @@ -6,11 +6,11 @@ import { BotService, INJECT_LOCALE } from 'src/BotService'; import { getHandlerOptions, HandlerOptions } from 'src/controller'; import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller'; import { User } from 'src/entity/auth/User'; -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 { Listener } from 'src/listener/Listener'; -import { Locale } from 'src/locale'; +import { Locale, TranslateOptions } from 'src/locale'; import { ServiceModule } from 'src/module/ServiceModule'; import { ServiceDefinition } from 'src/Service'; import { applyTransforms } from 'src/transform/helpers'; @@ -181,4 +181,23 @@ export abstract class BaseController extends BotSe } return user; } + + protected translate(key: string, options: TranslateOptions = {}): string { + return this.locale.translate(`service.${this.kind}.${key}`, options); + } + + protected defaultHelp(cmd: Command): string { + const data = { + data: this.data, + }; + const desc = this.translate('help.desc', data); + + if (cmd.has('topic')) { + const topic = cmd.getHead('topic'); + const topicDesc = this.translate(`help.${topic}`, data); + return `${desc}\n${topicDesc}`; + } + + return desc; + } } diff --git a/src/controller/CompletionController.ts b/src/controller/CompletionController.ts index 19314f870..91dd7d277 100644 --- a/src/controller/CompletionController.ts +++ b/src/controller/CompletionController.ts @@ -62,7 +62,7 @@ export class CompletionController extends BaseController { + return this.reply(ctx, this.defaultHelp(cmd)); + } + protected async createContext(maybeCtx?: Context) { const ctx = mustExist(maybeCtx); if (isNil(ctx.target)) { diff --git a/src/controller/CountController.ts b/src/controller/CountController.ts index 3c80fc104..16ba3a87f 100644 --- a/src/controller/CountController.ts +++ b/src/controller/CountController.ts @@ -69,6 +69,11 @@ export class CountController extends BaseController impleme await this.counterRepository.save(counter); } + @Handler(NOUN_COUNTER, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + public async findOrCreateCounter(name: string, roomId: string): Promise { const counter = await this.counterRepository.findOne({ where: { diff --git a/src/controller/DiceController.ts b/src/controller/DiceController.ts index 6cccc52b9..12a871a44 100644 --- a/src/controller/DiceController.ts +++ b/src/controller/DiceController.ts @@ -6,6 +6,7 @@ import { CheckRBAC, Handler } from 'src/controller'; import { BaseController } from 'src/controller/BaseController'; import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller'; import { Command, CommandVerb } from 'src/entity/Command'; +import { Context } from 'src/entity/Context'; import { mustExist } from 'src/utils'; const DICE_MINIMUM = 1; @@ -41,9 +42,14 @@ export class DiceController extends BaseController implement this.logger.debug({ count, sides }, 'handling dice results'); const sum = results.reduce((a, b) => a + b, 0); - return this.reply(mustExist(cmd.context), this.locale.translate('service.controller.dice.roll.create', { + return this.reply(mustExist(cmd.context), this.translate('create.success', { results, sum, })); } + + @Handler(NOUN_ROLL, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/EchoController.ts b/src/controller/EchoController.ts index 42a8ad54d..8bb7b58d3 100644 --- a/src/controller/EchoController.ts +++ b/src/controller/EchoController.ts @@ -4,6 +4,7 @@ import { CheckRBAC, Handler } from 'src/controller'; import { BaseController } from 'src/controller/BaseController'; import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller'; import { Command, CommandVerb } from 'src/entity/Command'; +import { Context } from 'src/entity/Context'; export const NOUN_ECHO = 'echo'; @@ -18,8 +19,13 @@ export class EchoController extends BaseController implement @Handler(NOUN_ECHO, CommandVerb.Create) @CheckRBAC() - public async createEcho(cmd: Command): Promise { + public async createEcho(cmd: Command, ctx: Context): Promise { this.logger.debug({ cmd }, 'echoing command'); return this.transformJSON(cmd, {}); } + + @Handler(NOUN_ECHO, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/LearnController.ts b/src/controller/LearnController.ts index 05238c164..9fa885ac2 100644 --- a/src/controller/LearnController.ts +++ b/src/controller/LearnController.ts @@ -62,7 +62,7 @@ export class LearnController extends BaseController impleme } await this.keywordRepository.save(keyword); - return this.reply(ctx, this.locale.translate('service.controller.learn.keyword.create', { + return this.reply(ctx, this.translate('create.success', { key, })); } @@ -83,7 +83,7 @@ export class LearnController extends BaseController impleme id: keyword.id, key, }); - return this.reply(ctx, this.locale.translate('service.controller.learn.keyword.delete', { + return this.reply(ctx, this.translate('delete.success', { key, })); } @@ -115,4 +115,9 @@ export class LearnController extends BaseController impleme await this.bot.executeCommand(merged); return; } + + @Handler(NOUN_KEYWORD, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/MathController.ts b/src/controller/MathController.ts index 2bbf46831..b6014bd98 100644 --- a/src/controller/MathController.ts +++ b/src/controller/MathController.ts @@ -49,6 +49,11 @@ export class MathController extends BaseController implement return this.reply(ctx, body); } + @Handler(NOUN_MATH, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + protected solve(expr: string, scope: object): string { try { const body = this.math.eval(expr, scope); @@ -56,7 +61,7 @@ export class MathController extends BaseController implement return formatResult(body, scope, this.data.format); } catch (err) { - return this.locale.translate('service.controller.math.math.error', { + return this.translate('create.error', { msg: err.message, }); } diff --git a/src/controller/PickController.ts b/src/controller/PickController.ts index 3e5034e76..dac2914c2 100644 --- a/src/controller/PickController.ts +++ b/src/controller/PickController.ts @@ -42,4 +42,9 @@ export class PickController extends BaseController implement this.logger.debug({ count, data, list, puck }, 'picking item'); return this.reply(ctx, puck.join(',')); } + + @Handler(NOUN_PICK, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/SearchController.ts b/src/controller/SearchController.ts index 03d073db1..c86cd1515 100644 --- a/src/controller/SearchController.ts +++ b/src/controller/SearchController.ts @@ -54,4 +54,9 @@ export class SearchController extends BaseController imple return this.transformJSON(cmd, response); } + + @Handler(NOUN_SEARCH, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/SedController.ts b/src/controller/SedController.ts index df6ec60b9..8b67489d4 100644 --- a/src/controller/SedController.ts +++ b/src/controller/SedController.ts @@ -30,7 +30,7 @@ export class SedController extends BaseController implements const parts = expr.match(/\/((?:[^\\]|\\.)*)\/((?:[^\\]|\\.)*)\/([gmiuy]*)/); if (isNil(parts)) { this.logger.debug({ expr }, 'invalid input.'); - return this.reply(ctx, this.locale.translate('service.controller.sed.invalid')); + return this.reply(ctx, this.translate('create.invalid')); } this.logger.debug({ parts }, 'fetching messages'); @@ -47,12 +47,17 @@ export class SedController extends BaseController implements } } - return this.reply(ctx, this.locale.translate('service.controller.sed.missing')); + return this.reply(ctx, this.translate('create.missing')); } catch (error) { this.logger.error('Failed to fetch messages.'); } } + @Handler(NOUN_SED, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + private async processMessage(message: Message, command: Command, parts: RegExpMatchArray): Promise { if (doesExist(message.context) && doesExist(command.context) && message.context.channel.thread === command.context.channel.thread) { return false; diff --git a/src/controller/TimeController.ts b/src/controller/TimeController.ts index b57f7797d..67ef2a673 100644 --- a/src/controller/TimeController.ts +++ b/src/controller/TimeController.ts @@ -36,8 +36,13 @@ export class TimeController extends BaseController implement const zone = cmd.getHeadOrDefault('zone', this.data.zone); this.logger.debug({ locale, time, zone }, 'handling time'); - return this.reply(ctx, this.locale.translate('service.controller.time.get', { + return this.reply(ctx, this.translate('get.success', { time, })); } + + @Handler(NOUN_TIME, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/TokenController.ts b/src/controller/TokenController.ts index 916307650..7789aac5c 100644 --- a/src/controller/TokenController.ts +++ b/src/controller/TokenController.ts @@ -70,7 +70,7 @@ export class TokenController extends BaseController impleme const user = this.getUserOrFail(ctx); const before = cmd.getHeadOrNumber('before', this.clock.getSeconds()); if (cmd.getHeadOrDefault('confirm', 'no') !== 'yes') { - const completion = createCompletion(cmd, 'confirm', this.locale.translate('service.controller.token.delete.confirm', { + const completion = createCompletion(cmd, 'confirm', this.translate('delete.confirm', { before, name: user.name, })); @@ -83,7 +83,7 @@ export class TokenController extends BaseController impleme subject: Equal(mustExist(user.id)), }); - return this.reply(ctx, `tokens deleted`); + return this.reply(ctx, this.translate('delete.success')); } @Handler(NOUN_TOKEN, CommandVerb.Get) @@ -97,13 +97,13 @@ export class TokenController extends BaseController impleme }); return this.reply(ctx, JSON.stringify(data)); } catch (err) { - return this.reply(ctx, this.locale.translate('service.controller.token.get.invalid', { + return this.reply(ctx, this.translate('get.invalid', { msg: err.message, })); } } else { if (isNil(ctx.token)) { - return this.reply(ctx, this.locale.translate('service.controller.token.get.missing')); + return this.reply(ctx, this.translate('get.missing')); } else { return this.reply(ctx, ctx.token.toString()); } @@ -122,4 +122,9 @@ export class TokenController extends BaseController impleme return this.reply(ctx, JSON.stringify(tokens)); } + + @Handler(NOUN_TOKEN, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/UserController.ts b/src/controller/UserController.ts index 2b422d668..1aef95229 100644 --- a/src/controller/UserController.ts +++ b/src/controller/UserController.ts @@ -1,6 +1,6 @@ import { isNil } from 'lodash'; import { Inject } from 'noicejs'; -import { Connection, In, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { INJECT_STORAGE } from 'src/BotService'; import { CheckRBAC, Handler } from 'src/controller'; @@ -54,7 +54,9 @@ export class UserController extends BaseController implement }, }); if (isNil(role)) { - return this.reply(ctx, this.locale.translate('service.controller.user.role.missing')); + return this.reply(ctx, this.translate('role-get.missing', { + name, + })); } else { return this.reply(ctx, role.toString()); } @@ -68,6 +70,11 @@ export class UserController extends BaseController implement return this.reply(ctx, roleText); } + @Handler(NOUN_ROLE, CommandVerb.Help) + public async getRoleHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + @Handler(NOUN_USER, CommandVerb.Create) @CheckRBAC() public async createUser(cmd: Command, ctx: Context): Promise { @@ -126,4 +133,9 @@ export class UserController extends BaseController implement const updatedUser = await this.userRepository.save(user); return this.reply(ctx, updatedUser.toString()); } + + @Handler(NOUN_USER, CommandVerb.Help) + public async getUserHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } } diff --git a/src/controller/WeatherController.ts b/src/controller/WeatherController.ts index 365784bc6..0cfbe3771 100644 --- a/src/controller/WeatherController.ts +++ b/src/controller/WeatherController.ts @@ -45,6 +45,11 @@ export class WeatherController extends BaseController imp } } + @Handler(NOUN_WEATHER, CommandVerb.Help) + public async getHelp(cmd: Command, ctx: Context): Promise { + return this.reply(ctx, this.defaultHelp(cmd)); + } + public async requestWeather(location: string): Promise { const query = this.getQuery(location); this.logger.debug({ location, query, root: this.data.api.root }, 'requesting weather data from API'); diff --git a/src/locale/en.yml b/src/locale/en.yml index 982af5855..2e2044716 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -1,46 +1,168 @@ translation: service: - controller: - account: - account: - create: - exists: "user {{ name }} already exists" - success: "user {{ name }} joined, sign in token: {{ jwt }}" - delete: - confirm: "please confirm deleting all tokens for {{ name }}" - success: "revoked tokens for {{ name }}, new sign in token: {{ jwt }}" - session: - create: - success: signed in - completion: - fragment: - missing: fragment not found - prompt: "complete {{ id }} with {{ key }}: {{ msg }}" - dice: - roll: - create: "the results of your rolls were: {{ results }}. The sum is {{ sum }}" - learn: - keyword: - create: "learned keyword {{ key }}" - delete: "deleted keyword {{ key }}" - math: - math: - error: "error evaluating math: {{ msg }}" - sed: + account-controller: + account-create: + exists: "user {{ name }} already exists" + success: "user {{ name }} joined, sign in token: {{ jwt }}" + account-delete: + confirm: "please confirm deleting all tokens for {{ name }}" + success: "revoked tokens for {{ name }}, new sign in token: {{ jwt }}" + session-create: + success: signed in + help: + warn: Should only be executed in a private channel. + desc: >- + The account controller handles registering new users, sign in for existing + users, revoking sign in tokens, and checking grants. + account-create: + Register a new account and issue a sign in token. + $t(service.account-controller.help.warn) + account-delete: >- + Revoke all tokens for an account. + $t(service.account-controller.help.warn) + grant-get: >- + Check if a grant is allowed. + grant-list: >- + Given a grant with `?` placeholder, list all valid grants for that position. + session-create: >- + Log in with the given token. + $t(service.account-controller.help.warn) + completion-controller: + create: + prompt: "complete {{ id }} with {{ key }}: {{ msg }}" + update: + missing: fragment not found + help: + desc: >- + The completion controller saves fragment commands for users to + complete later. + create: >- + Create a command fragment to be completed and executed later. + Typically executed by other services or parsers upon finding a + missing field. + update: >- + Provide the next field for a fragment and attempt to parse it + again. This may complete and execute the original command or it + may prompt the user to complete another field. + count-controller: + help: + desc: >- + The count controller keeps track of counters for users and messages, + tracking their reputation or score over time. + get: >- + The GET verb is a catch-all for the counter. + The `count` field can be set to `++` or `--` to increment or decrement + a counter, `ls` to list the existing counters (scoped by channel), or + a number (positive or negative integer) by which to change the counter. + dice-controller: + create: + success: "the results of your rolls were: {{ results }}. The sum is {{ sum }}" + help: + desc: >- + The dice controller rolls dice. + create: >- + Create a new set of dice and roll them, replying with each die and the + sum. + echo-controller: + help: + desc: >- + The echo controller transforms the incoming messages and replies to the + user. + create: >- + Create a new echo and reply to the user. + learn-controller: + create: + success: "learned keyword {{ key }}" + delete: + success: "deleted keyword {{ key }}" + help: + desc: >- + The learn controller saves commands to be executed later with a keyword. + create: >- + Save a new command and the keyword that will execute it. + delete: >- + Delete a stored command and keyword. + update: >- + Execute a stored command by keyword. + math-controller: + create: + error: "error evaluating math: {{ msg }}" + help: + desc: >- + The math controller solves mathematical expressions. + create: >- + Create a new expression and solve it. + search-controller: + help: + desc: >- + The search controller runs a search, via REST, and formats the results. + get: >- + Get search results for the keyword in {{ data.field }}. + sed-controller: + create: invalid: "invalid input expression. Please use `!!s/e/d/[flags]`" - missing: no messages found! - time: - get: "current time is: {{ time }}" - token: - delete: - confirm: "please confirm deleting tokens for {{ name }} from before {{ before }}" - success: tokens deleted - get: - invalid: "error validating token: {{ msg }}" - missing: session must have been created with a token - user: - role: - missing: role not found + missing: "no messages found!" + help: + desc: >- + The sed controller performs regex replacement on previous messages and + posts the results. + create: >- + Create a new replacement. This should be a `s/e/d/` expression, with + optional regex flags following. + time-controller: + get: + success: "current time is: {{ time }}" + help: + desc: >- + The time controller is not able to control time, but can report it. + get: >- + Get the time in a particular locale and zone. + token-controller: + delete: + confirm: "please confirm deleting tokens for {{ name }} from before {{ before }}" + success: tokens deleted + get: + invalid: "error validating token: {{ msg }}" + missing: session must have been created with a token + help: + desc: >- + The token controller handles CRUD and validation for JWTs. + create: >- + Create a new token with the specified grants. + delete: >- + Delete tokens from before the specified date. + get: >- + Get payload of the current token or the token field. + list: >- + List tokens for the current user. + user-controller: + role-get: + missing: "role {{ name }} not found" + help: + desc: >- + The user controller provides CRUD operations for users and roles. + role-create: >- + Create a new role with the specified grants. + role-get: >- + Get a role and its grants. + role-list: >- + List the available roles. + user-create: >- + Create a new user with the specified roles. + user-delete: >- + Delete a user. + user-get: >- + Get a user and their roles. + user-list: >- + List the registered users. + user-update: >- + Update a user's roles. + weather-controller: + help: + desc: >- + The weather controller, somewhat like the time controller, reports the weather. + get: >- + Get the current weather report in a location. error: grant: diff --git a/src/locale/index.ts b/src/locale/index.ts index 2da0460d0..7e71f5fb2 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -7,6 +7,8 @@ export interface LocaleOptions { lang: string; } +export type TranslateOptions= i18next.TranslationOptions; + export class Locale implements ServiceLifecycle { protected readonly lang: string; protected translator?: i18next.TranslationFunction; @@ -31,7 +33,7 @@ export class Locale implements ServiceLifecycle { /* noop */ } - public translate(key: string, options: i18next.TranslationOptions = {}): string { + public translate(key: string, options: TranslateOptions = {}): string { const t = mustExist(this.translator); return t(key, options); }