Skip to content

Commit c2566d9

Browse files
authored
Merge pull request #227 from arespawn:pair-code-handiling
Fix /pairwithcode phone input handling
2 parents c3908bb + 7fd9a91 commit c2566d9

7 files changed

Lines changed: 262 additions & 15 deletions

File tree

.github/workflows/new-release-v2.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ jobs:
3232
- name: Generate binaries
3333
run: |
3434
mkdir -p build
35-
npx -y @yao-pkg/pkg out.cjs -t node24-linux-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-Linux
36-
npx -y @yao-pkg/pkg out.cjs -t node24-linux-arm64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-Linux-arm64
37-
npx -y @yao-pkg/pkg out.cjs -t node24-macos-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-macOS
38-
npx -y @yao-pkg/pkg out.cjs -t node24-win-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC.exe
35+
npx -y @yao-pkg/pkg@6.19.0 out.cjs -t node24-linux-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-Linux
36+
npx -y @yao-pkg/pkg@6.19.0 out.cjs -t node24-linux-arm64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-Linux-arm64
37+
npx -y @yao-pkg/pkg@6.19.0 out.cjs -t node24-macos-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC-macOS
38+
npx -y @yao-pkg/pkg@6.19.0 out.cjs -t node24-win-x64 --options no-warnings --no-bytecode --public --public-packages "*" -o build/WA2DC.exe
3939
4040
- name: Generate runtime sidecar archives
4141
run: |

docs/commands.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ All bot controls now run exclusively through Discord slash commands. Type `/` in
88

99
### `/pairwithcode`
1010
Request a pairing code for a specific phone number.
11-
Usage: `/pairwithcode number:<E.164 phone number>`
11+
Usage: `/pairwithcode phone:<E.164 phone number>`
12+
Note: `phone` can include a leading `+`, spaces, dashes, dots, or parentheses; WA2DC normalizes it before requesting the pairing code.
1213

1314
### `/chatinfo`
1415
Show which WhatsApp chat the current channel/thread is linked to (JID + type), plus the Discord target mode.

docs/dev/testing-and-release.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Testing And Release
22

33
> Owner: WA2DC maintainers
4-
> Last reviewed: 2026-03-19
4+
> Last reviewed: 2026-05-07
55
> Scope: Validation commands, CI expectations, and packaging constraints.
66
77
## Validation matrix
@@ -24,8 +24,8 @@ CI executes the following on `ubuntu-latest`, `macos-latest`, and `windows-lates
2424
Release pipeline builds packaged binaries from an ESM runtime bundle plus a pkg-safe CJS bootstrap:
2525

2626
- esbuild bundles `src/runner.js` to `out.js` (ESM) for Node smoke checks
27-
- esbuild also bundles `src/runner.js` to `out.js` (ESM) for pkg, then writes `out.cjs` as a tiny bootstrap that dynamically imports `out.js`
28-
- `pkg` produces platform binaries from `out.cjs` with `--no-bytecode`
27+
- esbuild also bundles `src/runner.js` to `out.js` (ESM) for pkg, then writes `out.cjs` as a tiny bootstrap that loads `out.js` from pkg's virtual filesystem and dynamically imports it
28+
- pinned `@yao-pkg/pkg` produces platform binaries from `out.cjs` with `--no-bytecode`
2929
- packaged builds also stage `build/runtime/` as a sidecar for runtime-only media dependencies (`sharp`, `canvas`, `jsdom`, `lottie-web`) so native image normalization and Discord sticker rendering remain available in packaged runtimes
3030
- release builds publish a signed `${binary}.runtime.tar.gz` archive for each packaged binary so `/update` can refresh the sidecar automatically
3131
- packaged startup may download that signed runtime archive on demand when a packaged install is missing `runtime/`

scripts/buildBinary.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66

77
const require = createRequire(import.meta.url);
88
const RUNTIME_SIDECAR_DEPENDENCIES = ["sharp", "canvas", "jsdom", "lottie-web"];
9+
const PKG_PACKAGE_SPEC = process.env.WA2DC_PKG_PACKAGE || "@yao-pkg/pkg@6.19.0";
910

1011
function platformToPkgOs(platform) {
1112
if (platform === "win32") return "win";
@@ -123,7 +124,7 @@ run(getBin("npm"), ["run", "bundle:pkg"]);
123124

124125
const pkgArgs = [
125126
"-y",
126-
"@yao-pkg/pkg",
127+
PKG_PACKAGE_SPEC,
127128
pkgEntrypoint,
128129
"-t",
129130
target,

scripts/buildPkgBundle.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ await esbuild.build({
1818
outfile: "out.js",
1919
});
2020

21-
const bundleBase64 = fs.readFileSync("out.js", "base64");
2221
const pkgBootstrap = `'use strict';
2322
23+
const fs = require('fs');
24+
const path = require('path');
25+
2426
globalThis.__wa2dcPkgRequire = require;
2527
26-
const bundleUrl = "data:text/javascript;base64,${bundleBase64}";
28+
const bundleBase64 = fs.readFileSync(path.join(__dirname, 'out.js'), 'base64');
29+
const bundleUrl = \`data:text/javascript;base64,\${bundleBase64}\`;
2730
2831
(async () => {
2932
await import(bundleUrl);

src/discordHandler.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,48 @@ const requireNewsletterMethod = async (ctx, methodName) => {
21832183
return method.bind(state.waClient);
21842184
};
21852185

2186+
const getStringOrNumberOptionValue = (ctx, optionName) => {
2187+
try {
2188+
const stringValue = ctx.getStringOption(optionName);
2189+
if (stringValue !== null && stringValue !== undefined) {
2190+
return stringValue;
2191+
}
2192+
} catch {}
2193+
2194+
try {
2195+
const numberValue = ctx.getNumberOption(optionName);
2196+
if (numberValue !== null && numberValue !== undefined) {
2197+
return numberValue;
2198+
}
2199+
} catch {}
2200+
2201+
try {
2202+
const integerValue = ctx.getIntegerOption(optionName);
2203+
if (integerValue !== null && integerValue !== undefined) {
2204+
return integerValue;
2205+
}
2206+
} catch {}
2207+
2208+
return null;
2209+
};
2210+
2211+
const normalizePairingPhoneNumber = (value) => {
2212+
if (
2213+
typeof value === "number" &&
2214+
(!Number.isFinite(value) || !Number.isInteger(value))
2215+
) {
2216+
return null;
2217+
}
2218+
2219+
const raw = String(value ?? "").trim();
2220+
if (!raw || !/^\+?[\d\s().-]+$/.test(raw)) {
2221+
return null;
2222+
}
2223+
2224+
const digits = raw.replace(/\D/g, "");
2225+
return /^[1-9]\d{6,14}$/.test(digits) ? digits : null;
2226+
};
2227+
21862228
const commandHandlers = {
21872229
ping: {
21882230
description: "Check the bot latency.",
@@ -2230,17 +2272,20 @@ const commandHandlers = {
22302272
description: "Request a WhatsApp pairing code.",
22312273
options: [
22322274
{
2233-
name: "number",
2234-
description: "Phone number with country code.",
2275+
name: "phone",
2276+
description: "Phone number with country code, such as +12025550123.",
22352277
type: ApplicationCommandOptionTypes.STRING,
22362278
required: true,
22372279
},
22382280
],
22392281
async execute(ctx) {
2240-
const number = ctx.getStringOption("number");
2282+
const rawNumber =
2283+
getStringOrNumberOptionValue(ctx, "phone") ??
2284+
getStringOrNumberOptionValue(ctx, "number");
2285+
const number = normalizePairingPhoneNumber(rawNumber);
22412286
if (!number) {
22422287
await ctx.reply(
2243-
'Please enter your number. Usage: `pairWithCode <number>`. Don\'t use "+" or any other special characters.',
2288+
"Please enter a phone number with country code, such as `+12025550123` or `12025550123`.",
22442289
);
22452290
return;
22462291
}

tests/updateCommands.test.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
33
import test from "node:test";
44
import { setTimeout as delay } from "node:timers/promises";
55

6+
import discordJs from "discord.js";
67
import {
78
resetClientFactoryOverrides,
89
setClientFactoryOverrides,
@@ -19,6 +20,8 @@ import initIsolatedStorage from "./helpers/initIsolatedStorage.js";
1920

2021
await initIsolatedStorage(import.meta.url);
2122

23+
const { ApplicationCommandOptionType } = discordJs;
24+
2225
const importDiscordHandler = async (tag) =>
2326
(await import(`../src/discordHandler.js?test=${encodeURIComponent(tag)}`))
2427
.default;
@@ -1029,6 +1032,200 @@ test("/poll in a newsletter-linked channel falls back to text when interactive a
10291032
}
10301033
});
10311034

1035+
test("/pairwithcode registers a phone string option", async () => {
1036+
const originalDiscordUtils = {
1037+
getGuild: utils.discord.getGuild,
1038+
getControlChannel: utils.discord.getControlChannel,
1039+
};
1040+
const originalSettings = {
1041+
Token: state.settings.Token,
1042+
GuildID: state.settings.GuildID,
1043+
ControlChannelID: state.settings.ControlChannelID,
1044+
};
1045+
const originalDcClient = state.dcClient;
1046+
1047+
try {
1048+
state.settings.Token = "TEST_TOKEN";
1049+
state.settings.GuildID = "guild";
1050+
state.settings.ControlChannelID = "control";
1051+
1052+
let registeredCommands = null;
1053+
utils.discord.getGuild = async () => ({
1054+
commands: {
1055+
set: async (commands) => {
1056+
registeredCommands = commands;
1057+
},
1058+
},
1059+
});
1060+
utils.discord.getControlChannel = async () => ({ send: async () => {} });
1061+
1062+
const fakeClient = new FakeDiscordClient();
1063+
setClientFactoryOverrides({ createDiscordClient: () => fakeClient });
1064+
const discordHandler = await importDiscordHandler(
1065+
"pairwithcode-registration",
1066+
);
1067+
state.dcClient = await discordHandler.start();
1068+
1069+
assert.equal(await waitFor(() => registeredCommands), true);
1070+
const command = registeredCommands.find(
1071+
(candidate) => candidate.name === "pairwithcode",
1072+
);
1073+
assert.ok(command);
1074+
assert.deepEqual(command.options, [
1075+
{
1076+
name: "phone",
1077+
description: "Phone number with country code, such as +12025550123.",
1078+
type: ApplicationCommandOptionType.String,
1079+
required: true,
1080+
},
1081+
]);
1082+
} finally {
1083+
utils.discord.getGuild = originalDiscordUtils.getGuild;
1084+
utils.discord.getControlChannel = originalDiscordUtils.getControlChannel;
1085+
1086+
state.settings.Token = originalSettings.Token;
1087+
state.settings.GuildID = originalSettings.GuildID;
1088+
state.settings.ControlChannelID = originalSettings.ControlChannelID;
1089+
1090+
state.dcClient = originalDcClient;
1091+
resetClientFactoryOverrides();
1092+
}
1093+
});
1094+
1095+
test("/pairwithcode accepts E.164 input and sends digits to Baileys", async () => {
1096+
const originalDiscordUtils = {
1097+
getGuild: utils.discord.getGuild,
1098+
getControlChannel: utils.discord.getControlChannel,
1099+
};
1100+
const originalSettings = {
1101+
Token: state.settings.Token,
1102+
GuildID: state.settings.GuildID,
1103+
ControlChannelID: state.settings.ControlChannelID,
1104+
};
1105+
const originalDcClient = state.dcClient;
1106+
const originalWaClient = state.waClient;
1107+
1108+
try {
1109+
state.settings.Token = "TEST_TOKEN";
1110+
state.settings.GuildID = "guild";
1111+
state.settings.ControlChannelID = "control";
1112+
1113+
utils.discord.getGuild = async () => ({
1114+
commands: { set: async () => {} },
1115+
});
1116+
utils.discord.getControlChannel = async () => ({ send: async () => {} });
1117+
1118+
const pairingRequests = [];
1119+
state.waClient = {
1120+
async requestPairingCode(phoneNumber) {
1121+
pairingRequests.push(phoneNumber);
1122+
return "ABCD-1234";
1123+
},
1124+
};
1125+
1126+
const fakeClient = new FakeDiscordClient();
1127+
setClientFactoryOverrides({ createDiscordClient: () => fakeClient });
1128+
const discordHandler = await importDiscordHandler("pairwithcode-e164");
1129+
state.dcClient = await discordHandler.start();
1130+
await delay(0);
1131+
1132+
const interaction = createInteraction({
1133+
channelId: "control",
1134+
commandName: "pairwithcode",
1135+
stringOptions: {
1136+
phone: "+20 (10) 123-4567",
1137+
},
1138+
});
1139+
fakeClient.emit("interactionCreate", interaction);
1140+
await delay(0);
1141+
1142+
assert.deepEqual(pairingRequests, ["20101234567"]);
1143+
assert.equal(interaction.records.editReply.length, 1);
1144+
assert.equal(
1145+
interaction.records.editReply[0]?.content,
1146+
"Your pairing code is: ABCD-1234",
1147+
);
1148+
} finally {
1149+
utils.discord.getGuild = originalDiscordUtils.getGuild;
1150+
utils.discord.getControlChannel = originalDiscordUtils.getControlChannel;
1151+
1152+
state.settings.Token = originalSettings.Token;
1153+
state.settings.GuildID = originalSettings.GuildID;
1154+
state.settings.ControlChannelID = originalSettings.ControlChannelID;
1155+
1156+
state.dcClient = originalDcClient;
1157+
state.waClient = originalWaClient;
1158+
resetClientFactoryOverrides();
1159+
}
1160+
});
1161+
1162+
test("/pairwithcode still accepts legacy number option interactions", async () => {
1163+
const originalDiscordUtils = {
1164+
getGuild: utils.discord.getGuild,
1165+
getControlChannel: utils.discord.getControlChannel,
1166+
};
1167+
const originalSettings = {
1168+
Token: state.settings.Token,
1169+
GuildID: state.settings.GuildID,
1170+
ControlChannelID: state.settings.ControlChannelID,
1171+
};
1172+
const originalDcClient = state.dcClient;
1173+
const originalWaClient = state.waClient;
1174+
1175+
try {
1176+
state.settings.Token = "TEST_TOKEN";
1177+
state.settings.GuildID = "guild";
1178+
state.settings.ControlChannelID = "control";
1179+
1180+
utils.discord.getGuild = async () => ({
1181+
commands: { set: async () => {} },
1182+
});
1183+
utils.discord.getControlChannel = async () => ({ send: async () => {} });
1184+
1185+
const pairingRequests = [];
1186+
state.waClient = {
1187+
async requestPairingCode(phoneNumber) {
1188+
pairingRequests.push(phoneNumber);
1189+
return "WXYZ-9876";
1190+
},
1191+
};
1192+
1193+
const fakeClient = new FakeDiscordClient();
1194+
setClientFactoryOverrides({ createDiscordClient: () => fakeClient });
1195+
const discordHandler = await importDiscordHandler("pairwithcode-legacy");
1196+
state.dcClient = await discordHandler.start();
1197+
await delay(0);
1198+
1199+
const interaction = createInteraction({
1200+
channelId: "control",
1201+
commandName: "pairwithcode",
1202+
stringOptions: {
1203+
number: "+1 202 555 0123",
1204+
},
1205+
});
1206+
fakeClient.emit("interactionCreate", interaction);
1207+
await delay(0);
1208+
1209+
assert.deepEqual(pairingRequests, ["12025550123"]);
1210+
assert.equal(interaction.records.editReply.length, 1);
1211+
assert.equal(
1212+
interaction.records.editReply[0]?.content,
1213+
"Your pairing code is: WXYZ-9876",
1214+
);
1215+
} finally {
1216+
utils.discord.getGuild = originalDiscordUtils.getGuild;
1217+
utils.discord.getControlChannel = originalDiscordUtils.getControlChannel;
1218+
1219+
state.settings.Token = originalSettings.Token;
1220+
state.settings.GuildID = originalSettings.GuildID;
1221+
state.settings.ControlChannelID = originalSettings.ControlChannelID;
1222+
1223+
state.dcClient = originalDcClient;
1224+
state.waClient = originalWaClient;
1225+
resetClientFactoryOverrides();
1226+
}
1227+
});
1228+
10321229
test("/setwamediaburstsize updates WhatsApp to Discord media burst size", async () => {
10331230
const originalDiscordUtils = {
10341231
getGuild: utils.discord.getGuild,

0 commit comments

Comments
 (0)