Opera8 commited on
Commit
4a4e0ad
·
verified ·
1 Parent(s): 1434eda

Upload index.js

Browse files
Files changed (1) hide show
  1. .streamlit/index.js +643 -0
.streamlit/index.js ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // server/index.js (نسخه بهینه شده: انتخاب تصادفی کلیدها + پایداری بالا)
2
+ const express = require('express');
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const { WebSocketServer, WebSocket } = require('ws');
6
+ const http = require('node:http');
7
+ require('dotenv').config();
8
+
9
+ const app = express();
10
+ app.set('trust proxy', true);
11
+
12
+ // ==================== مدیریت محدودیت زمانی رایگان روزانه (سه لایه) ====================
13
+ // شناسایی هر کاربر با ترکیب سه لایه انجام می‌شود:
14
+ // لایه ۱: آی‌پی کاربر
15
+ // لایه ۲: اثر انگشت مرورگر/دستگاه (fingerprint) - از src/lib/fingerprint.ts
16
+ // لایه ۳: یک شناسه ذخیره‌شده در حافظه مرورگر کاربر (localStorage)
17
+ // اگر فقط یکی از این سه مورد با یکی از کاربران قبلی یکی باشد، همان پروفایل
18
+ // (و همان اعتبار باقیمانده) به کاربر نسبت داده می‌شود. یعنی:
19
+ // - پاک کردن حافظه مرورگر (لایه ۳) کمکی نمی‌کند، چون IP و fingerprint هنوز شناخته می‌شوند.
20
+ // - باز کردن در تب ناشناس/مرورگر دیگر روی همان دستگاه و همان اینترنت هم چون IP یکی
21
+ // می‌ماند و در بسیاری موارد fingerprint هم شبیه است، همان اعتبار قبلی نشان داده می‌شود.
22
+ // همه چیز روی سرور محاسبه و ذخیره می‌شود، مرورگر کاربر هیچ نقشی در تعیین اعتبار واقعی ندارد.
23
+
24
+ const DAILY_LIMIT_SECONDS = 300; // ۵ دقیقه
25
+
26
+ // مسیر ذخیره‌سازی دائمی (باکت/دیسک پایدار هاگینگ‌فیس معمولاً روی /data مونت می‌شود)
27
+ const PRIMARY_DATA_DIR = process.env.PERSIST_DATA_DIR || '/data';
28
+ const FALLBACK_DATA_DIR = __dirname;
29
+ const DB_FILE_NAME = 'sotifree.db';
30
+
31
+ function resolveWritableDataDir() {
32
+ try {
33
+ fs.mkdirSync(PRIMARY_DATA_DIR, { recursive: true });
34
+ const testFile = path.join(PRIMARY_DATA_DIR, '.write-test');
35
+ fs.writeFileSync(testFile, 'ok');
36
+ fs.unlinkSync(testFile);
37
+ console.log(`✅ مسیر ذخیره‌سازی دائمی فعال است: ${PRIMARY_DATA_DIR}`);
38
+ return PRIMARY_DATA_DIR;
39
+ } catch (e) {
40
+ console.warn(`⚠️ مسیر ${PRIMARY_DATA_DIR} در دسترس/قابل نوشتن نیست (${e.message})، استفاده از مسیر جایگزین: ${FALLBACK_DATA_DIR}`);
41
+ return FALLBACK_DATA_DIR;
42
+ }
43
+ }
44
+
45
+ const DATA_DIR = resolveWritableDataDir();
46
+ const USAGE_FILE = path.join(DATA_DIR, DB_FILE_NAME);
47
+
48
+ // ساختار داده در حافظه:
49
+ // profiles: { [profileId]: { date: 'YYYY-MM-DD', usedSeconds: number } }
50
+ // ipIndex / fpIndex / sidIndex: { [value]: profileId }
51
+ let db = {
52
+ profiles: {},
53
+ ipIndex: {},
54
+ fpIndex: {},
55
+ sidIndex: {},
56
+ };
57
+
58
+ function loadDb() {
59
+ try {
60
+ if (fs.existsSync(USAGE_FILE)) {
61
+ const raw = fs.readFileSync(USAGE_FILE, 'utf8');
62
+ const parsed = JSON.parse(raw);
63
+ if (parsed && typeof parsed === 'object') {
64
+ db = {
65
+ profiles: parsed.profiles || {},
66
+ ipIndex: parsed.ipIndex || {},
67
+ fpIndex: parsed.fpIndex || {},
68
+ sidIndex: parsed.sidIndex || {},
69
+ };
70
+ }
71
+ console.log(`✅ فایل اعتبار کاربران بارگذاری شد (${USAGE_FILE})`);
72
+ } else {
73
+ console.log(`ℹ️ فایل اعتبار کاربران وجود نداشت، یک فایل جدید ساخته می‌شود (${USAGE_FILE})`);
74
+ }
75
+ } catch (e) {
76
+ console.error('⚠️ خطا در بارگذاری فایل اعتبار کاربران، شروع از صفر:', e.message);
77
+ db = { profiles: {}, ipIndex: {}, fpIndex: {}, sidIndex: {} };
78
+ }
79
+ }
80
+
81
+ let dirty = false;
82
+ function markDirty() {
83
+ dirty = true;
84
+ }
85
+
86
+ function flushDbToDisk() {
87
+ if (!dirty) return;
88
+ try {
89
+ fs.writeFileSync(USAGE_FILE, JSON.stringify(db));
90
+ dirty = false;
91
+ } catch (e) {
92
+ console.error('⚠️ خطا در ذخیره فایل اعتبار کاربران:', e.message);
93
+ }
94
+ }
95
+
96
+ // طبق درخواست: چون هر بار فقط چند ثانیه از اعتبار کم می‌شود، نیازی نیست هر
97
+ // ثانیه روی دیسک نوشته شود. هر ۳۰ ثانیه یک‌بار روی دیسک ذخیره می‌شود.
98
+ const PERSIST_INTERVAL_MS = 30000;
99
+ setInterval(flushDbToDisk, PERSIST_INTERVAL_MS);
100
+
101
+ // ذخیره نهایی در زمان خاموش شدن سرور (تا حد امکان)
102
+ process.on('SIGTERM', () => { flushDbToDisk(); process.exit(0); });
103
+ process.on('SIGINT', () => { flushDbToDisk(); process.exit(0); });
104
+
105
+ loadDb();
106
+
107
+ function getTodayString() {
108
+ // تاریخ روز جاری بر مبنای UTC، تا در ری‌استارت سرور دچار اختلاف منطقه زمانی نشویم
109
+ return new Date().toISOString().slice(0, 10);
110
+ }
111
+
112
+ function getClientIp(req) {
113
+ const forwarded = req.headers['x-forwarded-for'];
114
+ if (forwarded) {
115
+ return String(forwarded).split(',')[0].trim();
116
+ }
117
+ return (req.socket && req.socket.remoteAddress) || 'unknown-ip';
118
+ }
119
+
120
+ function normalizeKeyPart(value, maxLen) {
121
+ if (value && typeof value === 'string' && value.length > 0 && value.length <= maxLen) {
122
+ return value;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function createNewProfileId() {
128
+ return 'p_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
129
+ }
130
+
131
+ function getUsedSecondsToday(profileId) {
132
+ const profile = db.profiles[profileId];
133
+ if (!profile) return 0;
134
+ const today = getTodayString();
135
+ if (profile.date !== today) return 0;
136
+ return profile.usedSeconds || 0;
137
+ }
138
+
139
+ // پیدا کردن یا ساختن پروفایل کاربر بر اساس سه لایه (IP، fingerprint، شناسه حافظه مرورگر)
140
+ // اگر هرکدام از این سه به یک پروفایل قبلی اشاره کنند، همان پروفایل (پروفایلی که
141
+ // بیشترین اعتبار مصرف‌شده امروز را دارد، برای جلوگیری از دور زدن محدودیت) انتخاب می‌شود
142
+ // و هر سه ایندکس به آن پروفایل واحد متصل می‌شوند.
143
+ function resolveProfileId(ip, fp, sid) {
144
+ const safeIp = normalizeKeyPart(ip, 100) || 'unknown-ip';
145
+ const safeFp = normalizeKeyPart(fp, 200);
146
+ const safeSid = normalizeKeyPart(sid, 200);
147
+
148
+ const candidateIds = new Set();
149
+ if (db.ipIndex[safeIp]) candidateIds.add(db.ipIndex[safeIp]);
150
+ if (safeFp && db.fpIndex[safeFp]) candidateIds.add(db.fpIndex[safeFp]);
151
+ if (safeSid && db.sidIndex[safeSid]) candidateIds.add(db.sidIndex[safeSid]);
152
+
153
+ let profileId;
154
+ if (candidateIds.size === 0) {
155
+ profileId = createNewProfileId();
156
+ db.profiles[profileId] = { date: getTodayString(), usedSeconds: 0, lastSeen: Date.now() };
157
+ } else {
158
+ // از بین همه پروفایل‌های مرتبط، پروفایلی که بیشترین مصرف امروز را دارد به عنوان
159
+ // مرجع انتخاب می‌شود (رویکرد محافظه‌کارانه برای جلوگیری از دور زدن محدودیت)
160
+ let maxUsed = -1;
161
+ for (const id of candidateIds) {
162
+ const used = getUsedSecondsToday(id);
163
+ if (used > maxUsed) {
164
+ maxUsed = used;
165
+ profileId = id;
166
+ }
167
+ }
168
+ if (!db.profiles[profileId] || db.profiles[profileId].date !== getTodayString()) {
169
+ db.profiles[profileId] = { date: getTodayString(), usedSeconds: maxUsed > 0 ? maxUsed : 0, lastSeen: Date.now() };
170
+ } else {
171
+ db.profiles[profileId].lastSeen = Date.now();
172
+ }
173
+ }
174
+
175
+ // اتصال هر سه لایه به همین پروفایل واحد
176
+ if (db.ipIndex[safeIp] !== profileId) { db.ipIndex[safeIp] = profileId; markDirty(); }
177
+ if (safeFp && db.fpIndex[safeFp] !== profileId) { db.fpIndex[safeFp] = profileId; markDirty(); }
178
+ if (safeSid && db.sidIndex[safeSid] !== profileId) { db.sidIndex[safeSid] = profileId; markDirty(); }
179
+
180
+ return profileId;
181
+ }
182
+
183
+ function getRemainingSeconds(ip, fp, sid) {
184
+ const profileId = resolveProfileId(ip, fp, sid);
185
+ const used = getUsedSecondsToday(profileId);
186
+ return Math.max(0, DAILY_LIMIT_SECONDS - used);
187
+ }
188
+
189
+ function addUsageSeconds(ip, fp, sid, seconds) {
190
+ const profileId = resolveProfileId(ip, fp, sid);
191
+ const today = getTodayString();
192
+ if (!db.profiles[profileId] || db.profiles[profileId].date !== today) {
193
+ db.profiles[profileId] = { date: today, usedSeconds: 0, lastSeen: Date.now() };
194
+ }
195
+ db.profiles[profileId].usedSeconds += seconds;
196
+ db.profiles[profileId].lastSeen = Date.now();
197
+ markDirty();
198
+ return Math.max(0, DAILY_LIMIT_SECONDS - db.profiles[profileId].usedSeconds);
199
+ }
200
+
201
+ // ---------- پاکسازی خودکار رکوردهای قدیمی‌تر از ۲۴ ساعت ----------
202
+ // چون محدودیت، «روزانه» است، هیچ نیازی نیست اطلاعات کاربری که بیش از ۲۴
203
+ // ساعت است سراغش نیامده در فایل sotifree.db نگه داشته شود. این تابع به‌طور
204
+ // دوره‌ای هر پروفایلی که در ۲۴ ساعت اخیر هیچ فعالیتی نداشته را، به همراه
205
+ // تمام ایندکس‌های (IP/fingerprint/sid) متصل به آن، کامل از فایل حذف می‌کند.
206
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
207
+
208
+ function cleanupOldData() {
209
+ const cutoff = Date.now() - TWENTY_FOUR_HOURS_MS;
210
+ const staleProfileIds = new Set();
211
+
212
+ for (const profileId of Object.keys(db.profiles)) {
213
+ const profile = db.profiles[profileId];
214
+ const lastSeen = typeof profile.lastSeen === 'number' ? profile.lastSeen : 0;
215
+ if (lastSeen < cutoff) {
216
+ staleProfileIds.add(profileId);
217
+ }
218
+ }
219
+
220
+ if (staleProfileIds.size === 0) return;
221
+
222
+ for (const profileId of staleProfileIds) {
223
+ delete db.profiles[profileId];
224
+ }
225
+ for (const key of Object.keys(db.ipIndex)) {
226
+ if (staleProfileIds.has(db.ipIndex[key])) delete db.ipIndex[key];
227
+ }
228
+ for (const key of Object.keys(db.fpIndex)) {
229
+ if (staleProfileIds.has(db.fpIndex[key])) delete db.fpIndex[key];
230
+ }
231
+ for (const key of Object.keys(db.sidIndex)) {
232
+ if (staleProfileIds.has(db.sidIndex[key])) delete db.sidIndex[key];
233
+ }
234
+
235
+ markDirty();
236
+ flushDbToDisk();
237
+ console.log(`🧹 پاکسازی خودکار: ${staleProfileIds.size} رکورد کاربر که بیش از ۲۴ ساعت بی‌استفاده بودند، از فایل ${USAGE_FILE} حذف شدند.`);
238
+ }
239
+
240
+ // یک‌بار در لحظه بالا آمدن سرور، و از آن به بعد هر یک ساعت یک‌بار اجرا می‌شود
241
+ cleanupOldData();
242
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // هر ۱ ساعت
243
+ setInterval(cleanupOldData, CLEANUP_INTERVAL_MS);
244
+ // ================================================================================
245
+
246
+ // افزایش محدودیت‌ها برای جلوگیری از خطای هدر در درخواست‌های سنگین
247
+ const server = http.createServer({
248
+ maxHeaderSize: 16384 // 16KB
249
+ }, app);
250
+
251
+ const wss = new WebSocketServer({
252
+ server,
253
+ clientTracking: true, // برای مدیریت هارت‌بیت و بستن اتصالات مرده
254
+ perMessageDeflate: false // غیرفعال کردن فشرده‌سازی برای کاهش بار CPU و حافظه
255
+ });
256
+
257
+ // --- بخش مدیریت تنظیمات شخصیت‌ها ---
258
+ const instructionSecretNames = {
259
+ default: 'PERSONALITY_DEFAULT',
260
+ teacher: 'PERSONALITY_TEACHER',
261
+ poetic: 'PERSONALITY_POETIC',
262
+ funny: 'PERSONALITY_FUNNY',
263
+ };
264
+
265
+ const personalityInstructions = {};
266
+ console.log('🔄 در حال خواندن دستورالعمل‌های شخصیت از Secrets...');
267
+ Object.keys(instructionSecretNames).forEach(key => {
268
+ const secretName = instructionSecretNames[key];
269
+ const instruction = process.env[secretName];
270
+ if (instruction) {
271
+ personalityInstructions[key] = instruction;
272
+ } else {
273
+ personalityInstructions[key] = `دستورالعمل '${key}' یافت نشد.`;
274
+ }
275
+ });
276
+
277
+ // --- بخش مدیریت کلیدهای API (Random Selection) ---
278
+ const apiKeysEnv = process.env.ALL_GEMINI_API_KEYS;
279
+ // تبدیل رشته کلیدها به آرایه و حذف فاصله‌های اضافی
280
+ const apiKeys = apiKeysEnv ? apiKeysEnv.split(',').map(key => key.trim()).filter(key => key) : [];
281
+
282
+ if (apiKeys.length === 0) {
283
+ console.error('🔴 خطای حیاتی: هیچ کلید API یافت نشد! لطفا متغیر ALL_GEMINI_API_KEYS را تنظیم کنید.');
284
+ process.exit(1);
285
+ }
286
+ console.log(`🚀 سرور با ${apiKeys.length} کلید API آماده‌سازی شد. (حالت انتخاب تصادفی)`);
287
+
288
+ // --- مکانیزم Heartbeat برای جلوگیری از قطعی و هنگ کردن سرور ---
289
+ // این تابع هر ۳۰ ثانیه اتصالات مرده را پاک می‌کند تا حافظه آزاد شود
290
+ function heartbeat() {
291
+ this.isAlive = true;
292
+ }
293
+
294
+ const interval = setInterval(function ping() {
295
+ wss.clients.forEach(function each(ws) {
296
+ if (ws.isAlive === false) {
297
+ // اگر کلاینت پاسخ نداد، اتصال را قطع کن تا حافظه آزاد شود
298
+ return ws.terminate();
299
+ }
300
+
301
+ ws.isAlive = false;
302
+ ws.ping();
303
+ });
304
+ }, 30000);
305
+
306
+ wss.on('close', function close() {
307
+ clearInterval(interval);
308
+ });
309
+
310
+ // --- توابع اتصال به گوگل ---
311
+
312
+ /**
313
+ * اتصال سوکت کلاینت و سوکت جمینای را به هم متصل می‌کند
314
+ */
315
+ function attachGeminiEventHandlers(clientWs, geminiWs, apiKeyUsed) {
316
+ // انتقال پیام از گوگل به کلاینت
317
+ geminiWs.on('message', (data) => {
318
+ if (clientWs.readyState === WebSocket.OPEN) {
319
+ clientWs.send(data, { binary: true });
320
+ }
321
+ });
322
+
323
+ geminiWs.on('error', (error) => {
324
+ console.error(`🔴 خطای جمینای (کلید ...${apiKeyUsed.slice(-4)}):`, error.message);
325
+ // در صورت خطا، سوکت کلاینت را نمی‌بندیم تا شاید بتواند دوباره تلاش کند،
326
+ // اما معمولا کلاینت خودش قطع می‌شود.
327
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.close();
328
+ });
329
+
330
+ geminiWs.on('close', (code) => {
331
+ if (clientWs.readyState === WebSocket.OPEN) clientWs.close();
332
+ });
333
+
334
+ geminiWs.on('ping', () => {
335
+ try { geminiWs.pong(); } catch (e) {}
336
+ });
337
+ }
338
+
339
+ /**
340
+ * تلاش برای اتصال به جمینای با انتخاب تصادفی و امتحان مجدد
341
+ * @param {WebSocket} clientWs سوکت کاربر
342
+ * @param {Object} setupData داده‌های اولیه (کانفیگ)
343
+ * @param {Number} startIndex ایندکس شروع (که به صورت تصادفی انتخاب شده)
344
+ * @param {Number} attemptCount شمارنده تلاش‌ها
345
+ */
346
+ async function tryConnectToGemini(clientWs, setupData, startIndex, attemptCount = 0) {
347
+ // اگر کلاینت در حین تلاش قطع شد، ادامه نده (جلوگیری از لیک حافظه)
348
+ if (clientWs.readyState !== WebSocket.OPEN) return null;
349
+
350
+ // اگر تمام کلیدها تست شدند و هیچکدام کار نکرد
351
+ if (attemptCount >= apiKeys.length) {
352
+ console.error(`⛔ تمام ${apiKeys.length} کلید API با شکست مواجه شدند.`);
353
+ if (clientWs.readyState === WebSocket.OPEN) {
354
+ clientWs.send(JSON.stringify({ error: "سرویس موقتاً در دسترس نیست (ظرفیت تکمیل)." }));
355
+ clientWs.close();
356
+ }
357
+ return null;
358
+ }
359
+
360
+ // منطق انتخاب کلید:
361
+ // بار اول: ایندکس کاملا تصادفی (startIndex)
362
+ // دفعات بعد (در صورت خطا): کلید بعدی در لیست به صورت چرخشی
363
+ const keyIndexToTry = (startIndex + attemptCount) % apiKeys.length;
364
+ const apiKey = apiKeys[keyIndexToTry];
365
+
366
+ const url = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=${apiKey}`;
367
+
368
+ return new Promise((resolve) => {
369
+ const geminiWs = new WebSocket(url);
370
+
371
+ // تایم‌اوت سخت: اگر گوگل تا ۸ ثانیه هیچ پاسخی نداد (هنگ کرد)، برو کلید بعدی
372
+ const timeout = setTimeout(() => {
373
+ console.warn(`⏳ تایم‌اوت اتصال (کلید ...${apiKey.slice(-4)}). رفتن به کلید بعدی...`);
374
+ geminiWs.terminate();
375
+ resolve(tryConnectToGemini(clientWs, setupData, startIndex, attemptCount + 1));
376
+ }, 8000);
377
+
378
+ geminiWs.on('open', () => {
379
+ clearTimeout(timeout);
380
+
381
+ if (clientWs.readyState !== WebSocket.OPEN) {
382
+ geminiWs.close();
383
+ return resolve(null);
384
+ }
385
+
386
+ console.log(`🔗 اتصال موفق با کلید شماره ${keyIndexToTry} (تلاش ${attemptCount + 1})`);
387
+
388
+ try {
389
+ geminiWs.send(JSON.stringify(setupData));
390
+ attachGeminiEventHandlers(clientWs, geminiWs, apiKey);
391
+ resolve(geminiWs);
392
+ } catch (e) {
393
+ console.error("خطا در ارسال تنظیمات اولیه:", e);
394
+ geminiWs.close();
395
+ resolve(tryConnectToGemini(clientWs, setupData, startIndex, attemptCount + 1));
396
+ }
397
+ });
398
+
399
+ geminiWs.on('error', (err) => {
400
+ clearTimeout(timeout);
401
+ console.warn(`⚠️ خطای اتصال (کلید ...${apiKey.slice(-4)}): ${err.message || 'Unknown'}. تلاش با کلید بعدی...`);
402
+ // فراخوانی بازگشتی برای تست کلید بعدی
403
+ resolve(tryConnectToGemini(clientWs, setupData, startIndex, attemptCount + 1));
404
+ });
405
+ });
406
+ }
407
+
408
+ // --- سرو کردن فایل‌های استاتیک ---
409
+ app.use(express.static(path.join(__dirname, '../build')));
410
+
411
+ // API Endpoint دستورالعمل‌ها
412
+ app.get('/api/instructions', (req, res) => {
413
+ res.setHeader('Cache-Control', 'no-store');
414
+ res.json(personalityInstructions);
415
+ });
416
+
417
+ // API Endpoint استعلام زمان باقیمانده رایگان روزانه (سه لایه: IP + fingerprint + شناسه حافظه مرورگر)
418
+ app.get('/api/usage', (req, res) => {
419
+ res.setHeader('Cache-Control', 'no-store');
420
+ const ip = getClientIp(req);
421
+ const fp = req.query.fp;
422
+ const sid = req.query.sid;
423
+ const remainingSeconds = getRemainingSeconds(ip, fp, sid);
424
+ res.json({ remainingSeconds, limitSeconds: DAILY_LIMIT_SECONDS });
425
+ });
426
+
427
+ // --- مدیریت WebSocket کلاینت ---
428
+ wss.on('connection', (ws, req) => {
429
+ ws.isAlive = true;
430
+ ws.on('pong', heartbeat);
431
+
432
+ // --- شناسایی کاربر بر اساس سه لایه: IP + اثر انگشت مرورگر + شناسه حافظه مرورگر ---
433
+ const ip = getClientIp(req);
434
+ let fp = null;
435
+ let sid = null;
436
+ try {
437
+ const parsedUrl = new URL(req.url, 'http://localhost');
438
+ fp = parsedUrl.searchParams.get('fp');
439
+ sid = parsedUrl.searchParams.get('sid');
440
+ } catch (e) {
441
+ fp = null;
442
+ sid = null;
443
+ }
444
+
445
+ // اگر همین الان هم زمان کاربر برای امروز تمام شده، اصلا اجازه اتصال نده
446
+ if (getRemainingSeconds(ip, fp, sid) <= 0) {
447
+ try {
448
+ ws.send(JSON.stringify({ type: 'limitreached' }));
449
+ } catch (e) {}
450
+ ws.close(4001, 'DAILY_LIMIT_REACHED');
451
+ return;
452
+ }
453
+
454
+ // *تغییر اصلی:* انتخاب یک نقطه شروع کاملاً تصادفی برای هر کاربر
455
+ // این کار باعث می‌شود بار روی سرور پخش شود و نیاز به حافظه برای نگهداری نوبت نباشد.
456
+ const randomStartIndex = Math.floor(Math.random() * apiKeys.length);
457
+
458
+ let geminiWs = null;
459
+ let isConnecting = false;
460
+ let usageIntervalRef = null;
461
+ let micActive = false; // آخرین وضعیتی که کلاینت اعلام کرده (مکالمه فعال است یا نه)
462
+ let lastActiveHeartbeatAt = 0; // زمان آخرین باری که کلاینت واقعا (با اجرای جاوااسکریپت) اعلام «هنوز فعالم» کرد
463
+ let lastRealtimeDataAt = 0; // زمان آخرین باری که داده صوتی/تصویری واقعی از کلاینت دریافت شد
464
+ let heartbeatWatchdogRef = null;
465
+
466
+ // حداکثر فاصله مجاز بین دو هارتبیت متوالی کلاینت. کلاینت هر ۵ ثانیه یک بار
467
+ // این سیگنال را می‌فرستد؛ اگر تا ۱۵ ثانیه (سه برابر فاصله عادی، برای
468
+ // اطمینان از نوسانات طبیعی شبکه) هیچ هارتبیت جدیدی نرسد، یعنی به احتمال
469
+ // زیاد صفحه/برنامه به پس‌زمینه رفته و جاوااسکریپتش متوقف شده. در این حالت
470
+ // شمارش زمان به‌طور خودکار متوقف می‌شود تا اعتبار کاربر در پس‌زمینه هدر نرود.
471
+ const HEARTBEAT_TIMEOUT_MS = 15000;
472
+
473
+ function stopUsageTracking() {
474
+ if (usageIntervalRef) {
475
+ clearInterval(usageIntervalRef);
476
+ usageIntervalRef = null;
477
+ }
478
+ }
479
+
480
+ function forceCloseForLimit() {
481
+ stopUsageTracking();
482
+ try {
483
+ if (ws.readyState === WebSocket.OPEN) {
484
+ ws.send(JSON.stringify({ type: 'limitreached' }));
485
+ ws.close(4001, 'DAILY_LIMIT_REACHED');
486
+ }
487
+ } catch (e) {}
488
+ if (geminiWs) {
489
+ try { geminiWs.terminate(); } catch (e) {}
490
+ geminiWs = null;
491
+ }
492
+ }
493
+
494
+ // این شمارنده هر ثانیه یک ثانیه از سهمیه واقعی کاربر روی سرور (در حافظه) کم می‌کند
495
+ // و مقدار باقیمانده را برای همگام‌سازی نمایش تایمر به کلاینت ارسال می‌کند.
496
+ // ذخیره‌سازی روی دیسک/باکت هر ۳۰ ثانیه یک‌بار انجام می‌شود (نه هر ثانیه)، اما شمارش
497
+ // و اعمال محدودیت همیشه لحظه‌به‌لحظه و سمت سرور است، پس قابل دور زدن نیست.
498
+ //
499
+ // *مهم - تضمین اتمی*: این شمارنده فقط زمانی واقعا اعتبار کم می‌کند که در
500
+ // ۱۰ ثانیه اخیر داده صوتی/تصویری واقعی از کلاینت دریافت شده باشد (یا در
501
+ // ۱۰ ثانیه اول بعد از شروع، به‌عنوان فرصت راه‌اندازی). این یعنی شمارش به
502
+ // «وجود واقعی و قابل‌اثبات جریان مکالمه» گره خورده، نه صرفا یک پرچم که
503
+ // ممکن است به هر دلیلی (باگ، پیام گم‌شده، تاخیر شبکه) اشتباه بماند. اگر
504
+ // داده صوتی واقعی قطع شود، شمارش خودش و بدون نیاز به هیچ پیام دیگری متوقف می‌شود.
505
+ const REALTIME_DATA_TIMEOUT_MS = 10000;
506
+
507
+ function startUsageTracking() {
508
+ if (usageIntervalRef) return;
509
+ const trackingStartedAt = Date.now();
510
+ usageIntervalRef = setInterval(() => {
511
+ const hasRecentRealtimeData = lastRealtimeDataAt > 0 && (Date.now() - lastRealtimeDataAt) < REALTIME_DATA_TIMEOUT_MS;
512
+ const withinStartupGrace = (Date.now() - trackingStartedAt) < REALTIME_DATA_TIMEOUT_MS;
513
+ if (!hasRecentRealtimeData && !withinStartupGrace) {
514
+ // مدتی است هیچ داده صوتی/تصویری واقعی‌ای دریافت نشده؛ یعنی عملا
515
+ // مکالمه‌ای در جریان نیست، پس شمارش را قطعا متوقف کن
516
+ micActive = false;
517
+ stopUsageTracking();
518
+ return;
519
+ }
520
+ const remaining = addUsageSeconds(ip, fp, sid, 1);
521
+ if (ws.readyState === WebSocket.OPEN) {
522
+ try {
523
+ ws.send(JSON.stringify({ type: 'usage', remainingSeconds: remaining, limitSeconds: DAILY_LIMIT_SECONDS }));
524
+ } catch (e) {}
525
+ }
526
+ if (remaining <= 0) {
527
+ forceCloseForLimit();
528
+ }
529
+ }, 1000);
530
+ }
531
+
532
+ // این تابع وضعیت واقعی شمارش زمان را با «آخرین سیگنالی که کلاینت فرستاده» و
533
+ // «وضعیت فعلی اتصال به Gemini» هماهنگ می‌کند. چون اتصال به Gemini کمی طول
534
+ // می‌کشد، ممکن است سیگنال «مکالمه فعال است» کمی زودتر از برقرار شدن اتصال
535
+ // برسد؛ این تابع تضمین می‌کند در آن حالت هم به‌محض آماده شدن اتصال، شمارش
536
+ // به‌درستی شروع شود.
537
+ function syncUsageTracking() {
538
+ if (micActive && geminiWs && getRemainingSeconds(ip, fp, sid) > 0) {
539
+ startUsageTracking();
540
+ } else {
541
+ stopUsageTracking();
542
+ }
543
+ }
544
+
545
+ // *نگهبان هارتبیت*: صرفِ باز بودن اتصال WebSocket (که با پینگ/پونگ خودکار
546
+ // مرورگر حتی در پس‌زمینه هم زنده نشان داده می‌شود) دلیل کافی برای «در حال
547
+ // مکالمه بودن» ن��ست. این نگهبان هر ۵ ثانیه بررسی می‌کند که آیا هارتبیت
548
+ // واقعی و تازه‌ای (که فقط با اجرای جاوااسکریپت صفحه ارسال می‌شود) از کلاینت
549
+ // رسیده یا نه؛ اگر نرسیده باشد، شمارش را متوقف می‌کند.
550
+ heartbeatWatchdogRef = setInterval(() => {
551
+ if (micActive && lastActiveHeartbeatAt > 0 && (Date.now() - lastActiveHeartbeatAt) > HEARTBEAT_TIMEOUT_MS) {
552
+ micActive = false;
553
+ stopUsageTracking();
554
+ }
555
+ }, 5000);
556
+
557
+ ws.on('message', async (message) => {
558
+ try {
559
+ // بررسی مجدد قبل از پردازش هر پیام، برای جلوگیری از سوءاستفاده در لحظه آخر
560
+ if (getRemainingSeconds(ip, fp, sid) <= 0) {
561
+ forceCloseForLimit();
562
+ return;
563
+ }
564
+
565
+ if (!Buffer.isBuffer(message) || message[0] === 123) {
566
+ const msgStr = message.toString();
567
+ if (msgStr.startsWith('{')) {
568
+ const data = JSON.parse(msgStr);
569
+ if (data.setup) {
570
+ if (isConnecting || geminiWs) return;
571
+ isConnecting = true;
572
+ // ارسال ایندکس تصادفی به تابع اتصال
573
+ geminiWs = await tryConnectToGemini(ws, data, randomStartIndex);
574
+ isConnecting = false;
575
+ if (geminiWs) {
576
+ // اگر کلاینت از قبل (حتی چند لحظه زودتر) اعلام کرده بود که
577
+ // مکالمه فعال است، همین الان که اتصال واقعا برقرار شد شمارش شروع می‌شود.
578
+ syncUsageTracking();
579
+ if (ws.readyState === WebSocket.OPEN) {
580
+ ws.send(JSON.stringify({ type: 'usage', remainingSeconds: getRemainingSeconds(ip, fp, sid), limitSeconds: DAILY_LIMIT_SECONDS }));
581
+ }
582
+ }
583
+ return;
584
+ }
585
+
586
+ if (data.type === 'usage_control') {
587
+ // کلاینت دقیقا اعلام می‌کند که مکالمه الان فعال است یا نه.
588
+ // شمارش زمان فقط و فقط بر اساس همین سیگنال شروع یا متوقف می‌شود،
589
+ // نه صرفا باز بودن اتصال. به همین دلیل با قطع مکالمه (حتی اگر
590
+ // اتصال WebSocket برای اتصال سریع‌تر دفعه بعد باز بماند)، دیگر
591
+ // چیزی از اعتبار کاربر کم نمی‌شود.
592
+ micActive = data.active === true;
593
+ if (micActive) {
594
+ lastActiveHeartbeatAt = Date.now();
595
+ }
596
+ syncUsageTracking();
597
+ if (micActive && ws.readyState === WebSocket.OPEN) {
598
+ ws.send(JSON.stringify({ type: 'usage', remainingSeconds: getRemainingSeconds(ip, fp, sid), limitSeconds: DAILY_LIMIT_SECONDS }));
599
+ }
600
+ return;
601
+ }
602
+ }
603
+ }
604
+
605
+ if (geminiWs && geminiWs.readyState === WebSocket.OPEN) {
606
+ // این دقیقا لحظه‌ای است که داده صوتی/تصویری واقعی از کلاینت به سمت
607
+ // Gemini فوروارد می‌شود؛ یعنی مکالمه واقعا در جریان است.
608
+ lastRealtimeDataAt = Date.now();
609
+ geminiWs.send(message);
610
+ }
611
+ } catch (e) {
612
+ console.error("خطا در پردازش پیام کلاینت:", e.message);
613
+ }
614
+ });
615
+
616
+ ws.on('close', () => {
617
+ stopUsageTracking();
618
+ if (heartbeatWatchdogRef) { clearInterval(heartbeatWatchdogRef); heartbeatWatchdogRef = null; }
619
+ flushDbToDisk(); // ذخیره فوری روی دیسک/باکت در پایان هر تماس، برای اطمینان بیشتر
620
+ if (geminiWs) {
621
+ // بستن اجباری اتصال گوگل برای آزادسازی منابع
622
+ try {
623
+ geminiWs.terminate();
624
+ } catch(e) {}
625
+ geminiWs = null;
626
+ }
627
+ });
628
+
629
+ ws.on('error', (error) => {
630
+ stopUsageTracking();
631
+ if (heartbeatWatchdogRef) { clearInterval(heartbeatWatchdogRef); heartbeatWatchdogRef = null; }
632
+ if (geminiWs) {
633
+ try { geminiWs.terminate(); } catch(e) {}
634
+ geminiWs = null;
635
+ }
636
+ });
637
+ });
638
+
639
+ // ارسال تمام درخواست‌های دیگر به React
640
+ app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../build', 'index.html')));
641
+
642
+ const PORT = process.env.PORT || 3001;
643
+ server.listen(PORT, () => console.log(`🚀 سرور (حالت تصادفی) روی پورت ${PORT} اجرا شد.`));