levix-bot 4.0.0 → 4.0.3

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-BBBXWzM0.js"></script>
14
- <link rel="stylesheet" crossorigin href="/dashboard/assets/index-pYaivHON.css">
13
+ <script type="module" crossorigin src="/dashboard/assets/index-Dn8ZyzsW.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/dashboard/assets/index-BnJbz8iW.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
  // ===========================================================================