@manybot/manybot 5.8.0 → 5.9.1

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.
Files changed (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +245 -51
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. package/package.json +4 -3
@@ -30,6 +30,7 @@
30
30
  "schedulerError": "Plugin \"{{name}}\" scheduling error: {{message}}",
31
31
  "schedulerRegistered": "Schedule registered — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Download job failed — {{message}}",
33
+ "downloadJobNoErrorFn": "Download job failed and no errorFn was provided to enqueue()",
33
34
  "reconnecting": "Reconnecting in {{secs}}s...",
34
35
  "reconnectHalted": "Gave up reconnecting after {{attempts}} attempts — call connect() manually to retry. Repeated reconnect loops can make a WhatsApp restriction last longer.",
35
36
  "sessionExpired": "Session expired or logged out — removing local session and restarting...",
@@ -56,6 +57,13 @@
56
57
  "commandsConfigImportInvalidRoot": "commands.yaml: import \"{{path}}\" top-level must be a map of sections — skipped",
57
58
  "commandsConfigImportNested": "commands.yaml: import \"{{path}}\" declares its own \"import\" — nested imports are not supported and were ignored",
58
59
  "commandsConfigImportKeyConflict": "commands.yaml: import \"{{path}}\" redefines \"{{key}}\", already owned by \"{{owner}}\" — keeping the first one",
60
+ "commandsConfigCommandsWrapperInvalid": "commands.yaml: top-level \"commands:\" must be a map of command entries — ignored",
61
+ "commandsConfigCommandsWrapperCollision": "commands.yaml: \"commands:\" wrapper defines \"{{key}}\" that already exists at the top level — keeping the top-level entry",
62
+ "commandsConfigConflictingScopeFlags": "commands.yaml: \"permissions\" has both group_only and dm_only set to true — pick one (or use \"scope:\" directly)",
63
+ "commandsConfigLoadingPresetMissing": "commands.yaml: \"{{id}}\" loading preset \"{{name}}\" not found — leaving the field unset",
64
+ "commandsConfigLoadingInvalid": "commands.yaml: \"{{id}}\" loading value is not a preset name or inline object — ignored",
65
+ "commandsConfigLoadingUnknownType": "commands.yaml: \"{{id}}\" loading type \"{{type}}\" is not one of reaction|typing|recording_audio|spinner|none — ignored",
66
+ "commandsConfigLoadingUnknownProp": "commands.yaml: \"{{id}}\" loading type \"{{type}}\" does not accept property \"{{key}}\" — entry is malformed and was dropped",
59
67
  "commandRegistryOrphanEntry": "commandRegistry: entry \"{{id}}\" targets {{plugin}}.{{function}} but no active plugin provides it — skipped",
60
68
  "commandRegistryInvalidEntry": "commandRegistry: entry \"{{id}}\" has no plugin/function and no text — skipped",
61
69
  "commandRegistryInvocationCollision": "commandRegistry: invocation \"{{text}}\" already owned by \"{{winner}}\" — dropping {{loser}} ({{via}})",
@@ -66,6 +74,7 @@
66
74
  "commandDeprecationReservedInvocation": "commandRegistry: invocation \"{{text}}\" is reserved by active deprecation for \"{{id}}\" — refusing registration",
67
75
  "commandManualMissing": "No detailed manual available for command \"{{cmd}}\".",
68
76
  "commandNotFound": "Command or category \"{{cmd}}\" not found. Use {{prefix}}{{menuCmd}} to view the command list.",
77
+ "commandNotFoundSuggestion": "Command \"{{cmd}}\" not found. Did you mean {{prefix}}{{suggestion}}? Use {{prefix}}{{menuCmd}} to view the command list.",
69
78
  "commandRegistryMenuAliasCollision": "commandRegistry: menu alias \"{{alias}}\" collided with real command \"{{winner}}\" — menu alias ignored"
70
79
  },
71
80
  "errors": {
@@ -84,8 +93,11 @@
84
93
  "botNotAdmin": "This command requires the bot to be a group administrator.",
85
94
  "senderNotAdmin": "This command is restricted to group administrators.",
86
95
  "ownerOnly": "This command can only be run by the bot owner.",
96
+ "donoOnly": "This command can only be run by its designated owner.",
87
97
  "wrongScope": "This command cannot be used in this context.",
88
- "cooldown": "Wait {{seconds}}s before using this command again."
98
+ "cooldown": "Wait {{seconds}}s before using this command again.",
99
+ "blacklist": "You are not allowed to use this command.",
100
+ "allowedChats": "This command is not available in this chat."
89
101
  },
90
102
  "commandRun": {
91
103
  "missingRequiredArg": "Missing required argument(s).",
@@ -101,6 +113,7 @@
101
113
  },
102
114
  "driver": {
103
115
  "groupParticipantNotFound": "\"{{id}}\" does not match a current participant in group \"{{group}}\" (check whether the person is still in the group, or use a reply/mention instead of typing the number).",
116
+ "cannotKickSelf": "Refusing to remove the bot itself from the group via admin.kick().",
104
117
  "groupParticipantsUpdateRejected": "groupParticipantsUpdate(\"{{action}}\") rejected for: {{detail}}",
105
118
  "groupParticipantsUpdateFailed": "groupParticipantsUpdate(\"{{action}}\") failed for group \"{{group}}\" with participants [{{users}}]: {{message}}",
106
119
  "pollDecryptFailed": "Failed to decrypt poll vote: {{error}}"
@@ -30,6 +30,7 @@
30
30
  "schedulerError": "Error en la programación del plugin \"{{name}}\": {{message}}",
31
31
  "schedulerRegistered": "Programación registrada — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Error en el trabajo de descarga — {{message}}",
33
+ "downloadJobNoErrorFn": "El trabajo de descarga falló y no se pasó errorFn a enqueue()",
33
34
  "reconnecting": "Reconectando en {{secs}}s...",
34
35
  "reconnectHalted": "Se dejó de reconectar tras {{attempts}} intentos — llama a connect() manualmente para reintentar. Los bucles de reconexión repetidos pueden prolongar una restricción de WhatsApp.",
35
36
  "sessionExpired": "Sesión expirada o cerrada — eliminando sesión local y reiniciando...",
@@ -56,6 +57,13 @@
56
57
  "commandsConfigImportInvalidRoot": "commands.yaml: el import \"{{path}}\" debe tener un mapa de secciones en el nivel raíz — se omitió",
57
58
  "commandsConfigImportNested": "commands.yaml: el import \"{{path}}\" declara su propio \"import\" — los imports anidados no son compatibles y se ignoraron",
58
59
  "commandsConfigImportKeyConflict": "commands.yaml: el import \"{{path}}\" redefine \"{{key}}\", que ya pertenece a \"{{owner}}\" — se conserva el primero",
60
+ "commandsConfigCommandsWrapperInvalid": "commands.yaml: el bloque superior \"commands:\" debe ser un mapa de comandos — se omitió",
61
+ "commandsConfigCommandsWrapperCollision": "commands.yaml: el bloque \"commands:\" define \"{{key}}\", que ya existe en el nivel superior — se conserva la entrada superior",
62
+ "commandsConfigConflictingScopeFlags": "commands.yaml: \"permissions\" tiene group_only y dm_only a la vez — elegí uno (o usá \"scope:\" directo)",
63
+ "commandsConfigLoadingPresetMissing": "commands.yaml: \"{{id}}\" referencia el preset de loading \"{{name}}\" que no existe — el campo queda vacío",
64
+ "commandsConfigLoadingInvalid": "commands.yaml: \"{{id}}\" el valor de loading no es nombre de preset ni objeto inline — se omitió",
65
+ "commandsConfigLoadingUnknownType": "commands.yaml: \"{{id}}\" el tipo de loading \"{{type}}\" no es uno de reaction|typing|recording_audio|spinner|none — se omitió",
66
+ "commandsConfigLoadingUnknownProp": "commands.yaml: \"{{id}}\" el tipo de loading \"{{type}}\" no acepta la propiedad \"{{key}}\" — la entrada está malformada y se descartó",
59
67
  "commandRegistryOrphanEntry": "commandRegistry: la entrada \"{{id}}\" apunta a {{plugin}}.{{function}} pero ningún plugin activo la provee — se omitió",
60
68
  "commandRegistryInvalidEntry": "commandRegistry: la entrada \"{{id}}\" no tiene plugin/function ni text — se omitió",
61
69
  "commandRegistryInvocationCollision": "commandRegistry: la invocación \"{{text}}\" ya pertenece a \"{{winner}}\" — descartando {{loser}} ({{via}})",
@@ -66,6 +74,7 @@
66
74
  "commandDeprecationReservedInvocation": "commandRegistry: la invocación \"{{text}}\" está reservada por deprecation activa de \"{{id}}\" — registro rechazado",
67
75
  "commandManualMissing": "No hay manual detallado disponible para el comando \"{{cmd}}\".",
68
76
  "commandNotFound": "Comando o categoría \"{{cmd}}\" no encontrado. Usa {{prefix}}{{menuCmd}} para ver la lista de comandos.",
77
+ "commandNotFoundSuggestion": "Comando \"{{cmd}}\" no encontrado. ¿Quisiste decir {{prefix}}{{suggestion}}? Usa {{prefix}}{{menuCmd}} para ver la lista de comandos.",
69
78
  "commandRegistryMenuAliasCollision": "commandRegistry: alias de menú \"{{alias}}\" colisionó con comando real \"{{winner}}\" — alias de menú ignorado"
70
79
  },
71
80
  "errors": {
@@ -82,10 +91,13 @@
82
91
  },
83
92
  "commandPermissions": {
84
93
  "botNotAdmin": "Este comando requiere que el bot sea administrador del grupo.",
85
- "senderNotAdmin": "Este comando está restringido a administradores del grupo.",
86
- "ownerOnly": "Este comando solo puede ser ejecutado por el dueño del bot.",
87
- "wrongScope": "Este comando no puede usarse en este contexto.",
88
- "cooldown": "Espera {{seconds}}s antes de volver a usar este comando."
94
+ "senderNotAdmin": "Este comando es solo para administradores del grupo.",
95
+ "ownerOnly": "Solo el dueño del bot puede usar este comando.",
96
+ "donoOnly": "Solo el dueño designado de este comando puede usarlo.",
97
+ "wrongScope": "Este comando no se puede usar en este contexto.",
98
+ "cooldown": "Esperá {{seconds}}s antes de usar este comando de nuevo.",
99
+ "blacklist": "No podés usar este comando.",
100
+ "allowedChats": "Este comando no está disponible en este chat."
89
101
  },
90
102
  "commandRun": {
91
103
  "missingRequiredArg": "Faltan argumentos obligatorios.",
@@ -101,6 +113,7 @@
101
113
  },
102
114
  "driver": {
103
115
  "groupParticipantNotFound": "\"{{id}}\" no coincide con ningún participante actual del grupo \"{{group}}\" (comprueba si la persona sigue en el grupo o usa una respuesta/mención en lugar de escribir el número).",
116
+ "cannotKickSelf": "Se rechaza eliminar al propio bot del grupo mediante admin.kick().",
104
117
  "groupParticipantsUpdateRejected": "groupParticipantsUpdate(\"{{action}}\") rechazado para: {{detail}}",
105
118
  "groupParticipantsUpdateFailed": "groupParticipantsUpdate(\"{{action}}\") falló para el grupo \"{{group}}\" con participantes [{{users}}]: {{message}}",
106
119
  "pollDecryptFailed": "No se pudo descifrar el voto de la encuesta: {{error}}"
@@ -30,6 +30,7 @@
30
30
  "schedulerError": "Erro no agendamento do plugin \"{{name}}\": {{message}}",
31
31
  "schedulerRegistered": "Agendamento registrado — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Falha no job de download — {{message}}",
33
+ "downloadJobNoErrorFn": "Job de download falhou e nenhum errorFn foi passado para enqueue()",
33
34
  "reconnecting": "Reconectando em {{secs}}s...",
34
35
  "reconnectHalted": "Desisti de reconectar após {{attempts}} tentativas — chame connect() manualmente para tentar de novo. Loops de reconexão repetidos podem prolongar uma restrição do WhatsApp.",
35
36
  "sessionExpired": "Sessão expirada ou desconectada — removendo sessão local e reiniciando...",
@@ -56,6 +57,13 @@
56
57
  "commandsConfigImportInvalidRoot": "commands.yaml: o import \"{{path}}\" precisa ter um mapa de seções no nível raiz — ignorado",
57
58
  "commandsConfigImportNested": "commands.yaml: o import \"{{path}}\" declara seu próprio \"import\" — imports aninhados não são suportados e foram ignorados",
58
59
  "commandsConfigImportKeyConflict": "commands.yaml: o import \"{{path}}\" redefine \"{{key}}\", que já pertence a \"{{owner}}\" — mantendo o primeiro",
60
+ "commandsConfigCommandsWrapperInvalid": "commands.yaml: o bloco \"commands:\" de topo precisa ser um mapa de comandos — ignorado",
61
+ "commandsConfigCommandsWrapperCollision": "commands.yaml: o bloco \"commands:\" define \"{{key}}\", que já existe no nível de topo — mantendo a entrada do topo",
62
+ "commandsConfigConflictingScopeFlags": "commands.yaml: \"permissions\" tem group_only e dm_only ao mesmo tempo — escolha um (ou use \"scope:\" direto)",
63
+ "commandsConfigLoadingPresetMissing": "commands.yaml: \"{{id}}\" referencia o preset de loading \"{{name}}\" que não existe — campo fica vazio",
64
+ "commandsConfigLoadingInvalid": "commands.yaml: \"{{id}}\" o valor de loading não é nome de preset nem objeto inline — ignorado",
65
+ "commandsConfigLoadingUnknownType": "commands.yaml: \"{{id}}\" o tipo de loading \"{{type}}\" não é um dos reaction|typing|recording_audio|spinner|none — ignorado",
66
+ "commandsConfigLoadingUnknownProp": "commands.yaml: \"{{id}}\" o tipo de loading \"{{type}}\" não aceita a propriedade \"{{key}}\" — entrada malformada, descartada",
59
67
  "commandRegistryOrphanEntry": "commandRegistry: entrada \"{{id}}\" aponta para {{plugin}}.{{function}} mas nenhum plugin ativo fornece — ignorada",
60
68
  "commandRegistryInvalidEntry": "commandRegistry: entrada \"{{id}}\" não tem plugin/function nem text — ignorada",
61
69
  "commandRegistryInvocationCollision": "commandRegistry: invocação \"{{text}}\" já pertence a \"{{winner}}\" — descartando {{loser}} ({{via}})",
@@ -66,6 +74,7 @@
66
74
  "commandDeprecationReservedInvocation": "commandRegistry: invocação \"{{text}}\" está reservada por deprecation ativa de \"{{id}}\" — registro recusado",
67
75
  "commandManualMissing": "Nenhum manual detalhado disponível para o comando \"{{cmd}}\".",
68
76
  "commandNotFound": "Comando ou categoria \"{{cmd}}\" não encontrado. Use {{prefix}}{{menuCmd}} para ver a lista de comandos.",
77
+ "commandNotFoundSuggestion": "Comando \"{{cmd}}\" não encontrado. Você quis dizer {{prefix}}{{suggestion}}? Use {{prefix}}{{menuCmd}} para ver a lista de comandos.",
69
78
  "commandRegistryMenuAliasCollision": "commandRegistry: alias de menu \"{{alias}}\" colidiu com comando real \"{{winner}}\" — alias de menu ignorado"
70
79
  },
71
80
  "errors": {
@@ -81,11 +90,14 @@
81
90
  "description": "Descrição"
82
91
  },
83
92
  "commandPermissions": {
84
- "botNotAdmin": "Este comando exige que o bot seja administrador do grupo.",
85
- "senderNotAdmin": "Este comando é restrito a administradores do grupo.",
86
- "ownerOnly": "Este comando pode ser executado pelo dono do bot.",
87
- "wrongScope": "Este comando não pode ser usado neste contexto.",
88
- "cooldown": "Aguarde {{seconds}}s antes de usar este comando novamente."
93
+ "botNotAdmin": "Esse comando precisa que o bot seja admin do grupo.",
94
+ "senderNotAdmin": "Esse comando é pra admins do grupo.",
95
+ "ownerOnly": " o dono do bot pode usar esse comando.",
96
+ "donoOnly": " o dono designado desse comando pode usá-lo.",
97
+ "wrongScope": "Esse comando não funciona aqui.",
98
+ "cooldown": "Espera {{seconds}}s antes de usar esse comando de novo.",
99
+ "blacklist": "Você não pode usar esse comando.",
100
+ "allowedChats": "Esse comando não está disponível nesse chat."
89
101
  },
90
102
  "commandRun": {
91
103
  "missingRequiredArg": "Faltam argumentos obrigatórios.",
@@ -101,6 +113,7 @@
101
113
  },
102
114
  "driver": {
103
115
  "groupParticipantNotFound": "\"{{id}}\" não corresponde a nenhum participante atual do grupo \"{{group}}\" (confira se a pessoa ainda está no grupo, ou use reply/menção em vez de digitar o número).",
116
+ "cannotKickSelf": "Removendo o próprio bot do grupo via admin.kick() recusado.",
104
117
  "groupParticipantsUpdateRejected": "groupParticipantsUpdate(\"{{action}}\") rejeitado para: {{detail}}",
105
118
  "groupParticipantsUpdateFailed": "groupParticipantsUpdate(\"{{action}}\") falhou para o grupo \"{{group}}\" com participantes [{{users}}]: {{message}}",
106
119
  "pollDecryptFailed": "Erro ao decriptar voto da enquete: {{error}}"
@@ -102,24 +102,41 @@ export const api = {
102
102
  export async function setup(ctx) {
103
103
  // The integration chat is decided by the harness BEFORE
104
104
  // setupPlugins() is called. Historically the harness set
105
- // MANYBOT_TEST_CHAT, but users often set TEST_CHAT (per docs).
106
- // Accept either, normalizing the value to the canonical JID form.
107
- // Keep behavior strict: if neither is set, fail.
108
- const rawEnv = process.env.MANYBOT_TEST_CHAT ?? process.env.TEST_CHAT;
109
- if (!rawEnv) {
110
- throw new Error(`[${INTEGRATION_PLUGIN_NAME}] setup() called without MANYBOT_TEST_CHAT or TEST_CHAT — ` +
111
- `set MANYBOT_TEST_CHAT (preferred) or TEST_CHAT before setupPlugins().`);
112
- }
113
- // Normalize and validate the provided chat identifier.
105
+ // MANYBOT_TEST_CHAT, but users often set TEST_CHAT (per docs)
106
+ // either as an env var or as a `TEST_CHAT` key in manybot.toml.
107
+ //
108
+ // `kernel/integrationMode.ts`'s own gate (`getIntegrationModeStatus`,
109
+ // which is what decided integration mode was "ready" and logged as
110
+ // much before this plugin's setup() ever ran) resolves TEST_CHAT via
111
+ // `getTestConfig()` env var first, then manybot.toml. Reading
112
+ // straight off `process.env.TEST_CHAT` here (as this used to)
113
+ // silently diverged from that: the gate would report ready off a
114
+ // toml-only value while this threw "no TEST_CHAT set", because it
115
+ // never looked at the toml. Importing normalizeTestChat/getTestConfig
116
+ // here (rather than at module top) keeps the module load cheap when
117
+ // the plugin isn't used in production code paths.
118
+ //
119
+ // MANYBOT_TEST_CHAT remains a preferred override on top of that, for
120
+ // callers that want to point this specific plugin at a different
121
+ // chat than the gate's own resolution.
122
+ const { getTestConfig, normalizeTestChat } = await import("#kernel/testConfig.js");
114
123
  let normalized;
115
- try {
116
- // Importing normalizeTestChat here keeps the module load cheap
117
- // when the plugin isn't used in production code paths.
118
- const { normalizeTestChat } = await import("#kernel/testConfig.js");
119
- normalized = normalizeTestChat(rawEnv);
124
+ const overrideEnv = process.env.MANYBOT_TEST_CHAT;
125
+ if (overrideEnv) {
126
+ try {
127
+ normalized = normalizeTestChat(overrideEnv);
128
+ }
129
+ catch (e) {
130
+ throw new Error(`[${INTEGRATION_PLUGIN_NAME}] invalid MANYBOT_TEST_CHAT provided: ${e.message}`);
131
+ }
120
132
  }
121
- catch (e) {
122
- throw new Error(`[${INTEGRATION_PLUGIN_NAME}] invalid test chat provided: ${e.message}`);
133
+ else {
134
+ const cfg = await getTestConfig();
135
+ if (!cfg.chat) {
136
+ throw new Error(`[${INTEGRATION_PLUGIN_NAME}] setup() called without MANYBOT_TEST_CHAT or TEST_CHAT — ` +
137
+ `set MANYBOT_TEST_CHAT (preferred), or TEST_CHAT (env var or manybot.toml), before setupPlugins().`);
138
+ }
139
+ normalized = cfg.chat;
123
140
  }
124
141
  configuredTestChat = normalized;
125
142
  api.testChat = normalized;
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, beforeEach, describe, test } from "node:test";
3
+ import { _resetTestConfigForTests } from "#kernel/testConfig.js";
3
4
  const integrationPluginUrl = new URL("../../plugins/__manybot_integration__/index.ts", import.meta.url).href;
4
5
  describe("plugins/__manybot_integration__", () => {
5
6
  let mod;
@@ -7,13 +8,23 @@ describe("plugins/__manybot_integration__", () => {
7
8
  // Use a cache-busting query so each test gets a fresh module
8
9
  // (and therefore a fresh ring buffer + EventEmitter).
9
10
  mod = await import(`${integrationPluginUrl}?t=${Date.now()}-${Math.random()}`);
11
+ // setup() now falls back to kernel/testConfig.js's getTestConfig()
12
+ // for the TEST_CHAT-only case (see setup()'s comment), and that
13
+ // module caches its result in-process after the first read. Tests
14
+ // in this file mutate TEST_CHAT/MANYBOT_TEST_CHAT freely between
15
+ // cases, so the cache must be dropped before every test or a
16
+ // stale resolution from an earlier test leaks in.
17
+ _resetTestConfigForTests();
10
18
  });
11
19
  after(async () => {
12
20
  // Best-effort cleanup; nothing in the plugin subscribes to
13
21
  // process-level resources, but we re-clear the configured
14
- // chat so a subsequent suite (e.g. loadIntegrationPlugin.test.ts)
15
- // starts blank.
22
+ // chat (and the testConfig cache it now feeds through) so a
23
+ // subsequent suite (e.g. loadIntegrationPlugin.test.ts) starts
24
+ // blank.
16
25
  process.env.MANYBOT_TEST_CHAT = "";
26
+ delete process.env.TEST_CHAT;
27
+ _resetTestConfigForTests();
17
28
  });
18
29
  test("exports default, setup, and api", () => {
19
30
  assert.equal(typeof mod.default, "function");
@@ -27,11 +38,14 @@ describe("plugins/__manybot_integration__", () => {
27
38
  test("api.recentBodies() starts empty", () => {
28
39
  assert.deepEqual(mod.api.recentBodies(), []);
29
40
  });
30
- test("setup() throws when MANYBOT_TEST_CHAT is unset", async () => {
31
- delete process.env.MANYBOT_TEST_CHAT;
32
- const { ctx } = makeSetupCtx();
33
- await assert.rejects(mod.setup(ctx), /MANYBOT_TEST_CHAT/);
34
- });
41
+ // Note: the previous "setup() throws when MANYBOT_TEST_CHAT is unset" test
42
+ // was removed when the resolution path was widened to env-or-toml (see the
43
+ // `setup() agrees with integrationMode's own gate` regression test below).
44
+ // With toml resolution in scope, "unset" can only be asserted by also
45
+ // clearing the toml — that's testConfig.test.ts's territory, not this
46
+ // plugin's. The plugin-specific throw cases (invalid format, missing
47
+ // MANYBOT_TEST_CHAT override while TEST_CHAT is empty, etc.) are covered
48
+ // by the remaining tests below.
35
49
  test("setup() captures the test chat and isTestChat accepts it", async () => {
36
50
  process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
37
51
  const { ctx } = makeSetupCtx();
@@ -122,10 +136,30 @@ describe("plugins/__manybot_integration__", () => {
122
136
  assert.equal(mod.api.testChat, "5516999998888@s.whatsapp.net");
123
137
  assert.equal(mod.api.isTestChat("5516999998888@s.whatsapp.net"), true);
124
138
  });
139
+ test("setup() agrees with integrationMode's own gate (both resolve TEST_CHAT via getTestConfig)", async () => {
140
+ // Regression test: setup() used to read process.env.TEST_CHAT
141
+ // directly, which diverged from kernel/integrationMode.ts's gate
142
+ // (getIntegrationModeStatus -> getTestConfig, env-or-toml). That
143
+ // let the gate report "ready" off a value setup() couldn't see —
144
+ // this pins the two to the same resolution instead.
145
+ delete process.env.MANYBOT_TEST_CHAT;
146
+ process.env.TEST_CHAT = "5516999997777@s.whatsapp.net";
147
+ const { getTestConfig } = await import("#kernel/testConfig.js");
148
+ const { getIntegrationModeStatus } = await import("#kernel/integrationMode.js");
149
+ _resetTestConfigForTests();
150
+ const gateStatus = await getIntegrationModeStatus();
151
+ const { ctx } = makeSetupCtx();
152
+ await mod.setup(ctx);
153
+ assert.equal(gateStatus.chat, mod.api.testChat, "gate and setup() must resolve the same chat");
154
+ assert.equal(mod.api.testChat, "5516999997777@s.whatsapp.net");
155
+ // Sanity: both are backed by the same cached getTestConfig() call.
156
+ const cfg = await getTestConfig();
157
+ assert.equal(cfg.chat, mod.api.testChat);
158
+ });
125
159
  test("setup() throws when given an invalid test chat format", async () => {
126
160
  process.env.MANYBOT_TEST_CHAT = "not_a_valid_jid";
127
161
  const { ctx } = makeSetupCtx();
128
- await assert.rejects(mod.setup(ctx), /invalid test chat provided/);
162
+ await assert.rejects(mod.setup(ctx), /invalid MANYBOT_TEST_CHAT provided/);
129
163
  });
130
164
  test("ring buffer caps at 50 messages and evicts oldest", async () => {
131
165
  process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * src/utils/phoneNumber.ts
3
+ *
4
+ * Phone-number normalization built on `libphonenumber-js`. Centralized so
5
+ * the Baileys adapter, the `whatsmeow` driver, and any future call site
6
+ * produce a single, consistent shape for the `IContact` returned to
7
+ * plugins:
8
+ *
9
+ * {
10
+ * number: "+12025550100", // E.164 with leading "+"
11
+ * numberRaw: "12025550100", // digits only, no "+"
12
+ * numberPretty: "+1 202 555-0100", // formatInternational()
13
+ * country: "US", // ISO 3166-1 alpha-2
14
+ * countryCallingCode: "1", // ITU calling code
15
+ * }
16
+ *
17
+ * When the input is not a parseable phone number (bot JIDs, group JIDs,
18
+ * random strings, malformed numbers) every field is `null`. The helper
19
+ * never throws and never calls the network — it's a pure synchronous
20
+ * transform over the digits.
21
+ *
22
+ * `libphonenumber-js` has many format variants; we pick `formatInternational()`
23
+ * for `numberPretty` because it's the form most users expect to see in
24
+ * a chat reply ("+63 938 346-4136") and it preserves the country-code
25
+ * prefix unambiguously.
26
+ */
27
+ import parsePhoneNumber from "libphonenumber-js";
28
+ /** All-null {@link ParsedPhoneNumber} — the obvious return value when
29
+ * the input isn't a phone number at all. Exported so test code (and
30
+ * any consumer that wants to deep-compare) has a stable reference. */
31
+ export const NULL_PHONE = Object.freeze({
32
+ number: null,
33
+ numberRaw: null,
34
+ numberPretty: null,
35
+ country: null,
36
+ countryCallingCode: null,
37
+ });
38
+ function isParseable(p) {
39
+ // libphonenumber-js's `parsePhoneNumberFromString` returns a PhoneNumber
40
+ // even for garbage input — but `country` is only set when the parser
41
+ // could pin a country. Use that as the "this is a real phone" signal:
42
+ // a country-less parse has nothing useful to say about country/CC.
43
+ return Boolean(p && p.country);
44
+ }
45
+ /**
46
+ * Parse a phone number into the canonical ManyBot shape. Accepts either
47
+ * a phone-based JID (`"5511999999999@s.whatsapp.net"`), a `@c.us` legacy
48
+ * form (`"5511999999999@c.us"`), or a plain digit string. Returns the
49
+ * all-null `NULL_PHONE` when the input is empty, a non-phone JID (group
50
+ * `@g.us`, bot meta-AI, status broadcast), or simply not parseable.
51
+ */
52
+ export function parsePhone(value) {
53
+ if (!value)
54
+ return NULL_PHONE;
55
+ // Strip JID wrapper if present. Anything ending in a non-`@s.whatsapp.net` /
56
+ // non-`@c.us` server (groups, status, newsletters, bot meta) isn't a phone
57
+ // number at all — short-circuit to NULL_PHONE before calling the parser.
58
+ const isJid = value.includes("@");
59
+ if (isJid) {
60
+ const server = value.slice(value.indexOf("@") + 1);
61
+ if (server !== "s.whatsapp.net" && server !== "c.us")
62
+ return NULL_PHONE;
63
+ }
64
+ const digits = value.replace(/\D/g, "");
65
+ if (!digits)
66
+ return NULL_PHONE;
67
+ // Try the raw digit form first (covers the "5511999999999@s.whatsapp.net"
68
+ // case where the parser knows no default country); the "+"-prefixed form
69
+ // second (covers "5511999999999" when a default country is supplied by the
70
+ // caller). Without a country hint libphonenumber-js can't tell a Brazilian
71
+ // 55-prefix from a Portuguese 55-prefix and returns no country — that's
72
+ // why the wrapper JID case is the common path.
73
+ const parsed = parsePhoneNumber(value) ?? parsePhoneNumber("+" + digits);
74
+ if (!isParseable(parsed))
75
+ return NULL_PHONE;
76
+ return {
77
+ number: parsed.number,
78
+ numberRaw: digits,
79
+ numberPretty: parsed.formatInternational(),
80
+ country: parsed.country ?? null,
81
+ countryCallingCode: parsed.countryCallingCode ?? null,
82
+ };
83
+ }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { parsePhone, NULL_PHONE } from "#utils/phoneNumber.js";
4
+ test("parsePhone — US number (NANPA +1)", () => {
5
+ // Use the 555-0100 reserved-for-fiction range so the example never
6
+ // collides with a real subscriber anywhere on Earth.
7
+ const r = parsePhone("+12025550100");
8
+ assert.equal(r.number, "+12025550100");
9
+ assert.equal(r.numberRaw, "12025550100");
10
+ assert.equal(r.country, "US");
11
+ assert.equal(r.countryCallingCode, "1");
12
+ assert.match(r.numberPretty, /^\+1 /);
13
+ });
14
+ test("parsePhone — BR number from @s.whatsapp.net JID", () => {
15
+ const r = parsePhone("5516999999999@s.whatsapp.net");
16
+ assert.equal(r.number, "+5516999999999");
17
+ assert.equal(r.numberRaw, "5516999999999");
18
+ assert.equal(r.country, "BR");
19
+ assert.equal(r.countryCallingCode, "55");
20
+ assert.match(r.numberPretty, /^\+55 /);
21
+ });
22
+ test("parsePhone — US number with leading +", () => {
23
+ const r = parsePhone("+14155552671");
24
+ assert.equal(r.country, "US");
25
+ assert.equal(r.countryCallingCode, "1");
26
+ assert.equal(r.numberRaw, "14155552671");
27
+ });
28
+ test("parsePhone — group JID is not a phone number", () => {
29
+ // @g.us must NOT be parsed as a phone number, even though its user-part
30
+ // is digit-only. The wrapper short-circuits to NULL_PHONE.
31
+ assert.deepEqual(parsePhone("120363402117932687@g.us"), NULL_PHONE);
32
+ });
33
+ test("parsePhone — status/broadcast/newsletter JIDs are not phones", () => {
34
+ assert.deepEqual(parsePhone("status@broadcast"), NULL_PHONE);
35
+ assert.deepEqual(parsePhone("0@c.us"), NULL_PHONE);
36
+ });
37
+ test("parsePhone — empty / null / undefined all return NULL_PHONE", () => {
38
+ assert.deepEqual(parsePhone(""), NULL_PHONE);
39
+ assert.deepEqual(parsePhone(null), NULL_PHONE);
40
+ assert.deepEqual(parsePhone(undefined), NULL_PHONE);
41
+ });
42
+ test("parsePhone — unparseable digit string returns NULL_PHONE", () => {
43
+ // 7 digits with no country hint → libphonenumber-js returns no country
44
+ // for the raw form and no useful result for the +N form either.
45
+ assert.deepEqual(parsePhone("1234567"), NULL_PHONE);
46
+ });
47
+ test("parsePhone — accepts the legacy @c.us form (framework internal JID)", () => {
48
+ // Some code paths still hand us the framework's normalized "@c.us" form
49
+ // instead of the raw "@s.whatsapp.net" one. Both should parse identically.
50
+ const wire = parsePhone("5516999999999@s.whatsapp.net");
51
+ const internal = parsePhone("5516999999999@c.us");
52
+ assert.deepEqual(internal, wire);
53
+ });
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "5.8.0",
8
+ "version": "5.9.1",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {
@@ -33,7 +33,7 @@
33
33
  "test:integration": "NODE_ENV=test MANYBOT_RUN_WHATSAPP_TESTS=1 bash -O globstar -c 'tsx --conditions development --test src/**/*.integration.test.ts'",
34
34
  "test:integration:local": "NODE_ENV=test MANYBOT_RUN_WHATSAPP_TESTS=1 bash -O globstar -c 'tsx --conditions development --import ./src/main.ts --test src/**/*.integration.test.ts'",
35
35
  "typecheck": "tsc --noEmit",
36
- "check": "npm run typecheck && npm run lint && npm run test"
36
+ "check": "npm run typecheck && npm run lint && npm run test && npx tsx scripts/check-types-drift.ts"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^10.0.1",
@@ -51,8 +51,9 @@
51
51
  "@grpc/grpc-js": "^1.14.4",
52
52
  "@grpc/proto-loader": "^0.7.15",
53
53
  "@hapi/boom": "^10.0.1",
54
- "@whiskeysockets/baileys": "6.7.24",
54
+ "@whiskeysockets/baileys": "^7.0.0-rc14",
55
55
  "js-yaml": "^5.2.3",
56
+ "libphonenumber-js": "^1.13.11",
56
57
  "node-cron": "^4.6.0",
57
58
  "node-webpmux": "^3.2.1",
58
59
  "nodemailer": "^9.0.3",