levix-bot 4.0.0-beta → 4.0.2

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Tajawal:wght@400;500;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/dashboard/assets/index-C1b7ijfu.js"></script>
14
- <link rel="stylesheet" crossorigin href="/dashboard/assets/index-pYaivHON.css">
13
+ <script type="module" crossorigin src="/dashboard/assets/index-CTOEAQIt.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/dashboard/assets/index-BKU7nW6g.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,230 @@
1
+ // The operator's line to the developer.
2
+ //
3
+ // Google Play expects an app to offer a working way to reach its developer, and
4
+ // a self-hosted bot has nowhere local to put one: the panel runs on the phone
5
+ // that reports the problem. So the panel posts to /dashboard/api/feedback, and
6
+ // this file forwards it — from the server, not the browser — to
7
+ // POST /api/feedback on levix.leviro.net, which delivers it as a Telegram
8
+ // message to the developer (levix-ui: src/app/api/feedback/route.ts).
9
+ //
10
+ // Forwarding server-side is the point: the panel's page never talks to the
11
+ // website, so no cross-origin request is made from a browser holding a panel
12
+ // session, and the request carries the bot's own version and platform rather
13
+ // than whatever a page claims.
14
+ //
15
+ // Nothing is sent unless the operator presses Send, and nothing about WhatsApp
16
+ // — no message, chat, contact, credential or key — is ever attached. What goes
17
+ // out is exactly the four fields below plus the runtime facts in
18
+ // describeRuntime().
19
+
20
+ const fs = require("node:fs");
21
+ const logger = require("../utils/logger.cjs");
22
+ const settings = require("../config/settings.cjs");
23
+
24
+ const DEFAULT_ENDPOINT = "https://levix.leviro.net/api/feedback";
25
+
26
+ /** Matches the topics the website's own form offers. */
27
+ const TOPICS = Object.freeze(["bug", "idea", "question", "praise", "other"]);
28
+
29
+ const MESSAGE_MIN = 10;
30
+ const MESSAGE_MAX = 2000;
31
+ const CONTACT_MAX = 200;
32
+
33
+ // One operator, pressing a button. This only exists so a stuck retry loop in a
34
+ // browser tab cannot turn into a flood the receiving end has to absorb.
35
+ const THROTTLE_MAX = 5;
36
+ const THROTTLE_WINDOW_MS = 60 * 60 * 1000;
37
+ let attempts = [];
38
+
39
+ /** Seconds to wait, or 0 when a submission is allowed right now. */
40
+ function feedbackRetryAfter() {
41
+ const now = Date.now();
42
+ attempts = attempts.filter((at) => now - at < THROTTLE_WINDOW_MS);
43
+ if (attempts.length < THROTTLE_MAX) return 0;
44
+ return Math.max(1, Math.ceil((attempts[0] + THROTTLE_WINDOW_MS - now) / 1000));
45
+ }
46
+
47
+ function recordAttempt() {
48
+ attempts.push(Date.now());
49
+ }
50
+
51
+ /**
52
+ * Control characters would mangle the message on the way out; tabs and line
53
+ * breaks are the exception, because a paragraph the operator typed is content.
54
+ * Written as a scan rather than a regex — a control character inside a
55
+ * character class is exactly what the linter rejects, with good reason.
56
+ */
57
+ function stripControl(value) {
58
+ let out = "";
59
+ for (const char of value) {
60
+ const code = char.codePointAt(0);
61
+ const control = code < 0x20 || code === 0x7f;
62
+ if (!control || char === "\n" || char === "\r" || char === "\t") out += char;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function clean(value, max) {
68
+ return stripControl(String(value ?? ""))
69
+ .trim()
70
+ .slice(0, max);
71
+ }
72
+
73
+ /**
74
+ * Check what the panel sent.
75
+ *
76
+ * @param {object} body
77
+ * @returns {{ ok: true, value: object } | { ok: false, error: string }}
78
+ */
79
+ function validateFeedback(body) {
80
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
81
+ return { ok: false, error: "Expected a feedback object" };
82
+ }
83
+
84
+ const message = clean(body.message, MESSAGE_MAX);
85
+ if (message.length < MESSAGE_MIN) {
86
+ return { ok: false, error: `Write at least ${MESSAGE_MIN} characters` };
87
+ }
88
+
89
+ const topicRaw = clean(body.topic, 20);
90
+ const topic = TOPICS.includes(topicRaw) ? topicRaw : "other";
91
+
92
+ let rating = null;
93
+ if (body.rating !== undefined && body.rating !== null && body.rating !== "") {
94
+ const value = Number(body.rating);
95
+ if (!Number.isInteger(value) || value < 1 || value > 5) {
96
+ return { ok: false, error: "Rating must be a whole number from 1 to 5" };
97
+ }
98
+ rating = value;
99
+ }
100
+
101
+ const contact = clean(body.contact, CONTACT_MAX) || null;
102
+
103
+ return { ok: true, value: { message, topic, rating, contact } };
104
+ }
105
+
106
+ /** Where this copy of Levix is running — the part a bug report cannot do without. */
107
+ function describeRuntime() {
108
+ let platform = `node-${process.platform}`;
109
+ if (process.env.LEVIX_ANDROID === "1") platform = "android";
110
+ else if (isDocker()) platform = "docker";
111
+ else if (isPackaged()) platform = `sea-${process.platform}`;
112
+
113
+ let version = "unknown";
114
+ try {
115
+ version = require("../../package.json").version || "unknown";
116
+ } catch {
117
+ // A packaged build without a readable manifest still sends the rest.
118
+ }
119
+
120
+ let locale = null;
121
+ try {
122
+ locale = settings.get("bot_language") || null;
123
+ } catch {
124
+ // The database may not be open yet; the field is optional.
125
+ }
126
+
127
+ return { version, platform, locale };
128
+ }
129
+
130
+ function isDocker() {
131
+ try {
132
+ return fs.existsSync("/.dockerenv");
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function isPackaged() {
139
+ try {
140
+ return require("node:sea").isSea() === true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /** The receiving end's own wording is fine to show; its length is not. */
147
+ function safeRemoteError(value) {
148
+ const text = clean(value, 200);
149
+ return text || null;
150
+ }
151
+
152
+ /**
153
+ * Forward one validated submission.
154
+ *
155
+ * @param {{ message: string, topic: string, rating: number|null, contact: string|null }} feedback
156
+ * @returns {Promise<{ ok: true } | { ok: false, status: number, error: string }>}
157
+ */
158
+ async function submitFeedback(feedback) {
159
+ const endpoint = process.env.LEVIX_FEEDBACK_URL || DEFAULT_ENDPOINT;
160
+ const runtime = describeRuntime();
161
+
162
+ recordAttempt();
163
+
164
+ let response;
165
+ let data = null;
166
+ try {
167
+ response = await fetch(endpoint, {
168
+ method: "POST",
169
+ headers: {
170
+ "Content-Type": "application/json",
171
+ "User-Agent": `Levix/${runtime.version} (${runtime.platform})`,
172
+ },
173
+ body: JSON.stringify({
174
+ ...feedback,
175
+ source: "panel",
176
+ app: { version: runtime.version, platform: runtime.platform, locale: runtime.locale },
177
+ }),
178
+ signal: AbortSignal.timeout(15000),
179
+ });
180
+ data = await response.json().catch(() => null);
181
+ } catch (error) {
182
+ logger.warn({ err: error }, "[feedback] Could not reach the feedback endpoint");
183
+ return {
184
+ ok: false,
185
+ status: 502,
186
+ error: "Could not reach the Levix server. Check the bot's internet connection and try again.",
187
+ };
188
+ }
189
+
190
+ if (response.ok && data?.ok) {
191
+ logger.info(`[feedback] Sent (${feedback.topic}) from ${runtime.platform}`);
192
+ return { ok: true };
193
+ }
194
+
195
+ const remote = safeRemoteError(data?.error);
196
+ logger.warn(
197
+ `[feedback] The endpoint refused the submission (HTTP ${response.status})${remote ? `: ${remote}` : ""}`,
198
+ );
199
+
200
+ if (response.status === 429) {
201
+ return {
202
+ ok: false,
203
+ status: 429,
204
+ error: remote || "Too many submissions for now. Try again in a little while.",
205
+ };
206
+ }
207
+ if (response.status === 503) {
208
+ return {
209
+ ok: false,
210
+ status: 503,
211
+ error: "Feedback delivery is not switched on yet. Please use GitHub issues for now.",
212
+ };
213
+ }
214
+ return {
215
+ ok: false,
216
+ status: 502,
217
+ error: remote || "The feedback service refused the message. Please try again later.",
218
+ };
219
+ }
220
+
221
+ module.exports = {
222
+ TOPICS,
223
+ MESSAGE_MIN,
224
+ MESSAGE_MAX,
225
+ CONTACT_MAX,
226
+ validateFeedback,
227
+ describeRuntime,
228
+ feedbackRetryAfter,
229
+ submitFeedback,
230
+ };
@@ -48,6 +48,15 @@ const memory = require("../utils/memory.cjs");
48
48
  const secrets = require("../config/secrets.cjs");
49
49
  const { clientAddress } = require("../utils/requestOrigin.cjs");
50
50
  const { blockedFor, recordFailure, clearAttempts } = require("../panel/login-throttle.cjs");
51
+ const {
52
+ TOPICS: FEEDBACK_TOPICS,
53
+ MESSAGE_MAX: FEEDBACK_MESSAGE_MAX,
54
+ MESSAGE_MIN: FEEDBACK_MESSAGE_MIN,
55
+ describeRuntime,
56
+ feedbackRetryAfter,
57
+ submitFeedback,
58
+ validateFeedback,
59
+ } = require("../panel/feedback.cjs");
51
60
  const { stampPanelSession } = require("../panel/session-auth.cjs");
52
61
  const { DATA_DIR } = require("../config/paths.cjs");
53
62
  const { PERSONA_FILE, activeProviderKeySetting } = require("../services/aiAgent.cjs");
@@ -1104,6 +1113,49 @@ router.post("/security/password", (req, res) => {
1104
1113
  });
1105
1114
  });
1106
1115
 
1116
+ // ===========================================================================
1117
+ // Feedback — the operator's line to the developer
1118
+ // ===========================================================================
1119
+
1120
+ // What the form needs to render itself, so the topics and the limits are
1121
+ // defined in one place (src/panel/feedback.cjs) instead of twice.
1122
+ router.get("/feedback/meta", (req, res) => {
1123
+ const runtime = describeRuntime();
1124
+ res.json({
1125
+ success: true,
1126
+ topics: FEEDBACK_TOPICS,
1127
+ messageMin: FEEDBACK_MESSAGE_MIN,
1128
+ messageMax: FEEDBACK_MESSAGE_MAX,
1129
+ // Shown next to the form so the operator can see what travels with it.
1130
+ runtime: { version: runtime.version, platform: runtime.platform },
1131
+ });
1132
+ });
1133
+
1134
+ // Leaves this machine only when the operator presses Send, and carries nothing
1135
+ // but the form's own fields plus the version and platform — see feedback.cjs.
1136
+ router.post(
1137
+ "/feedback",
1138
+ asyncRoute(async (req, res) => {
1139
+ const parsed = validateFeedback(req.body || {});
1140
+ if (!parsed.ok) return badRequest(res, parsed.error);
1141
+
1142
+ const retryAfter = feedbackRetryAfter();
1143
+ if (retryAfter) {
1144
+ res.set("Retry-After", String(retryAfter));
1145
+ return res.status(429).json({
1146
+ success: false,
1147
+ error: "A few have gone out already. Try again in a little while.",
1148
+ });
1149
+ }
1150
+
1151
+ const result = await submitFeedback(parsed.value);
1152
+ if (!result.ok) {
1153
+ return res.status(result.status).json({ success: false, error: result.error });
1154
+ }
1155
+ res.json({ success: true });
1156
+ }),
1157
+ );
1158
+
1107
1159
  // ===========================================================================
1108
1160
  // Bot control
1109
1161
  // ===========================================================================
@@ -13,7 +13,6 @@
13
13
  // isn't allowed to do it.
14
14
 
15
15
  const dns = require("node:dns").promises;
16
- const http = require("node:http");
17
16
  const https = require("node:https");
18
17
  const net = require("node:net");
19
18
 
@@ -237,7 +236,7 @@ async function assertPublicUrl(rawUrl) {
237
236
  } catch {
238
237
  throw new Error("الرابط مش صالح");
239
238
  }
240
- if (!/^https?:$/.test(url.protocol)) throw new Error("مسموح بـ http/https بس");
239
+ if (url.protocol !== "https:") throw new Error("مسموح بروابط HTTPS بس");
241
240
 
242
241
  const host = url.hostname.replace(/^\[|\]$/g, "");
243
242
 
@@ -259,7 +258,7 @@ async function assertPublicUrl(rawUrl) {
259
258
  return { url, host, addresses };
260
259
  }
261
260
 
262
- function pinnedAgent(protocol, expectedHost, addresses) {
261
+ function pinnedAgent(expectedHost, addresses) {
263
262
  const lookup = (hostname, options, callback) => {
264
263
  if (String(hostname).toLowerCase() !== expectedHost.toLowerCase()) {
265
264
  const error = new Error("اتصال بدومين غير متوقع");
@@ -282,8 +281,7 @@ function pinnedAgent(protocol, expectedHost, addresses) {
282
281
  return callback(null, candidates[0].address, candidates[0].family);
283
282
  };
284
283
 
285
- const Agent = protocol === "https:" ? https.Agent : http.Agent;
286
- return new Agent({ keepAlive: false, lookup });
284
+ return new https.Agent({ keepAlive: false, lookup });
287
285
  }
288
286
 
289
287
  const MAX_REDIRECTS = 4;
@@ -294,7 +292,7 @@ async function fetchUrl(rawUrl) {
294
292
 
295
293
  for (let hop = 0; ; hop++) {
296
294
  const { url, host, addresses } = target;
297
- const agent = pinnedAgent(url.protocol, host, addresses);
295
+ const agent = pinnedAgent(host, addresses);
298
296
  try {
299
297
  response = await axios.get(url.toString(), {
300
298
  responseType: "arraybuffer",
@@ -305,8 +303,7 @@ async function fetchUrl(rawUrl) {
305
303
  // Ignore process-wide proxy variables: they would perform their own
306
304
  // hostname resolution and bypass the pinned lookup above.
307
305
  proxy: false,
308
- httpAgent: url.protocol === "http:" ? agent : undefined,
309
- httpsAgent: url.protocol === "https:" ? agent : undefined,
306
+ httpsAgent: agent,
310
307
  headers: {
311
308
  "User-Agent": UA,
312
309
  "Accept-Language": "ar,en;q=0.9",
@@ -492,11 +489,11 @@ const TOOLS = {
492
489
  declaration: {
493
490
  name: "fetch_url",
494
491
  description:
495
- "Open a URL and read its text content (article, API response, page the user sent). Use after web_search when a snippet is not enough.",
492
+ "Open an HTTPS URL and read its text content (article, API response, page the user sent). Use after web_search when a snippet is not enough.",
496
493
  parameters: {
497
494
  type: T.OBJECT,
498
495
  properties: {
499
- url: { type: T.STRING, description: "Full http(s) URL." },
496
+ url: { type: T.STRING, description: "Full HTTPS URL." },
500
497
  },
501
498
  required: ["url"],
502
499
  },