@manybot/manybot 5.8.0 → 5.9.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/README.md +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +212 -38
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +4 -3
package/dist/locales/en.json
CHANGED
|
@@ -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}}"
|
package/dist/locales/es.json
CHANGED
|
@@ -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
|
|
86
|
-
"ownerOnly": "
|
|
87
|
-
"
|
|
88
|
-
"
|
|
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}}"
|
package/dist/locales/pt.json
CHANGED
|
@@ -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": "
|
|
85
|
-
"senderNotAdmin": "
|
|
86
|
-
"ownerOnly": "
|
|
87
|
-
"
|
|
88
|
-
"
|
|
93
|
+
"botNotAdmin": "Esse comando precisa que o bot seja admin do grupo.",
|
|
94
|
+
"senderNotAdmin": "Esse comando é só pra admins do grupo.",
|
|
95
|
+
"ownerOnly": "Só o dono do bot pode usar esse comando.",
|
|
96
|
+
"donoOnly": "Só 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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
15
|
-
//
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
+
"version": "5.9.0",
|
|
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": "
|
|
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",
|