@usebridger/core 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { a as sessionStorage, i as pluginStorage, n as assertTenantId, o as shar
3
3
  import { b as isCapability, c as normalizeQuery, g as assertCapability, h as assertCanSend, l as normalizeWhere, m as UnsupportedError, o as normalizeDocument, r as transportIssues, s as normalizeIndexes, t as ReconnectionPolicy, u as createMessage, v as groupActionCapability, x as missingCapabilities, y as hasCapability } from "./reconnection--5ulo2FJ.mjs";
4
4
  import { randomBytes, randomUUID } from "node:crypto";
5
5
  import { pino, stdSerializers } from "pino";
6
+ import { styleText } from "node:util";
6
7
  import { existsSync } from "node:fs";
7
8
  import { readdir } from "node:fs/promises";
8
9
  import { extname, join, resolve } from "node:path";
@@ -10,7 +11,7 @@ import { pathToFileURL } from "node:url";
10
11
  import { AsyncLocalStorage } from "node:async_hooks";
11
12
  //#region src/text/format.ts
12
13
  /** Monta o `FormattedText`; os nós já vêm validados e congelados. */
13
- function formatted(nodes) {
14
+ function formatted$1(nodes) {
14
15
  return Object.freeze({
15
16
  type: "formatted",
16
17
  nodes: Object.freeze([...nodes])
@@ -30,8 +31,8 @@ function isFormatted(value) {
30
31
  }
31
32
  function style(type, parts) {
32
33
  const children = nodesOf(parts, type);
33
- if (children.length === 0) return formatted([]);
34
- return formatted([Object.freeze({
34
+ if (children.length === 0) return formatted$1([]);
35
+ return formatted$1([Object.freeze({
35
36
  type,
36
37
  children: Object.freeze(children)
37
38
  })]);
@@ -45,8 +46,8 @@ function italic(...parts) {
45
46
  /** Código em linha. O conteúdo é sempre literal: não aceita outra formatação dentro. */
46
47
  function code(text) {
47
48
  if (typeof text !== "string") throw new TypeError("code: esperava string");
48
- if (text === "") return formatted([]);
49
- return formatted([Object.freeze({
49
+ if (text === "") return formatted$1([]);
50
+ return formatted$1([Object.freeze({
50
51
  type: "code",
51
52
  text
52
53
  })]);
@@ -64,7 +65,7 @@ function link(url, label) {
64
65
  }
65
66
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError(`link: só http e https são aceitos: ${url}`);
66
67
  const children = nodesOf([label ?? url], "link");
67
- return formatted([Object.freeze({
68
+ return formatted$1([Object.freeze({
68
69
  type: "link",
69
70
  url,
70
71
  children: Object.freeze(children.length ? children : [url])
@@ -82,7 +83,7 @@ function mention(contact) {
82
83
  phone: contact.phone ?? null,
83
84
  ...contact.username !== void 0 && { username: contact.username }
84
85
  });
85
- return formatted([Object.freeze({
86
+ return formatted$1([Object.freeze({
86
87
  type: "mention",
87
88
  contact: target
88
89
  })]);
@@ -100,7 +101,7 @@ function fmt(strings, ...values) {
100
101
  parts.push(typeof value === "number" ? String(value) : value);
101
102
  }
102
103
  });
103
- return formatted(nodesOf(parts, "fmt"));
104
+ return formatted$1(nodesOf(parts, "fmt"));
104
105
  }
105
106
  /**
106
107
  * Texto visível, sem marcação: o que a pessoa lê. Uma menção vira `@` mais o usuário, o nome, o
@@ -311,6 +312,41 @@ function createPrefixStore(config, port) {
311
312
  }
312
313
  //#endregion
313
314
  //#region src/commands/command.ts
315
+ /** Decompõe a entrada sem validar; a validação fica em `assertCommandDefinition`. */
316
+ function parseAccept(spec) {
317
+ const quoted = spec.startsWith("quoted:");
318
+ const rest = quoted ? spec.slice(7) : spec;
319
+ const colon = rest.indexOf(":");
320
+ if (colon === -1) return {
321
+ quoted,
322
+ type: rest,
323
+ mimetype: null
324
+ };
325
+ return {
326
+ quoted,
327
+ type: rest.slice(0, colon),
328
+ mimetype: rest.slice(colon + 1).toLowerCase()
329
+ };
330
+ }
331
+ /**
332
+ * O mimetype da mídia casa com o da entrada. Compara só a essência: `audio/ogg; codecs=opus`
333
+ * casa com `audio/ogg`, porque os parâmetros variam por plataforma.
334
+ */
335
+ function mimetypeMatches(mimetype, pattern) {
336
+ const essence = mimetype.split(";", 1)[0]?.trim().toLowerCase() ?? "";
337
+ if (pattern.endsWith("/*")) return essence.startsWith(pattern.slice(0, -1));
338
+ return essence === pattern;
339
+ }
340
+ const MEDIA_TYPES = /* @__PURE__ */ new Set([
341
+ "image",
342
+ "video",
343
+ "audio",
344
+ "voice",
345
+ "sticker",
346
+ "document"
347
+ ]);
348
+ /** `tipo/subtipo` ou `tipo/*`, nos caracteres que o RFC 6838 permite. */
349
+ const MIMETYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/(\*|[a-z0-9][a-z0-9!#$&^_.+-]*)$/;
314
350
  const TOKEN = /^\S+$/;
315
351
  function assertToken(token, what) {
316
352
  if (!TOKEN.test(token)) throw new TypeError(`${what} de comando inválido: ${JSON.stringify(token)} (vazio ou com espaço)`);
@@ -318,16 +354,26 @@ function assertToken(token, what) {
318
354
  /**
319
355
  * Lança `TypeError` se o nome ou algum alias for vazio ou tiver espaço (nunca casaria) e
320
356
  * `RangeError` se `timeoutMs` não for finito e > 0 (o `setTimeout` trataria `Infinity`/`NaN`
321
- * como 1 ms e o comando estouraria na hora).
357
+ * como 1 ms e o comando estouraria na hora) ou se `concurrency` não for inteiro >= 1 (0 nunca
358
+ * rodaria). Entrada de `accepts` com mimetype mal formado, ou num tipo sem mídia, e capability
359
+ * desconhecida em `requires` também lançam `TypeError`.
322
360
  */
323
361
  function assertCommandDefinition(definition) {
324
362
  assertToken(definition.name, "Nome");
325
363
  for (const alias of definition.aliases ?? []) assertToken(alias, "Alias");
326
364
  const { timeoutMs } = definition;
327
365
  if (timeoutMs !== void 0 && !(Number.isFinite(timeoutMs) && timeoutMs > 0)) throw new RangeError(`Prazo do comando "${definition.name}" inválido: ${timeoutMs}`);
366
+ const { concurrency } = definition;
367
+ if (concurrency !== void 0 && !(Number.isInteger(concurrency) && concurrency >= 1)) throw new RangeError(`Limite de concorrência do comando "${definition.name}" inválido: ${concurrency}`);
368
+ for (const spec of definition.accepts ?? []) {
369
+ const { type, mimetype } = parseAccept(spec);
370
+ if (mimetype === null) continue;
371
+ if (!MEDIA_TYPES.has(type) || !MIMETYPE.test(mimetype)) throw new TypeError(`accepts do comando "${definition.name}" inválido: ${JSON.stringify(spec)} (mimetype só em tipo com mídia, como "tipo/subtipo" ou "tipo/*")`);
372
+ }
373
+ for (const capability of definition.requires ?? []) if (!isCapability(capability)) throw new TypeError(`requires do comando "${definition.name}": capability desconhecida ${JSON.stringify(capability)}`);
328
374
  }
329
375
  /**
330
- * Define um comando. Valida nome, aliases e prazo na definição, para o erro apontar o plugin que
376
+ * Define um comando. Valida nome, aliases, prazo e limite na definição, para o erro apontar o plugin que
331
377
  * declarou e não aparecer só quando alguém digitar o comando.
332
378
  */
333
379
  function command(definition) {
@@ -713,19 +759,41 @@ const FIRST_WHITESPACE = /\s/;
713
759
  function mediaOf(message) {
714
760
  return message !== null && "media" in message ? message.media : null;
715
761
  }
716
- /** Primeira entrada de `accepts` que casa, na ordem declarada. */
762
+ /** Primeira entrada de `accepts` que casa (tipo e, se houver, mimetype), na ordem declarada. */
717
763
  function resolveAccepts(message, accepts) {
718
764
  for (const spec of accepts) {
719
- const quoted = spec.startsWith("quoted:");
765
+ const { quoted, type, mimetype } = parseAccept(spec);
720
766
  const target = quoted ? message.quoted : message;
721
- const type = quoted ? spec.slice(7) : spec;
722
- if (target !== null && target.type === type) return {
767
+ if (target === null || target.type !== type) continue;
768
+ if (mimetype !== null) {
769
+ const media = mediaOf(target);
770
+ if (media === null || !mimetypeMatches(media.mimetype, mimetype)) continue;
771
+ }
772
+ return {
723
773
  spec,
724
774
  message: target
725
775
  };
726
776
  }
727
777
  return null;
728
778
  }
779
+ /** Roda o `run`; o erro, síncrono ou não, vira `failed`. Nunca rejeita. */
780
+ async function runCommand(definition, ctx, command) {
781
+ try {
782
+ await definition.run(ctx);
783
+ return {
784
+ consumed: true,
785
+ status: "ran",
786
+ command
787
+ };
788
+ } catch (error) {
789
+ return {
790
+ consumed: true,
791
+ status: "failed",
792
+ command,
793
+ error
794
+ };
795
+ }
796
+ }
729
797
  function createCommandRouter(options = {}) {
730
798
  const option = options.prefix ?? "!";
731
799
  let prefixOf;
@@ -852,16 +920,17 @@ function createCommandRouter(options = {}) {
852
920
  }
853
921
  media = mediaOf(accepted.message);
854
922
  } else media = mediaOf(message) ?? mediaOf(message.quoted);
855
- const commandCtx = Object.assign(base, {
923
+ const done = runCommand(definition, Object.assign(base, {
856
924
  accepted,
857
925
  media
858
- });
859
- await definition.run(commandCtx);
860
- return {
926
+ }), command);
927
+ if (definition.background) return {
861
928
  consumed: true,
862
- status: "ran",
863
- command
929
+ status: "background",
930
+ command,
931
+ done
864
932
  };
933
+ return await done;
865
934
  } catch (error) {
866
935
  return {
867
936
  consumed: true,
@@ -914,6 +983,71 @@ function normalizeOwners(owners) {
914
983
  return [...unique.values()];
915
984
  }
916
985
  //#endregion
986
+ //#region src/logger/pretty.ts
987
+ const LEVELS = {
988
+ 10: ["TRACE", "gray"],
989
+ 20: ["DEBUG", "blue"],
990
+ 30: ["INFO ", "green"],
991
+ 40: ["WARN ", "yellow"],
992
+ 50: ["ERROR", "red"],
993
+ 60: ["FATAL", ["bgRed", "white"]]
994
+ };
995
+ /** Campos que o cabeçalho já mostra ou que não ajudam a ler no terminal. */
996
+ const HIDDEN = /* @__PURE__ */ new Set([
997
+ "level",
998
+ "time",
999
+ "pid",
1000
+ "hostname",
1001
+ "msg",
1002
+ "plugin",
1003
+ "err"
1004
+ ]);
1005
+ /**
1006
+ * Embrulha `target` para receber texto legível em vez de JSON. `color` liga as cores ANSI; o
1007
+ * `styleText` ainda as desliga com `NO_COLOR` ou quando o stdout não é um terminal.
1008
+ */
1009
+ function prettyDestination(target, color) {
1010
+ const paint = (style, text) => color ? styleText(style, text, { stream: process.stdout }) : text;
1011
+ return { write(line) {
1012
+ let record;
1013
+ try {
1014
+ record = JSON.parse(line);
1015
+ } catch {
1016
+ target.write(line);
1017
+ return;
1018
+ }
1019
+ target.write(`${formatRecord(record, paint)}\n`);
1020
+ } };
1021
+ }
1022
+ function formatRecord(record, paint) {
1023
+ const [label, style] = LEVELS[record["level"]] ?? ["? ", "white"];
1024
+ const parts = [paint("gray", clock(record["time"])), paint(style, label)];
1025
+ if (typeof record["plugin"] === "string") parts.push(paint("cyan", `[${record["plugin"]}]`));
1026
+ parts.push(String(record["msg"] ?? ""));
1027
+ const fields = Object.entries(record).filter(([key]) => !HIDDEN.has(key)).map(([key, value]) => paint("gray", `${key}=${formatValue(value)}`));
1028
+ let text = [...parts, ...fields].join(" ");
1029
+ if (record["err"] !== void 0) text += `\n${indent(formatError(record["err"]))}`;
1030
+ return text;
1031
+ }
1032
+ /** Hora local `HH:MM:SS`; o dia fica de fora, o terminal é para acompanhar ao vivo. */
1033
+ function clock(time) {
1034
+ return (typeof time === "number" ? new Date(time) : /* @__PURE__ */ new Date()).toTimeString().slice(0, 8);
1035
+ }
1036
+ function formatValue(value) {
1037
+ if (typeof value === "string") return /^[^\s"=]+$/.test(value) ? value : JSON.stringify(value);
1038
+ return JSON.stringify(value) ?? String(value);
1039
+ }
1040
+ /** Stack do erro e as das causas, em sequência; sem stack, `tipo: mensagem` ou o JSON. */
1041
+ function formatError(err) {
1042
+ if (typeof err !== "object" || err === null) return String(err);
1043
+ const { type, message, stack, cause } = err;
1044
+ const own = stack ?? (message === void 0 ? JSON.stringify(err) : `${type}: ${message}`);
1045
+ return cause === void 0 ? own : `${own}\ncausa: ${formatError(cause)}`;
1046
+ }
1047
+ function indent(text) {
1048
+ return text.replaceAll(/^/gm, " ");
1049
+ }
1050
+ //#endregion
917
1051
  //#region src/logger/logger.ts
918
1052
  const CENSOR = "[REDACTED]";
919
1053
  /**
@@ -921,7 +1055,7 @@ const CENSOR = "[REDACTED]";
921
1055
  * campo `err` é serializado com `message`, `stack` e `cause`.
922
1056
  */
923
1057
  function createLogger(options = {}) {
924
- const destination = withSecretsCensored(options.destination, options.secrets);
1058
+ const destination = withSecretsCensored(formatted(options), options.secrets);
925
1059
  const root = pino({
926
1060
  level: options.level ?? "info",
927
1061
  serializers: { err: stdSerializers.errWithCause },
@@ -932,6 +1066,10 @@ function createLogger(options = {}) {
932
1066
  }, destination);
933
1067
  return wrap(options.bindings ? root.child(options.bindings) : root);
934
1068
  }
1069
+ function formatted(options) {
1070
+ if (options.format !== "pretty") return options.destination;
1071
+ return prettyDestination(options.destination ?? pino.destination(1), !options.destination);
1072
+ }
935
1073
  function withSecretsCensored(destination, secrets) {
936
1074
  if (secrets === void 0) return destination;
937
1075
  let source;
@@ -984,6 +1122,31 @@ function createNoopLogger() {
984
1122
  return NOOP_LOGGER;
985
1123
  }
986
1124
  //#endregion
1125
+ //#region src/plugin/messages.ts
1126
+ /** Placeholder que o kernel preenche: o prefixo de comando do chat (ADR 0063). */
1127
+ const PREFIX_PLACEHOLDER = "prefix";
1128
+ const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1129
+ /** Nomes dos placeholders do texto, na ordem em que aparecem, sem repetição. */
1130
+ function placeholders(text) {
1131
+ return [...new Set(Array.from(text.matchAll(PLACEHOLDER), (match) => match[1]))];
1132
+ }
1133
+ /** Preenche os placeholders de `text`; `key` só entra na mensagem de erro. */
1134
+ function fillMessage(text, values, key) {
1135
+ return text.replaceAll(PLACEHOLDER, (_match, name) => {
1136
+ const value = Object.hasOwn(values, name) ? values[name] : void 0;
1137
+ if (value === void 0) throw new TypeError(`mensagem "${key}": falta o valor do placeholder {${name}}`);
1138
+ return String(value);
1139
+ });
1140
+ }
1141
+ /** O `ctx.t`: a mensagem `key` das `messages` do plugin, com o `{prefix}` do chat. */
1142
+ function formatMessage(messages, key, values, prefix) {
1143
+ if (!Object.hasOwn(messages, key)) throw new TypeError(`mensagem "${key}" não declarada em messages`);
1144
+ return fillMessage(messages[key], {
1145
+ ...values,
1146
+ [PREFIX_PLACEHOLDER]: prefix
1147
+ }, key);
1148
+ }
1149
+ //#endregion
987
1150
  //#region src/config/schema.ts
988
1151
  /** Texto que substitui um valor secreto em qualquer exibição (dashboard, `describe`). */
989
1152
  const SECRET_MASK = "********";
@@ -1203,6 +1366,17 @@ var PluginConfigError = class extends Error {
1203
1366
  //#endregion
1204
1367
  //#region src/config/plugin-configs.ts
1205
1368
  const MESSAGES_KEY = "messages";
1369
+ /**
1370
+ * Problema da sobrescrita que usa placeholder que o texto padrão não tem (o `{prefix}` vale
1371
+ * sempre); `null` se está tudo certo.
1372
+ */
1373
+ function placeholderIssue(text, base) {
1374
+ const allowed = [...placeholders(base), PREFIX_PLACEHOLDER];
1375
+ const unknown = placeholders(text).filter((name) => !allowed.includes(name));
1376
+ if (unknown.length === 0) return null;
1377
+ const show = (names) => names.map((name) => `{${name}}`).join(", ");
1378
+ return `placeholder desconhecido ${show(unknown)}; a mensagem aceita ${show(allowed)}`;
1379
+ }
1206
1380
  const SECRETS_OWNER = (plugin) => `plugin:${plugin}`;
1207
1381
  const isPlainObject = (value) => {
1208
1382
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -1456,8 +1630,15 @@ function createPluginConfigs(options) {
1456
1630
  message: "deve ser texto"
1457
1631
  });
1458
1632
  else {
1459
- messages[key] = text;
1460
- messageSources[at.path] = at.source;
1633
+ const problem = placeholderIssue(text, base[key]);
1634
+ if (problem !== null) issues.push({
1635
+ ...at,
1636
+ message: problem
1637
+ });
1638
+ else {
1639
+ messages[key] = text;
1640
+ messageSources[at.path] = at.source;
1641
+ }
1461
1642
  }
1462
1643
  }
1463
1644
  if (issues.length > 0) return {
@@ -2639,7 +2820,7 @@ const SEPARATORS = [
2639
2820
  function splitText(text, limits, measure) {
2640
2821
  if (measure(text) <= limits.first) return [text];
2641
2822
  const raw = typeof text === "string";
2642
- const build = (segments) => raw ? segments.map((s) => s.text).join("") : formatted(rebuild(segments, 0));
2823
+ const build = (segments) => raw ? segments.map((s) => s.text).join("") : formatted$1(rebuild(segments, 0));
2643
2824
  const chunks = [];
2644
2825
  const fits = (segments) => measure(build(trim(segments))) <= (chunks.length === 0 ? limits.first : limits.rest);
2645
2826
  const pack = (segments, level) => {
@@ -3943,17 +4124,18 @@ function definePlugin(definition) {
3943
4124
  * Não edite à mão: `pnpm version-packages` roda o `changeset version` e reescreve esta linha
3944
4125
  * (`scripts/sync-version.ts`); o `version.test.ts` falha se ela divergir do `package.json`.
3945
4126
  */
3946
- const CORE_VERSION = "0.2.1";
4127
+ const CORE_VERSION = "0.3.0";
3947
4128
  //#endregion
3948
4129
  //#region src/plugin/declared.ts
3949
4130
  /** Registra os `commands` e o `on` do manifesto. Chamado pelo host antes do `setup`. */
3950
4131
  function installDeclared(definition, ctx) {
3951
4132
  for (const [name, declared] of Object.entries(definition.commands ?? {})) {
3952
- const { run, ...rest } = declared;
4133
+ const { run, onReject, ...rest } = declared;
3953
4134
  ctx.commands.add({
3954
4135
  ...rest,
3955
4136
  name,
3956
- run: (c) => run.call(declared, c, ctx)
4137
+ run: (c) => run.call(declared, c, ctx),
4138
+ ...onReject && { onReject: (c, rejection) => onReject.call(declared, c, rejection, ctx) }
3957
4139
  });
3958
4140
  }
3959
4141
  for (const [event, listener] of Object.entries(definition.on ?? {})) {
@@ -4141,7 +4323,7 @@ const reportBase = ({ definition, origin }) => ({
4141
4323
  /** Cria o host de plugins. Não roda nada até `start()`. */
4142
4324
  function createPluginHost(options) {
4143
4325
  const { transport, createContext, log } = options;
4144
- const coreVersion = options.coreVersion ?? "0.2.1";
4326
+ const coreVersion = options.coreVersion ?? "0.3.0";
4145
4327
  if (!parseVersion(coreVersion)) throw new RangeError(`coreVersion inválida: "${coreVersion}"`);
4146
4328
  const setupTimeoutMs = options.setupTimeoutMs ?? 1e4;
4147
4329
  const teardownTimeoutMs = options.teardownTimeoutMs ?? 5e3;
@@ -5409,7 +5591,12 @@ var MessageContextImpl = class {
5409
5591
  return state.log;
5410
5592
  }
5411
5593
  react(emoji) {
5412
- return this[STATE].deps.sender.react(this.message.key, emoji, { priority: "high" });
5594
+ const { deps } = this[STATE];
5595
+ if (!deps.reactions) {
5596
+ this.log.debug("react ignorado: o transport não tem a capability \"reactions\"", { emoji });
5597
+ return Promise.resolve();
5598
+ }
5599
+ return deps.sender.react(this.message.key, emoji, { priority: "high" });
5413
5600
  }
5414
5601
  };
5415
5602
  function replyFor(deps, message, owner) {
@@ -5554,12 +5741,28 @@ const signalDescriptor = { get() {
5554
5741
  return deadlineOf(this).signal;
5555
5742
  } };
5556
5743
  /**
5557
- * Descritores comuns às execuções que respondem a uma mensagem (comando e passo): `log` do
5744
+ * Descritor do `t`: a função presa à mensagem da visão, criada no primeiro acesso e fixada, para
5745
+ * `({ t }) => ...` funcionar desestruturado.
5746
+ */
5747
+ function textDescriptor(text) {
5748
+ return {
5749
+ configurable: true,
5750
+ get() {
5751
+ const { message } = this;
5752
+ const t = (key, values) => text(message, key, values);
5753
+ Object.defineProperty(this, "t", { value: t });
5754
+ return t;
5755
+ }
5756
+ };
5757
+ }
5758
+ /**
5759
+ * Descritores comuns às execuções que respondem a uma mensagem (comando e passo): `log` e `t` do
5558
5760
  * plugin, `signal`, e `reply`, `react` e `expectReply` presos ao prazo.
5559
5761
  */
5560
- function executionDescriptors(plugin, pluginLog, expect, scope) {
5762
+ function executionDescriptors(plugin, pluginLog, expect, text, scope) {
5561
5763
  return {
5562
5764
  log: pluginLogDescriptor(pluginLog),
5765
+ t: textDescriptor(text),
5563
5766
  signal: signalDescriptor,
5564
5767
  reply: expiringReplyDescriptor(plugin, (view) => pluginReply(view, plugin) ?? Object.getPrototypeOf(view).reply, scope),
5565
5768
  react: expiringReactDescriptor(plugin, (view) => {
@@ -5570,12 +5773,12 @@ function executionDescriptors(plugin, pluginLog, expect, scope) {
5570
5773
  };
5571
5774
  }
5572
5775
  /** Deriva os contextos de comando de um plugin. */
5573
- function commandViewFactory(plugin, pluginLog, expect) {
5776
+ function commandViewFactory(plugin, pluginLog, expect, text) {
5574
5777
  const scope = (view) => ({
5575
5778
  label: `comando "${view.command}"`,
5576
5779
  fields: { command: view.command }
5577
5780
  });
5578
- const runDescriptors = executionDescriptors(plugin, pluginLog, expect, scope);
5781
+ const runDescriptors = executionDescriptors(plugin, pluginLog, expect, text, scope);
5579
5782
  return { run(ctx, deadline) {
5580
5783
  const view = Object.create(ctx, runDescriptors);
5581
5784
  view[DEADLINE] = deadline;
@@ -5586,12 +5789,12 @@ function commandViewFactory(plugin, pluginLog, expect) {
5586
5789
  * Deriva, para os passos de conversa de um plugin, o contexto com `step`, `data` e os campos de
5587
5790
  * execução do comando (`log`, `signal`, `reply`, `react`, `expectReply`).
5588
5791
  */
5589
- function stepViewFactory(plugin, pluginLog, expect) {
5792
+ function stepViewFactory(plugin, pluginLog, expect, text) {
5590
5793
  const scope = (view) => ({
5591
5794
  label: `passo "${view.step}"`,
5592
5795
  fields: { step: view.step }
5593
5796
  });
5594
- const descriptors = executionDescriptors(plugin, pluginLog, expect, scope);
5797
+ const descriptors = executionDescriptors(plugin, pluginLog, expect, text, scope);
5595
5798
  return (ctx, deadline, step, data) => {
5596
5799
  const view = Object.create(ctx, descriptors);
5597
5800
  Object.assign(view, {
@@ -5623,7 +5826,7 @@ function roleViewFactory(pluginLog) {
5623
5826
  * compartilhados entre os listeners da emissão; o barramento pendura na visão o `Deadline` do
5624
5827
  * listener, que dá o `signal` e prende o `reply`.
5625
5828
  */
5626
- function listenerViewFactory(plugin, pluginLog, expect) {
5829
+ function listenerViewFactory(plugin, pluginLog, expect, text) {
5627
5830
  const kernel = (view) => view[KERNEL_CONTEXT];
5628
5831
  const scope = (view) => ({
5629
5832
  label: `listener de "${view.event}"`,
@@ -5643,11 +5846,65 @@ function listenerViewFactory(plugin, pluginLog, expect) {
5643
5846
  }, scope),
5644
5847
  react: expiringReactDescriptor(plugin, kernel, scope),
5645
5848
  expectReply: expectReplyDescriptor(plugin, expect, scope),
5646
- log: pluginLogDescriptor(pluginLog)
5849
+ log: pluginLogDescriptor(pluginLog),
5850
+ t: textDescriptor(text)
5647
5851
  };
5648
5852
  return (ctx) => Object.create(ctx, descriptors);
5649
5853
  }
5650
5854
  //#endregion
5855
+ //#region src/commands/concurrency.ts
5856
+ function createConcurrencyLimit(max) {
5857
+ let active = 0;
5858
+ const waiting = /* @__PURE__ */ new Set();
5859
+ const release = () => {
5860
+ const next = waiting.values().next();
5861
+ if (next.done) {
5862
+ active--;
5863
+ return;
5864
+ }
5865
+ waiting.delete(next.value);
5866
+ next.value();
5867
+ };
5868
+ const start = (task) => {
5869
+ let result;
5870
+ try {
5871
+ result = task();
5872
+ } catch (error) {
5873
+ release();
5874
+ throw error;
5875
+ }
5876
+ if (isThenable$2(result)) result.then(release, release);
5877
+ else release();
5878
+ return result;
5879
+ };
5880
+ return { run(task, signal) {
5881
+ if (active < max) {
5882
+ active++;
5883
+ return start(task);
5884
+ }
5885
+ return new Promise((resolve, reject) => {
5886
+ if (signal.aborted) {
5887
+ reject(signal.reason);
5888
+ return;
5889
+ }
5890
+ const onAbort = () => {
5891
+ waiting.delete(go);
5892
+ reject(signal.reason);
5893
+ };
5894
+ const go = () => {
5895
+ signal.removeEventListener("abort", onAbort);
5896
+ try {
5897
+ resolve(start(task));
5898
+ } catch (error) {
5899
+ reject(error);
5900
+ }
5901
+ };
5902
+ waiting.add(go);
5903
+ signal.addEventListener("abort", onAbort, { once: true });
5904
+ });
5905
+ } };
5906
+ }
5907
+ //#endregion
5651
5908
  //#region src/storage/tenant.ts
5652
5909
  /**
5653
5910
  * Storage do plugin que segue o `scope`. Os storages por tenant ficam em cache (as coleções do
@@ -5741,9 +5998,10 @@ function createPluginContextFactory(deps) {
5741
5998
  };
5742
5999
  const live = (operation, fn) => (...args) => lifetime.expired ? refuse(operation) : fn(...args);
5743
6000
  const expect = (message, step, options) => deps.conversations.expect(name, message.chat.id, message.sender.id, step, options);
5744
- const commandViews = commandViewFactory(name, log, expect);
5745
- const listenerView = listenerViewFactory(name, log, expect);
5746
- const stepView = stepViewFactory(name, log, expect);
6001
+ const text = (message, key, values) => formatMessage(messages, key, values, deps.prefixes.get(message.chat));
6002
+ const commandViews = commandViewFactory(name, log, expect, text);
6003
+ const listenerView = listenerViewFactory(name, log, expect, text);
6004
+ const stepView = stepViewFactory(name, log, expect, text);
5747
6005
  const roleView = roleViewFactory(log);
5748
6006
  const events = deps.bus.forPlugin(name, {
5749
6007
  view: (event) => isMessageEvent(event) ? listenerView : void 0,
@@ -5766,6 +6024,15 @@ function createPluginContextFactory(deps) {
5766
6024
  commands: {
5767
6025
  add(definition) {
5768
6026
  guard("commands.add");
6027
+ assertCommandDefinition(definition);
6028
+ const missing = missingCapabilities(deps.transport, definition.requires ?? []);
6029
+ if (missing.length > 0) {
6030
+ log.info(`comando "${definition.name}" não registrado: capability ausente no transport`, {
6031
+ command: definition.name,
6032
+ missing
6033
+ });
6034
+ return;
6035
+ }
5769
6036
  deps.router.registry.add(name, wrapCommand(name, definition, commandViews, lifetime, deps));
5770
6037
  },
5771
6038
  list: () => deps.router.registry.list().map(commandInfo)
@@ -5918,19 +6185,26 @@ function liveTenantStorage(storage, live) {
5918
6185
  function wrapCommand(plugin, definition, views, lifetime, deps) {
5919
6186
  const { run, onReject, name } = definition;
5920
6187
  const timeoutMs = definition.timeoutMs ?? deps.commandTimeoutMs;
6188
+ const limit = definition.concurrency === void 0 ? void 0 : createConcurrencyLimit(definition.concurrency);
5921
6189
  const timed = (stage, execute) => {
5922
6190
  const deadline = new Deadline(lifetime);
5923
6191
  return settleWithin(execute(deadline), timeoutMs, () => {
5924
6192
  const error = new CommandTimeoutError(plugin, name, timeoutMs, stage);
5925
6193
  deadline.expire(error);
5926
6194
  return error;
5927
- }, (error) => deps.onLateCommandError(plugin, name, error), deps.armed, deadline);
6195
+ }, (error) => {
6196
+ if (error === deadline.reason) return;
6197
+ deps.onLateCommandError(plugin, name, error);
6198
+ }, deps.armed, deadline);
5928
6199
  };
5929
6200
  return {
5930
6201
  ...definition,
5931
6202
  run: (ctx) => timed("run", (deadline) => {
5932
- const view = views.run(ctx, deadline);
5933
- return replyWithResult(view, run(view));
6203
+ const start = () => {
6204
+ const view = views.run(ctx, deadline);
6205
+ return replyWithResult(view, run(view));
6206
+ };
6207
+ return limit === void 0 ? start() : limit.run(start, deadline.signal);
5934
6208
  }),
5935
6209
  ...onReject && { onReject: (ctx, rejection) => timed("onReject", (deadline) => onReject(views.run(ctx, deadline), rejection)) }
5936
6210
  };
@@ -6391,6 +6665,19 @@ function createBot(config) {
6391
6665
  const timeouts = config.timeouts ?? {};
6392
6666
  const commandTimeoutMs = timeouts.commandMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
6393
6667
  const armed = new ArmedTimers();
6668
+ const background = /* @__PURE__ */ new Set();
6669
+ const backgroundWaiters = [];
6670
+ const releaseBackgroundWaiters = () => {
6671
+ for (const resolve of backgroundWaiters.splice(0)) resolve();
6672
+ };
6673
+ const trackBackground = (task) => {
6674
+ background.add(task);
6675
+ task.finally(() => {
6676
+ background.delete(task);
6677
+ if (background.size === 0) releaseBackgroundWaiters();
6678
+ });
6679
+ };
6680
+ const backgroundIdle = () => background.size === 0 ? Promise.resolve() : new Promise((resolve) => backgroundWaiters.push(resolve));
6394
6681
  const bus = createEventBus({
6395
6682
  listenerTimeoutMs: timeouts.listenerMs,
6396
6683
  armed,
@@ -6454,6 +6741,7 @@ function createBot(config) {
6454
6741
  const contextDeps = {
6455
6742
  sender: send,
6456
6743
  quote: hasCapability(transport, "quoted"),
6744
+ reactions: hasCapability(transport, "reactions"),
6457
6745
  log: getLog,
6458
6746
  actions: prepareActions
6459
6747
  };
@@ -6537,6 +6825,14 @@ function createBot(config) {
6537
6825
  /** Recusa, falha e evento `command` do comando que consumiu a mensagem ou o clique. */
6538
6826
  async function afterCommand(ctx, result) {
6539
6827
  const { message } = ctx;
6828
+ if (result.status === "background") {
6829
+ trackBackground(result.done.then((outcome) => afterCommand(ctx, outcome)).catch((error) => ctx.log.error("falha ao concluir o comando em segundo plano", {
6830
+ plugin: result.command.plugin,
6831
+ command: result.command.name,
6832
+ err: error
6833
+ })));
6834
+ return;
6835
+ }
6540
6836
  if (result.status === "rejected" && result.reply !== null) ctx.reply(result.reply).catch((error) => ctx.log.warn("falha ao enviar a recusa do comando", {
6541
6837
  plugin: result.command.plugin,
6542
6838
  command: result.command.name,
@@ -6848,6 +7144,8 @@ function createBot(config) {
6848
7144
  await scheduler.stop(aborted);
6849
7145
  await host?.stop(aborted);
6850
7146
  armed.disarmAll();
7147
+ background.clear();
7148
+ releaseBackgroundWaiters();
6851
7149
  conversations.close();
6852
7150
  actions.clear();
6853
7151
  outbound.close({ drain: false });
@@ -6944,6 +7242,7 @@ function createBot(config) {
6944
7242
  try {
6945
7243
  log = config.logger ?? createLogger({
6946
7244
  level: config.logLevel ?? "info",
7245
+ ...config.logFormat && { format: config.logFormat },
6947
7246
  secrets
6948
7247
  });
6949
7248
  transportLog = log.child({ transport: transport.name });
@@ -7047,8 +7346,9 @@ function createBot(config) {
7047
7346
  */
7048
7347
  async function settled() {
7049
7348
  if (state === "starting") await readyPromise;
7050
- while (!(inbound.stats().activeChats === 0 && bus.idle && outbound.stats().activeChats === 0)) {
7349
+ while (!(inbound.stats().activeChats === 0 && background.size === 0 && bus.idle && outbound.stats().activeChats === 0)) {
7051
7350
  await inbound.onIdle();
7351
+ await backgroundIdle();
7052
7352
  await bus.onIdle();
7053
7353
  await outbound.onIdle();
7054
7354
  }