levix-bot 2.0.0 → 2.1.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.
@@ -0,0 +1,266 @@
1
+ // The outbound proxy for the WhatsApp session — and for nothing else.
2
+ //
3
+ // WHY TWO AGENT SHAPES
4
+ // --------------------
5
+ // Baileys rc14 does not have one network layer, it has two, and they want
6
+ // different objects:
7
+ //
8
+ // * the WebSocket goes through the `ws` library, which puts the agent into
9
+ // https.request (Socket/Client/websocket.js:30 — `agent: this.config.agent`).
10
+ // Classic http.Agent.
11
+ // * media UPLOAD goes through `uploadMedia`, and on Node that is
12
+ // `uploadWithNodeHttp` — plain https.request again (Utils/messages-media.js:
13
+ // 633-642 branches on isNodeRuntime(); the fetch/dispatcher branch beside it
14
+ // is for Bun and Deno only). Classic http.Agent, via `fetchAgent`.
15
+ // * media DOWNLOAD goes through `getHttpStream`, which is Node's global fetch
16
+ // — and fetch takes an undici Dispatcher and nothing else
17
+ // (Utils/messages-media.js:297-301, `dispatcher: options.dispatcher`).
18
+ //
19
+ // Two of the three want a classic agent and the third wants a dispatcher, so
20
+ // this module builds both shapes from one configuration. Getting that mapping
21
+ // backwards is not a silent no-op in either direction: an undici dispatcher
22
+ // handed to https.request breaks uploads, and a classic agent handed to fetch
23
+ // is ignored, which would leave every download going out from the real IP.
24
+ //
25
+ // WHAT IS NOT PROXIED
26
+ // -------------------
27
+ // Nothing global is patched. The agents are handed to one `makeWASocket()`
28
+ // call by the session manager, so the dashboard's own HTTP server, Gemini,
29
+ // Groq and every other outbound request keep using the direct connection they
30
+ // use today.
31
+ //
32
+ // SECRETS
33
+ // -------
34
+ // The password is an ordinary `secret` setting, so it is stored like the API
35
+ // keys, never returned by the settings API, and never printed by settings.set.
36
+ // On top of that: `proxyUrl()` is the only function that puts the password in a
37
+ // string, its result never reaches a log or an HTTP response, and everything
38
+ // human-facing goes through `redactProxy()`.
39
+
40
+ import { createRequire } from "module";
41
+ import { HttpsProxyAgent } from "https-proxy-agent";
42
+ import { SocksProxyAgent } from "socks-proxy-agent";
43
+ import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent } from "undici";
44
+ import { SocksClient } from "socks";
45
+ import tls from "node:tls";
46
+
47
+ const require = createRequire(import.meta.url);
48
+ const settings = require("../config/settings.cjs");
49
+
50
+ export const PROXY_PROTOCOLS = Object.freeze(["http", "https", "socks5"]);
51
+
52
+ const MIN_PORT = 1;
53
+ const MAX_PORT = 65535;
54
+
55
+ /** Everything the operator can set, read at call time like every other setting. */
56
+ export function readProxyConfig() {
57
+ return normalizeProxyConfig({
58
+ enabled: settings.get("whatsapp_proxy_enabled"),
59
+ protocol: settings.get("whatsapp_proxy_protocol"),
60
+ host: settings.get("whatsapp_proxy_host"),
61
+ port: settings.get("whatsapp_proxy_port"),
62
+ username: settings.get("whatsapp_proxy_username"),
63
+ password: settings.get("whatsapp_proxy_password"),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Trim, coerce and check. Throws only on a value that cannot be used at all;
69
+ * a disabled proxy is never validated, so half-filled settings can be saved
70
+ * while the operator is still typing.
71
+ *
72
+ * @throws {Error} with a message safe to show a browser — it never contains
73
+ * the password.
74
+ */
75
+ export function normalizeProxyConfig(raw = {}) {
76
+ const enabled = raw.enabled === true || raw.enabled === "true" || raw.enabled === 1;
77
+ const protocol = String(raw.protocol ?? "http").trim().toLowerCase();
78
+ const host = String(raw.host ?? "").trim();
79
+ const port = Number(raw.port ?? 0);
80
+ // Usernames and passwords are taken exactly as given except for surrounding
81
+ // whitespace, which is almost always a copy-paste accident.
82
+ const username = String(raw.username ?? "").trim();
83
+ const password = String(raw.password ?? "");
84
+
85
+ const config = { enabled, protocol, host, port, username, password };
86
+ if (!enabled) return config;
87
+
88
+ if (!PROXY_PROTOCOLS.includes(protocol)) {
89
+ throw new Error(`Proxy protocol must be one of: ${PROXY_PROTOCOLS.join(", ")}`);
90
+ }
91
+ if (!host) {
92
+ throw new Error("Proxy host is required when the proxy is enabled");
93
+ }
94
+ if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
95
+ throw new Error(`Proxy port must be a whole number between ${MIN_PORT} and ${MAX_PORT}`);
96
+ }
97
+
98
+ return config;
99
+ }
100
+
101
+ /**
102
+ * The authenticated URL the agents want.
103
+ *
104
+ * Credentials are percent-encoded because all three agent libraries
105
+ * `decodeURIComponent` them back out (verified in https-proxy-agent
106
+ * dist/index.js:89, socks-proxy-agent dist/index.js:59, undici
107
+ * proxy-agent.js:142) — so a password containing `@`, `:` or `/` survives the
108
+ * round trip instead of corrupting the URL.
109
+ *
110
+ * NEVER log, return or embed this. Use redactProxy() for anything a human or a
111
+ * browser will see.
112
+ */
113
+ export function proxyUrl(config) {
114
+ const auth = config.username
115
+ ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
116
+ : "";
117
+ return `${config.protocol}://${auth}${config.host}:${config.port}`;
118
+ }
119
+
120
+ /** The same thing with the password removed. This is what may be shown. */
121
+ export function redactProxy(config) {
122
+ if (!config?.enabled) return null;
123
+ const auth = config.username ? `${config.username}:***@` : "";
124
+ return `${config.protocol}://${auth}${config.host}:${config.port}`;
125
+ }
126
+
127
+ /**
128
+ * An opaque value that changes whenever the effective proxy changes.
129
+ *
130
+ * Stays on the server: it is compared against the config the live socket was
131
+ * built with to decide whether a reconnect would change anything. Only the
132
+ * boolean answer is ever sent to a browser, so the password is not handed out
133
+ * as a hash to be attacked offline.
134
+ */
135
+ export function proxyFingerprint(config) {
136
+ if (!config?.enabled) return "disabled";
137
+ return proxyUrl(config);
138
+ }
139
+
140
+ /**
141
+ * Build the agents for one socket, or null when the proxy is off.
142
+ *
143
+ * @returns {{ agent: object, fetchAgent: object, dispatcher: object, label: string } | null}
144
+ */
145
+ export function createProxyAgents(config) {
146
+ if (!config?.enabled) return null;
147
+
148
+ const url = proxyUrl(config);
149
+ const socks = config.protocol === "socks5";
150
+
151
+ // For https.request: the WebSocket handshake and, on Node, media upload.
152
+ const agent = socks ? new SocksProxyAgent(url) : new HttpsProxyAgent(url);
153
+
154
+ // For global fetch: media download, app-state and history blobs. undici's own
155
+ // ProxyAgent speaks HTTP CONNECT only, so SOCKS5 gets a plain undici Agent
156
+ // whose socket factory dials through the SOCKS server instead.
157
+ const dispatcher = socks ? socksDispatcher(config) : new UndiciProxyAgent(url);
158
+
159
+ return {
160
+ agent,
161
+ // A second classic agent rather than the same instance: upload and the
162
+ // WebSocket are long-lived, independent connections, and sharing one
163
+ // agent's socket pool between them has no benefit.
164
+ fetchAgent: socks ? new SocksProxyAgent(url) : new HttpsProxyAgent(url),
165
+ dispatcher,
166
+ label: redactProxy(config),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * An undici Dispatcher that reaches the origin through a SOCKS5 server.
172
+ *
173
+ * undici hands us the target and expects a connected (and, for https, TLS-
174
+ * wrapped) socket back. `socks` does the SOCKS5 handshake; the TLS upgrade is
175
+ * ours to do, exactly as undici's own default connector would.
176
+ */
177
+ function socksDispatcher(config) {
178
+ const proxy = {
179
+ host: config.host,
180
+ port: config.port,
181
+ type: 5,
182
+ ...(config.username
183
+ ? { userId: config.username, password: config.password }
184
+ : {}),
185
+ };
186
+
187
+ return new UndiciAgent({
188
+ connect: (options, callback) => {
189
+ const port = Number(options.port) || (options.protocol === "http:" ? 80 : 443);
190
+ SocksClient.createConnection({
191
+ proxy,
192
+ command: "connect",
193
+ destination: { host: options.hostname, port },
194
+ })
195
+ .then(({ socket }) => {
196
+ if (options.protocol !== "https:") return callback(null, socket);
197
+ const secure = tls.connect({
198
+ ...options,
199
+ socket,
200
+ servername: options.servername || options.hostname,
201
+ });
202
+ secure.once("secureConnect", () => callback(null, secure));
203
+ secure.once("error", (error) => callback(error, null));
204
+ })
205
+ .catch((error) => callback(error, null));
206
+ },
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Turn a connection failure into something an operator can act on.
212
+ *
213
+ * Returns null when the failure has nothing to do with the proxy, so the
214
+ * session's own disconnect classification stays in charge of what the state
215
+ * becomes — this only enriches the sentence shown next to it.
216
+ *
217
+ * The proxy is named in redacted form; no credential ever reaches this string.
218
+ */
219
+ export function describeProxyFailure(error, config) {
220
+ if (!config?.enabled) return null;
221
+
222
+ const where = redactProxy(config);
223
+ const code = error?.code || error?.cause?.code || "";
224
+ const message = `${error?.message || ""} ${error?.cause?.message || ""}`.toLowerCase();
225
+
226
+ if (code === "ECONNREFUSED" || message.includes("econnrefused")) {
227
+ return `The proxy at ${where} refused the connection.`;
228
+ }
229
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN" || message.includes("enotfound")) {
230
+ return `The proxy host ${where} could not be resolved.`;
231
+ }
232
+ if (
233
+ code === "ETIMEDOUT" ||
234
+ message.includes("etimedout") ||
235
+ message.includes("timeout") ||
236
+ message.includes("timed out")
237
+ ) {
238
+ return `The proxy at ${where} timed out.`;
239
+ }
240
+ // https-proxy-agent surfaces the CONNECT response verbatim; socks throws its
241
+ // own auth failures.
242
+ if (
243
+ message.includes("407") ||
244
+ message.includes("proxy authentication") ||
245
+ message.includes("authentication failed") ||
246
+ message.includes("socks5 authentication")
247
+ ) {
248
+ return `The proxy at ${where} rejected the username or password.`;
249
+ }
250
+ if (message.includes("socks")) {
251
+ return `The SOCKS proxy at ${where} refused the connection.`;
252
+ }
253
+
254
+ return `Could not reach WhatsApp through the proxy at ${where}.`;
255
+ }
256
+
257
+ export default {
258
+ PROXY_PROTOCOLS,
259
+ readProxyConfig,
260
+ normalizeProxyConfig,
261
+ proxyUrl,
262
+ redactProxy,
263
+ proxyFingerprint,
264
+ createProxyAgents,
265
+ describeProxyFailure,
266
+ };