levix-bot 2.0.1 → 2.1.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.
- package/README.md +23 -5
- package/SETUP.md +24 -4
- package/deploy/install.sh +106 -11
- package/deploy/levix.service +3 -0
- package/package.json +11 -6
- package/public/dashboard.css +9 -0
- package/public/dashboard.js +233 -43
- package/scheduler.cjs +15 -0
- package/src/bootstrap/core.js +36 -32
- package/src/bootstrap/events.cjs +4 -2
- package/src/bootstrap/panel.js +9 -7
- package/src/commands/gemini.cjs +45 -28
- package/src/commands/restart.cjs +6 -2
- package/src/commands/stt.cjs +43 -26
- package/src/config/ai-identity.cjs +61 -0
- package/src/config/ai-persona.md +92 -17
- package/src/config/brand.cjs +8 -18
- package/src/config/constants.js +11 -2
- package/src/config/secrets.cjs +19 -0
- package/src/config/settings.cjs +96 -2
- package/src/core/connection.js +140 -134
- package/src/core/events.js +64 -22
- package/src/core/proxy.js +266 -0
- package/src/core/session.js +828 -0
- package/src/core/socket.js +39 -5
- package/src/index.js +136 -29
- package/src/routes/dashboard.api.esm.js +127 -30
- package/src/services/aiAgent.cjs +278 -48
- package/src/services/aiTools.cjs +14 -12
- package/src/utils/storage.esm.js +5 -0
- package/views/dashboard.ejs +20 -11
- package/views/qr.ejs +23 -3
|
@@ -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
|
+
};
|