universal-chatbot-saas 1.0.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,2331 @@
1
+ const express = require("express");
2
+ const cors = require("cors");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const dotenv = require("dotenv");
6
+ const sql = require("mssql");
7
+ const crypto = require("crypto");
8
+
9
+ dotenv.config();
10
+
11
+ const app = express();
12
+ const port = Number(process.env.PORT || 8787);
13
+ const defaultSystemPrompt = process.env.DEFAULT_SYSTEM_PROMPT || "You are a helpful assistant.";
14
+ const defaultProvider = (process.env.DEFAULT_PROVIDER || "claude").toLowerCase();
15
+ const enforceOriginLock = String(process.env.ENFORCE_ORIGIN_LOCK || "true").toLowerCase() === "true";
16
+ const rateLimitWindowMs = Math.max(Number(process.env.RATE_LIMIT_WINDOW_MS || 60000), 1000);
17
+ const rateLimitMaxRequests = Math.max(Number(process.env.RATE_LIMIT_MAX_REQUESTS || 20), 1);
18
+ const knowledgeSnippetLimit = Math.max(Number(process.env.KNOWLEDGE_SNIPPET_LIMIT || 4), 1);
19
+ const knowledgeContextCharLimit = Math.max(Number(process.env.KNOWLEDGE_CONTEXT_CHAR_LIMIT || 2400), 400);
20
+ const groqGuardModel = String(process.env.GROQ_GUARD_MODEL || "meta-llama/llama-prompt-guard-2-86m").trim();
21
+ const enableGroqPromptGuard = String(process.env.ENABLE_GROQ_PROMPT_GUARD || "true").toLowerCase() === "true";
22
+ const adminPortalUsername = String(process.env.ADMIN_PORTAL_USERNAME || "admin").trim() || "admin";
23
+ const adminPortalPassword = String(process.env.ADMIN_PORTAL_PASSWORD || "").trim();
24
+ const portalSessionTtlMs = Math.max(Number(process.env.PORTAL_SESSION_TTL_MS || 8 * 60 * 60 * 1000), 5 * 60 * 1000);
25
+ const enableChatHistoryStorage = String(process.env.ENABLE_CHAT_HISTORY_STORAGE || "true").toLowerCase() !== "false";
26
+ const mssqlEnabled = String(process.env.MSSQL_ENABLED || "false").toLowerCase() === "true";
27
+ const mssqlInstanceName = String(process.env.MSSQL_INSTANCE || "").trim();
28
+ const mssqlPortValue = String(process.env.MSSQL_PORT || "").trim();
29
+ const mssqlConfig = {
30
+ server: String(process.env.MSSQL_SERVER || "").trim(),
31
+ ...(mssqlPortValue ? { port: Number(mssqlPortValue) } : {}),
32
+ user: String(process.env.MSSQL_USER || "").trim(),
33
+ password: String(process.env.MSSQL_PASSWORD || "").trim(),
34
+ database: String(process.env.MSSQL_DATABASE || "").trim(),
35
+ options: {
36
+ encrypt: String(process.env.MSSQL_ENCRYPT || "true").toLowerCase() === "true",
37
+ trustServerCertificate: String(process.env.MSSQL_TRUST_SERVER_CERT || "true").toLowerCase() === "true",
38
+ ...(mssqlInstanceName ? { instanceName: mssqlInstanceName } : {})
39
+ },
40
+ pool: {
41
+ max: Math.max(Number(process.env.MSSQL_POOL_MAX || 10), 1),
42
+ min: 0,
43
+ idleTimeoutMillis: 30000
44
+ }
45
+ };
46
+
47
+ let mssqlPoolPromise = null;
48
+ let mssqlReadyPromise = null;
49
+ const portalSessions = new Map();
50
+
51
+ function hashPassword(password) {
52
+ return crypto.createHash("sha256").update(String(password || "")).digest("hex");
53
+ }
54
+
55
+ function parseCookies(req) {
56
+ const raw = String(req.headers?.cookie || "");
57
+ const out = {};
58
+ raw.split(";").forEach((part) => {
59
+ const index = part.indexOf("=");
60
+ if (index <= 0) {
61
+ return;
62
+ }
63
+ const key = part.slice(0, index).trim();
64
+ const value = part.slice(index + 1).trim();
65
+ if (!key) {
66
+ return;
67
+ }
68
+ out[key] = decodeURIComponent(value);
69
+ });
70
+ return out;
71
+ }
72
+
73
+ function createPortalSession(user) {
74
+ const token = crypto.randomBytes(32).toString("hex");
75
+ portalSessions.set(token, {
76
+ userId: user.id,
77
+ username: user.username,
78
+ expiresAt: Date.now() + portalSessionTtlMs
79
+ });
80
+ return token;
81
+ }
82
+
83
+ function getPortalSession(req) {
84
+ const cookies = parseCookies(req);
85
+ const token = String(cookies.portal_auth || "").trim();
86
+ if (!token) {
87
+ return null;
88
+ }
89
+ const session = portalSessions.get(token);
90
+ if (!session) {
91
+ return null;
92
+ }
93
+ if (session.expiresAt <= Date.now()) {
94
+ portalSessions.delete(token);
95
+ return null;
96
+ }
97
+ return { token, ...session };
98
+ }
99
+
100
+ function clearPortalSession(res, token) {
101
+ if (token) {
102
+ portalSessions.delete(token);
103
+ }
104
+ res.setHeader("Set-Cookie", "portal_auth=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0");
105
+ }
106
+
107
+ async function ensurePortalUserTables() {
108
+ if (!isMssqlConfigured()) {
109
+ return;
110
+ }
111
+ await ensureMssqlTables();
112
+ const pool = await getMssqlPool();
113
+ if (!pool) {
114
+ return;
115
+ }
116
+
117
+ await pool.request().query(`
118
+ IF OBJECT_ID('dbo.tblUsers', 'U') IS NULL
119
+ BEGIN
120
+ CREATE TABLE dbo.tblUsers (
121
+ id INT IDENTITY(1,1) PRIMARY KEY,
122
+ username NVARCHAR(120) NOT NULL UNIQUE,
123
+ password_hash NVARCHAR(255) NOT NULL,
124
+ is_active BIT NOT NULL CONSTRAINT DF_tblUsers_is_active DEFAULT(1),
125
+ created_at DATETIME2 NOT NULL CONSTRAINT DF_tblUsers_created_at DEFAULT(SYSUTCDATETIME()),
126
+ updated_at DATETIME2 NOT NULL CONSTRAINT DF_tblUsers_updated_at DEFAULT(SYSUTCDATETIME())
127
+ );
128
+ END
129
+
130
+ IF OBJECT_ID('dbo.tblBotUsers', 'U') IS NULL
131
+ BEGIN
132
+ CREATE TABLE dbo.tblBotUsers (
133
+ bot_id NVARCHAR(120) NOT NULL,
134
+ user_id INT NOT NULL,
135
+ created_at DATETIME2 NOT NULL CONSTRAINT DF_tblBotUsers_created_at DEFAULT(SYSUTCDATETIME()),
136
+ CONSTRAINT PK_tblBotUsers PRIMARY KEY (bot_id, user_id),
137
+ CONSTRAINT FK_tblBotUsers_bot FOREIGN KEY (bot_id) REFERENCES dbo.tblBots(id) ON DELETE CASCADE,
138
+ CONSTRAINT FK_tblBotUsers_user FOREIGN KEY (user_id) REFERENCES dbo.tblUsers(id) ON DELETE CASCADE
139
+ );
140
+ CREATE INDEX IX_tblBotUsers_user_id ON dbo.tblBotUsers(user_id);
141
+ END
142
+ `);
143
+
144
+ if (adminPortalUsername && adminPortalPassword) {
145
+ const passwordHash = hashPassword(adminPortalPassword);
146
+ await pool.request()
147
+ .input("username", sql.NVarChar(120), adminPortalUsername)
148
+ .input("passwordHash", sql.NVarChar(255), passwordHash)
149
+ .query(`
150
+ MERGE dbo.tblUsers AS target
151
+ USING (SELECT @username AS username) AS source
152
+ ON target.username = source.username
153
+ WHEN MATCHED THEN
154
+ UPDATE SET password_hash = @passwordHash,
155
+ is_active = 1,
156
+ updated_at = SYSUTCDATETIME()
157
+ WHEN NOT MATCHED THEN
158
+ INSERT (username, password_hash, is_active, created_at, updated_at)
159
+ VALUES (@username, @passwordHash, 1, SYSUTCDATETIME(), SYSUTCDATETIME());
160
+ `);
161
+
162
+ await pool.request()
163
+ .input("username", sql.NVarChar(120), adminPortalUsername)
164
+ .query(`
165
+ INSERT INTO dbo.tblBotUsers (bot_id, user_id)
166
+ SELECT b.id, u.id
167
+ FROM dbo.tblBots b
168
+ CROSS JOIN dbo.tblUsers u
169
+ WHERE u.username = @username
170
+ AND NOT EXISTS (
171
+ SELECT 1
172
+ FROM dbo.tblBotUsers bu
173
+ WHERE bu.bot_id = b.id AND bu.user_id = u.id
174
+ );
175
+ `);
176
+ }
177
+ }
178
+
179
+ async function findPortalUserByCredentials(username, password) {
180
+ if (!isMssqlConfigured()) {
181
+ return null;
182
+ }
183
+ await ensurePortalUserTables();
184
+ const pool = await getMssqlPool();
185
+ if (!pool) {
186
+ return null;
187
+ }
188
+ const result = await pool.request()
189
+ .input("username", sql.NVarChar(120), String(username || "").trim())
190
+ .input("passwordHash", sql.NVarChar(255), hashPassword(password))
191
+ .query(`
192
+ SELECT TOP 1 id, username, is_active
193
+ FROM dbo.tblUsers
194
+ WHERE username = @username
195
+ AND password_hash = @passwordHash
196
+ `);
197
+ const user = result.recordset?.[0];
198
+ if (!user || !(Number(user.is_active) === 1 || user.is_active === true)) {
199
+ return null;
200
+ }
201
+ return {
202
+ id: Number(user.id),
203
+ username: String(user.username || "").trim()
204
+ };
205
+ }
206
+
207
+ async function canPortalUserAccessBot(userId, botId) {
208
+ if (!isMssqlConfigured()) {
209
+ return false;
210
+ }
211
+ await ensurePortalUserTables();
212
+ const pool = await getMssqlPool();
213
+ if (!pool) {
214
+ return false;
215
+ }
216
+ const result = await pool.request()
217
+ .input("userId", sql.Int, Number(userId))
218
+ .input("botId", sql.NVarChar(120), String(botId || "").trim())
219
+ .query(`
220
+ SELECT TOP 1 1 AS ok
221
+ FROM dbo.tblBotUsers bu
222
+ INNER JOIN dbo.tblBots b ON b.id = bu.bot_id
223
+ WHERE bu.user_id = @userId
224
+ AND bu.bot_id = @botId
225
+ AND b.is_active = 1
226
+ `);
227
+ return Boolean(result.recordset?.length);
228
+ }
229
+
230
+ function safeParseJson(value, fallback) {
231
+ if (!value) {
232
+ return fallback;
233
+ }
234
+ try {
235
+ return JSON.parse(value);
236
+ } catch {
237
+ return fallback;
238
+ }
239
+ }
240
+
241
+ function resolveFilePath(filePath) {
242
+ if (!filePath) {
243
+ return "";
244
+ }
245
+
246
+ if (path.isAbsolute(filePath)) {
247
+ return filePath;
248
+ }
249
+
250
+ return path.resolve(process.cwd(), filePath);
251
+ }
252
+
253
+ function readJsonFile(filePath, fallback) {
254
+ const resolvedPath = resolveFilePath(filePath);
255
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
256
+ return fallback;
257
+ }
258
+
259
+ try {
260
+ const raw = fs.readFileSync(resolvedPath, "utf8");
261
+ return JSON.parse(raw);
262
+ } catch {
263
+ return fallback;
264
+ }
265
+ }
266
+
267
+ function isMssqlConfigured() {
268
+ return Boolean(mssqlEnabled && mssqlConfig.server && mssqlConfig.user && mssqlConfig.database);
269
+ }
270
+
271
+ async function getMssqlPool() {
272
+ if (!isMssqlConfigured()) {
273
+ return null;
274
+ }
275
+
276
+ if (!mssqlPoolPromise) {
277
+ mssqlPoolPromise = new sql.ConnectionPool(mssqlConfig)
278
+ .connect()
279
+ .catch((error) => {
280
+ mssqlPoolPromise = null;
281
+ throw error;
282
+ });
283
+ }
284
+
285
+ return mssqlPoolPromise;
286
+ }
287
+
288
+ async function ensureMssqlTables() {
289
+ if (!isMssqlConfigured()) {
290
+ return false;
291
+ }
292
+
293
+ if (!mssqlReadyPromise) {
294
+ mssqlReadyPromise = (async () => {
295
+ const pool = await getMssqlPool();
296
+ if (!pool) {
297
+ return false;
298
+ }
299
+
300
+ // Migration: Drop old table names if they exist
301
+ try {
302
+ await pool.request().query(`
303
+ IF OBJECT_ID('dbo.chatbot_knowledge', 'U') IS NOT NULL
304
+ BEGIN
305
+ DROP TABLE dbo.chatbot_knowledge;
306
+ END
307
+
308
+ IF OBJECT_ID('dbo.chatbot_bots', 'U') IS NOT NULL
309
+ BEGIN
310
+ DROP TABLE dbo.chatbot_bots;
311
+ END
312
+ `);
313
+ } catch (err) {
314
+ // Ignore migration errors if old tables don't exist
315
+ }
316
+
317
+ await pool.request().query(`
318
+ IF OBJECT_ID('dbo.tblBots', 'U') IS NULL
319
+ BEGIN
320
+ CREATE TABLE dbo.tblBots (
321
+ id NVARCHAR(120) PRIMARY KEY,
322
+ api_key NVARCHAR(255) NOT NULL,
323
+ name NVARCHAR(255) NOT NULL,
324
+ description NVARCHAR(MAX) NULL,
325
+ provider NVARCHAR(64) NOT NULL,
326
+ model NVARCHAR(255) NULL,
327
+ guard_model NVARCHAR(255) NULL,
328
+ enable_guard BIT NOT NULL CONSTRAINT DF_tblBots_enable_guard DEFAULT(1),
329
+ is_active BIT NOT NULL CONSTRAINT DF_tblBots_is_active DEFAULT(1),
330
+ system_prompt NVARCHAR(MAX) NULL,
331
+ created_at DATETIME2 NOT NULL CONSTRAINT DF_tblBots_created_at DEFAULT(SYSUTCDATETIME()),
332
+ updated_at DATETIME2 NOT NULL CONSTRAINT DF_tblBots_updated_at DEFAULT(SYSUTCDATETIME())
333
+ );
334
+ END
335
+
336
+ IF COL_LENGTH('dbo.tblBots', 'is_active') IS NULL
337
+ BEGIN
338
+ ALTER TABLE dbo.tblBots
339
+ ADD is_active BIT NOT NULL CONSTRAINT DF_tblBots_is_active DEFAULT(1) WITH VALUES;
340
+ END
341
+
342
+ IF OBJECT_ID('dbo.tblKnowledge', 'U') IS NULL
343
+ BEGIN
344
+ CREATE TABLE dbo.tblKnowledge (
345
+ id INT IDENTITY(1,1) PRIMARY KEY,
346
+ bot_id NVARCHAR(120) NOT NULL,
347
+ keyword NVARCHAR(255) NULL,
348
+ title NVARCHAR(255) NULL,
349
+ content NVARCHAR(MAX) NOT NULL,
350
+ tags_json NVARCHAR(MAX) NULL,
351
+ source NVARCHAR(80) NULL,
352
+ is_active BIT NOT NULL CONSTRAINT DF_tblKnowledge_is_active DEFAULT(1),
353
+ created_at DATETIME2 NOT NULL CONSTRAINT DF_tblKnowledge_created_at DEFAULT(SYSUTCDATETIME()),
354
+ updated_at DATETIME2 NOT NULL CONSTRAINT DF_tblKnowledge_updated_at DEFAULT(SYSUTCDATETIME()),
355
+ CONSTRAINT FK_tblKnowledge_bot FOREIGN KEY (bot_id) REFERENCES dbo.tblBots(id) ON DELETE CASCADE
356
+ );
357
+
358
+ CREATE INDEX IX_tblKnowledge_bot_id ON dbo.tblKnowledge (bot_id);
359
+ END
360
+
361
+ IF OBJECT_ID('dbo.tblChatHistory', 'U') IS NULL
362
+ BEGIN
363
+ CREATE TABLE dbo.tblChatHistory (
364
+ id BIGINT IDENTITY(1,1) PRIMARY KEY,
365
+ request_id NVARCHAR(64) NOT NULL UNIQUE,
366
+ session_id NVARCHAR(160) NULL,
367
+ bot_id NVARCHAR(120) NOT NULL,
368
+ website_id NVARCHAR(255) NULL,
369
+ site_key NVARCHAR(255) NULL,
370
+ site_name NVARCHAR(255) NULL,
371
+ site_url NVARCHAR(2048) NULL,
372
+ page_url NVARCHAR(MAX) NULL,
373
+ page_origin NVARCHAR(255) NULL,
374
+ referrer NVARCHAR(MAX) NULL,
375
+ provider NVARCHAR(64) NULL,
376
+ model NVARCHAR(255) NULL,
377
+ user_handle NVARCHAR(120) NULL,
378
+ tenant_id NVARCHAR(120) NULL,
379
+ widget_user_id NVARCHAR(120) NULL,
380
+ client_ip NVARCHAR(128) NULL,
381
+ user_agent NVARCHAR(512) NULL,
382
+ request_messages_json NVARCHAR(MAX) NOT NULL,
383
+ latest_user_message NVARCHAR(MAX) NULL,
384
+ knowledge_context NVARCHAR(MAX) NULL,
385
+ system_prompt NVARCHAR(MAX) NULL,
386
+ assistant_response NVARCHAR(MAX) NULL,
387
+ response_status NVARCHAR(40) NOT NULL CONSTRAINT DF_tblChatHistory_response_status DEFAULT('started'),
388
+ error_message NVARCHAR(MAX) NULL,
389
+ started_at DATETIME2 NOT NULL CONSTRAINT DF_tblChatHistory_started_at DEFAULT(SYSUTCDATETIME()),
390
+ completed_at DATETIME2 NULL,
391
+ duration_ms INT NULL,
392
+ CONSTRAINT FK_tblChatHistory_bot FOREIGN KEY (bot_id) REFERENCES dbo.tblBots(id) ON DELETE CASCADE
393
+ );
394
+
395
+ CREATE INDEX IX_tblChatHistory_bot_started ON dbo.tblChatHistory (bot_id, started_at DESC);
396
+ CREATE INDEX IX_tblChatHistory_session_started ON dbo.tblChatHistory (session_id, started_at DESC);
397
+ CREATE INDEX IX_tblChatHistory_website_started ON dbo.tblChatHistory (website_id, started_at DESC);
398
+ END
399
+ `);
400
+
401
+ return true;
402
+ })().catch((error) => {
403
+ mssqlReadyPromise = null;
404
+ throw error;
405
+ });
406
+ }
407
+
408
+ return mssqlReadyPromise;
409
+ }
410
+
411
+ async function ensureMssqlKnowledgeTable() {
412
+ return ensureMssqlTables();
413
+ }
414
+
415
+ function normalizeTags(tags) {
416
+ if (Array.isArray(tags)) {
417
+ return tags.map((tag) => String(tag || "").trim()).filter(Boolean);
418
+ }
419
+ if (typeof tags === "string" && tags.trim()) {
420
+ return tags.split(",").map((tag) => tag.trim()).filter(Boolean);
421
+ }
422
+ return [];
423
+ }
424
+
425
+ function mapDbKnowledgeRow(row) {
426
+ const tags = safeParseJson(row.tags_json, normalizeTags(row.tags_json));
427
+ return {
428
+ id: row.id,
429
+ botId: String(row.bot_id || "").trim(),
430
+ keyword: String(row.keyword || row.title || "Knowledge").trim() || "Knowledge",
431
+ title: String(row.title || row.keyword || "Knowledge").trim() || "Knowledge",
432
+ content: String(row.content || "").trim(),
433
+ answer: String(row.content || "").trim(),
434
+ tags: Array.isArray(tags) ? tags : [],
435
+ source: String(row.source || "mssql").trim() || "mssql",
436
+ updatedAt: row.updated_at
437
+ };
438
+ }
439
+
440
+ async function getKnowledgeFromMssql(filters) {
441
+ if (!isMssqlConfigured()) {
442
+ return [];
443
+ }
444
+
445
+ await ensureMssqlKnowledgeTable();
446
+ const pool = await getMssqlPool();
447
+ if (!pool) {
448
+ return [];
449
+ }
450
+
451
+ const request = pool.request();
452
+ const where = ["is_active = 1"];
453
+ const botId = String(filters?.botId || "").trim();
454
+
455
+ if (botId) {
456
+ where.push("bot_id = @botId");
457
+ request.input("botId", sql.NVarChar(120), botId);
458
+ }
459
+
460
+ const result = await request.query(`
461
+ SELECT id, bot_id, keyword, title, content, tags_json, source, is_active, created_at, updated_at
462
+ FROM dbo.tblKnowledge
463
+ WHERE ${where.join(" AND ")}
464
+ ORDER BY updated_at DESC, id DESC
465
+ `);
466
+
467
+ return (result.recordset || []).map(mapDbKnowledgeRow);
468
+ }
469
+
470
+
471
+
472
+ function toNormalizedString(value) {
473
+ return String(value || "").trim().toLowerCase();
474
+ }
475
+
476
+
477
+
478
+ function truncateText(value, maxLength) {
479
+ const text = String(value || "").trim();
480
+ if (text.length <= maxLength) {
481
+ return text;
482
+ }
483
+
484
+ return `${text.slice(0, Math.max(maxLength - 1, 1)).trimEnd()}…`;
485
+ }
486
+
487
+
488
+
489
+
490
+
491
+ // ============================================================================
492
+ // BOT MANAGEMENT: Per-bot configuration, API keys, and settings
493
+ // ============================================================================
494
+
495
+
496
+
497
+ function mapDbBotRow(row) {
498
+ return {
499
+ id: String(row.id || "").trim(),
500
+ apiKey: String(row.api_key || "").trim(),
501
+ name: String(row.name || row.id || "Unnamed").trim(),
502
+ description: String(row.description || "").trim(),
503
+ provider: String(row.provider || defaultProvider).toLowerCase().trim(),
504
+ model: String(row.model || "").trim(),
505
+ guardModel: String(row.guard_model || groqGuardModel).trim(),
506
+ enableGuard: Number(row.enable_guard) === 1 || row.enable_guard === true,
507
+ isActive: Number(row.is_active) === 1 || row.is_active === true,
508
+ systemPrompt: String(row.system_prompt || defaultSystemPrompt).trim(),
509
+ knowledge: [],
510
+ createdAt: String(row.created_at || new Date().toISOString()).trim(),
511
+ updatedAt: String(row.updated_at || new Date().toISOString()).trim()
512
+ };
513
+ }
514
+
515
+ async function loadBotsFromMssql() {
516
+ if (!isMssqlConfigured()) {
517
+ return [];
518
+ }
519
+
520
+ await ensureMssqlTables();
521
+ const pool = await getMssqlPool();
522
+ if (!pool) {
523
+ return [];
524
+ }
525
+
526
+ const result = await pool.request().query(`
527
+ SELECT id, api_key, name, description, provider, model, guard_model, enable_guard, is_active, system_prompt, created_at, updated_at
528
+ FROM dbo.tblBots
529
+ ORDER BY updated_at DESC, id ASC
530
+ `);
531
+
532
+ return (result.recordset || []).map(mapDbBotRow).filter((bot) => bot.id && bot.apiKey);
533
+ }
534
+
535
+ async function upsertBotInMssql(bot) {
536
+ if (!isMssqlConfigured()) {
537
+ return;
538
+ }
539
+
540
+ await ensureMssqlTables();
541
+ const pool = await getMssqlPool();
542
+ if (!pool) {
543
+ return;
544
+ }
545
+
546
+ await pool.request()
547
+ .input("id", sql.NVarChar(120), bot.id)
548
+ .input("apiKey", sql.NVarChar(255), bot.apiKey)
549
+ .input("name", sql.NVarChar(255), bot.name)
550
+ .input("description", sql.NVarChar(sql.MAX), bot.description || null)
551
+ .input("provider", sql.NVarChar(64), bot.provider)
552
+ .input("model", sql.NVarChar(255), bot.model || null)
553
+ .input("guardModel", sql.NVarChar(255), bot.guardModel || null)
554
+ .input("enableGuard", sql.Bit, bot.enableGuard !== false)
555
+ .input("isActive", sql.Bit, bot.isActive !== false)
556
+ .input("systemPrompt", sql.NVarChar(sql.MAX), bot.systemPrompt || defaultSystemPrompt)
557
+ .query(`
558
+ MERGE dbo.tblBots AS target
559
+ USING (SELECT @id AS id) AS source
560
+ ON target.id = source.id
561
+ WHEN MATCHED THEN
562
+ UPDATE SET api_key = @apiKey,
563
+ name = @name,
564
+ description = @description,
565
+ provider = @provider,
566
+ model = @model,
567
+ guard_model = @guardModel,
568
+ enable_guard = @enableGuard,
569
+ is_active = @isActive,
570
+ system_prompt = @systemPrompt,
571
+ updated_at = SYSUTCDATETIME()
572
+ WHEN NOT MATCHED THEN
573
+ INSERT (id, api_key, name, description, provider, model, guard_model, enable_guard, is_active, system_prompt, created_at, updated_at)
574
+ VALUES (@id, @apiKey, @name, @description, @provider, @model, @guardModel, @enableGuard, @isActive, @systemPrompt, SYSUTCDATETIME(), SYSUTCDATETIME());
575
+ `);
576
+ }
577
+
578
+ async function deleteBotInMssql(botId) {
579
+ if (!isMssqlConfigured()) {
580
+ return;
581
+ }
582
+
583
+ await ensureMssqlTables();
584
+ const pool = await getMssqlPool();
585
+ if (!pool) {
586
+ return;
587
+ }
588
+
589
+ await pool.request()
590
+ .input("id", sql.NVarChar(120), botId)
591
+ .query("DELETE FROM dbo.tblBots WHERE id = @id");
592
+ }
593
+
594
+ async function initializeBotsRegistry() {
595
+ if (!isMssqlConfigured()) {
596
+ throw new Error("MSSQL is required. Enable MSSQL_ENABLED=true in .env");
597
+ }
598
+ const dbBots = await loadBotsFromMssql();
599
+ botsRegistry = dbBots;
600
+ }
601
+
602
+ let botsRegistry = [];
603
+
604
+ function getBotById(botId) {
605
+ return botsRegistry.find((bot) => bot.id === botId);
606
+ }
607
+
608
+ function validateBotApiKey(botId, apiKey) {
609
+ const bot = getBotById(botId);
610
+ if (!bot) {
611
+ return { valid: false, error: "Bot not found" };
612
+ }
613
+ if (bot.isActive === false) {
614
+ return { valid: false, error: "Bot is inactive" };
615
+ }
616
+ if (bot.apiKey !== apiKey) {
617
+ return { valid: false, error: "Invalid API key for bot" };
618
+ }
619
+ return { valid: true, bot };
620
+ }
621
+
622
+ function extractBotIdAndApiKey(req) {
623
+ // Check query params first
624
+ let botId = req.query?.botId || req.query?.bot_id;
625
+ let apiKey = req.query?.apiKey || req.query?.api_key;
626
+
627
+ // Fall back to request body
628
+ if (!botId) botId = req.body?.botId || req.body?.bot_id;
629
+ if (!apiKey) apiKey = req.body?.apiKey || req.body?.api_key;
630
+
631
+ // Fall back to headers
632
+ if (!apiKey) {
633
+ apiKey = req.headers["x-bot-api-key"];
634
+ }
635
+
636
+ return { botId: String(botId || "").trim(), apiKey: String(apiKey || "").trim() };
637
+ }
638
+
639
+ function getLatestUserMessage(messages) {
640
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
641
+ const message = messages[index];
642
+ if (message && message.role === "user" && typeof message.content === "string") {
643
+ return message.content.trim();
644
+ }
645
+ }
646
+
647
+ return "";
648
+ }
649
+
650
+ function scoreKnowledgeEntry(entry, query) {
651
+ const queryTokens = new Set(toNormalizedString(query).split(/[^a-z0-9]+/).filter((token) => token.length > 2));
652
+ if (!queryTokens.size) {
653
+ return 1;
654
+ }
655
+
656
+ const searchableText = [
657
+ entry.title,
658
+ entry.content,
659
+ entry.answer,
660
+ entry.keyword,
661
+ entry.source,
662
+ ...(entry.tags || [])
663
+ ]
664
+ .join(" ")
665
+ .toLowerCase();
666
+
667
+ let score = 0;
668
+ for (const token of queryTokens) {
669
+ if (searchableText.includes(token)) {
670
+ score += 1;
671
+ }
672
+ }
673
+
674
+ return score;
675
+ }
676
+
677
+ function formatKnowledgeEntry(entry) {
678
+ const lines = [];
679
+
680
+ if (entry.title || entry.keyword) {
681
+ lines.push(`Title: ${entry.title || entry.keyword}`);
682
+ }
683
+
684
+ if (entry.content || entry.answer) {
685
+ lines.push(`Content: ${truncateText(entry.content || entry.answer, 600)}`);
686
+ }
687
+
688
+ if (entry.source) {
689
+ lines.push(`Source: ${entry.source}`);
690
+ }
691
+
692
+ return lines.join("\n");
693
+ }
694
+
695
+ function buildKnowledgeContext(knowledge, latestUserMessage) {
696
+ const entries = Array.isArray(knowledge) ? [...knowledge] : [];
697
+ if (!entries.length) {
698
+ return "";
699
+ }
700
+
701
+ const rankedEntries = entries
702
+ .map((entry) => ({ entry, score: scoreKnowledgeEntry(entry, latestUserMessage) }))
703
+ .filter((item) => item.score > 0)
704
+ .sort((left, right) => right.score - left.score)
705
+ .slice(0, knowledgeSnippetLimit)
706
+ .map((item) => item.entry);
707
+
708
+ if (!rankedEntries.length) {
709
+ return "";
710
+ }
711
+
712
+ const snippets = [];
713
+ for (const entry of rankedEntries) {
714
+ snippets.push(formatKnowledgeEntry(entry));
715
+ }
716
+
717
+ return truncateText(snippets.join("\n\n"), knowledgeContextCharLimit);
718
+ }
719
+
720
+
721
+
722
+
723
+
724
+ function buildSystemPrompt(basePrompt, knowledgeContext) {
725
+ const sections = [];
726
+
727
+ sections.push(basePrompt || defaultSystemPrompt);
728
+ sections.push(
729
+ [
730
+ "Understand natural-language questions, including short, informal, or mixed-language requests.",
731
+ "Infer the user's intent from context and answer clearly, directly, and in plain language.",
732
+ "When information comes from the knowledge base, prefer it over guessing."
733
+ ].join(" ")
734
+ );
735
+
736
+ if (knowledgeContext) {
737
+ sections.push(`Relevant knowledge snippets:\n${knowledgeContext}`);
738
+ }
739
+
740
+ return sections.filter(Boolean).join("\n\n");
741
+ }
742
+
743
+ const providerConfigs = {
744
+ claude: {
745
+ apiKey: process.env.ANTHROPIC_API_KEY,
746
+ model: process.env.CLAUDE_MODEL || "claude-sonnet-4-20250514"
747
+ },
748
+ openai: {
749
+ apiKey: process.env.OPENAI_API_KEY,
750
+ model: process.env.OPENAI_MODEL || "gpt-4o-mini",
751
+ baseUrl: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"
752
+ },
753
+ groq: {
754
+ apiKey: process.env.GROQ_API_KEY,
755
+ model: process.env.GROQ_MODEL || "llama-3.1-8b-instant",
756
+ baseUrl: process.env.GROQ_BASE_URL || "https://api.groq.com/openai/v1"
757
+ },
758
+ mistral: {
759
+ apiKey: process.env.MISTRAL_API_KEY,
760
+ model: process.env.MISTRAL_MODEL || "mistral-small-latest",
761
+ baseUrl: process.env.MISTRAL_BASE_URL || "https://api.mistral.ai/v1"
762
+ }
763
+ };
764
+
765
+ const userHandleConfig = {}; // Placeholder - user policies no longer used
766
+
767
+ const rateLimitBuckets = new Map();
768
+
769
+ const allowedOrigins = (process.env.ALLOWED_ORIGINS || "*")
770
+ .split(",")
771
+ .map((value) => value.trim())
772
+ .filter(Boolean);
773
+
774
+ const allowAnyOrigin = allowedOrigins.includes("*");
775
+
776
+ app.use(
777
+ cors({
778
+ origin(origin, callback) {
779
+ if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) {
780
+ callback(null, true);
781
+ return;
782
+ }
783
+ callback(new Error("Not allowed by CORS"));
784
+ }
785
+ })
786
+ );
787
+
788
+ app.use(express.json({ limit: "1mb" }));
789
+
790
+ function enforceAdminLogin(req, res, next) {
791
+ const normalizedPath = String(req.path || "");
792
+
793
+ // Public endpoint used by embedded widgets to pull knowledge.
794
+ if (normalizedPath.startsWith("/api/export/knowledge")) {
795
+ next();
796
+ return;
797
+ }
798
+
799
+ // If no password is configured, admin auth is disabled.
800
+ if (!adminPortalPassword) {
801
+ next();
802
+ return;
803
+ }
804
+
805
+ const authHeader = String(req.headers.authorization || "");
806
+ if (authHeader.toLowerCase().startsWith("basic ")) {
807
+ const encoded = authHeader.slice(6).trim();
808
+ try {
809
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
810
+ const delimiter = decoded.indexOf(":");
811
+ if (delimiter >= 0) {
812
+ const username = decoded.slice(0, delimiter);
813
+ const password = decoded.slice(delimiter + 1);
814
+ if (username === adminPortalUsername && password === adminPortalPassword) {
815
+ next();
816
+ return;
817
+ }
818
+ }
819
+ } catch {
820
+ // Fall through to auth challenge below.
821
+ }
822
+ }
823
+
824
+ res.set("WWW-Authenticate", 'Basic realm="Chatbot Admin"');
825
+ res.status(401).send("Authentication required");
826
+ }
827
+
828
+ async function enforcePortalLogin(req, res, next) {
829
+ const normalizedPath = String(req.path || "");
830
+
831
+ // Allow static portal assets to load without botId in query string.
832
+ if (/\.(js|css|png|jpg|jpeg|gif|svg|ico|webp|map)$/i.test(normalizedPath)) {
833
+ next();
834
+ return;
835
+ }
836
+
837
+ // Keep widget sync endpoint public.
838
+ if (normalizedPath.startsWith("/api/export/knowledge")) {
839
+ next();
840
+ return;
841
+ }
842
+
843
+ // Allow login assets/endpoints.
844
+ if (
845
+ normalizedPath === "/login.html" ||
846
+ normalizedPath.startsWith("/api/auth/") ||
847
+ normalizedPath.endsWith("portal-login.js")
848
+ ) {
849
+ next();
850
+ return;
851
+ }
852
+
853
+ const botId = String(req.query?.botId || req.body?.botId || "").trim();
854
+ if (!botId) {
855
+ res.status(400).send("botId query parameter is required");
856
+ return;
857
+ }
858
+
859
+ const session = getPortalSession(req);
860
+ if (!session) {
861
+ const loginRedirect = `/portal/login.html?botId=${encodeURIComponent(botId)}`;
862
+ if (normalizedPath.startsWith("/api/")) {
863
+ res.status(401).json({ error: "Login required" });
864
+ return;
865
+ }
866
+ res.redirect(loginRedirect);
867
+ return;
868
+ }
869
+
870
+ const canAccess = await canPortalUserAccessBot(session.userId, botId);
871
+ if (!canAccess) {
872
+ if (normalizedPath.startsWith("/api/")) {
873
+ res.status(403).json({ error: "Access denied for this bot" });
874
+ return;
875
+ }
876
+ res.status(403).send("Access denied for this bot");
877
+ return;
878
+ }
879
+
880
+ next();
881
+ }
882
+
883
+ app.use("/embed", express.static(path.join(__dirname, "..", "public")));
884
+ app.use("/admin", enforceAdminLogin);
885
+
886
+ app.get("/portal/admin-bots.html", enforceAdminLogin, (req, res) => {
887
+ res.sendFile(path.join(__dirname, "..", "public", "portal", "admin-bots.html"));
888
+ });
889
+
890
+ app.get("/portal/admin-bots.js", enforceAdminLogin, (req, res) => {
891
+ res.sendFile(path.join(__dirname, "..", "public", "portal", "admin-bots.js"));
892
+ });
893
+
894
+ app.get("/portal/admin-users.html", enforceAdminLogin, (req, res) => {
895
+ res.sendFile(path.join(__dirname, "..", "public", "portal", "admin-users.html"));
896
+ });
897
+
898
+ app.get("/portal/admin-users.js", enforceAdminLogin, (req, res) => {
899
+ res.sendFile(path.join(__dirname, "..", "public", "portal", "admin-users.js"));
900
+ });
901
+
902
+ app.use("/portal", enforcePortalLogin, express.static(path.join(__dirname, "..", "public", "portal")));
903
+
904
+ app.post("/portal/api/auth/login", express.json(), async (req, res) => {
905
+ try {
906
+ const username = String(req.body?.username || "").trim();
907
+ const password = String(req.body?.password || "").trim();
908
+ const botId = String(req.body?.botId || "").trim();
909
+
910
+ if (!username || !password || !botId) {
911
+ res.status(400).json({ error: "username, password and botId are required" });
912
+ return;
913
+ }
914
+
915
+ const user = await findPortalUserByCredentials(username, password);
916
+ if (!user) {
917
+ res.status(401).json({ error: "Invalid credentials" });
918
+ return;
919
+ }
920
+
921
+ const canAccess = await canPortalUserAccessBot(user.id, botId);
922
+ if (!canAccess) {
923
+ res.status(403).json({ error: "User does not have access to this bot" });
924
+ return;
925
+ }
926
+
927
+ const token = createPortalSession(user);
928
+ res.setHeader("Set-Cookie", `portal_auth=${encodeURIComponent(token)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${Math.floor(portalSessionTtlMs / 1000)}`);
929
+ res.json({ ok: true, username: user.username, botId });
930
+ } catch (error) {
931
+ res.status(500).json({ error: error?.message || "Login failed" });
932
+ }
933
+ });
934
+
935
+ app.post("/portal/api/auth/logout", (req, res) => {
936
+ const session = getPortalSession(req);
937
+ clearPortalSession(res, session?.token);
938
+ res.json({ ok: true });
939
+ });
940
+
941
+ app.get("/portal/api/auth/me", async (req, res) => {
942
+ try {
943
+ const session = getPortalSession(req);
944
+ if (!session) {
945
+ res.status(401).json({ error: "Not logged in" });
946
+ return;
947
+ }
948
+
949
+ const botId = String(req.query?.botId || "").trim();
950
+ if (!botId) {
951
+ res.status(400).json({ error: "botId is required" });
952
+ return;
953
+ }
954
+
955
+ const canAccess = await canPortalUserAccessBot(session.userId, botId);
956
+ if (!canAccess) {
957
+ res.status(403).json({ error: "Access denied for this bot" });
958
+ return;
959
+ }
960
+
961
+ res.json({ ok: true, user: { id: session.userId, username: session.username }, botId });
962
+ } catch (error) {
963
+ res.status(500).json({ error: error?.message || "Unable to fetch session" });
964
+ }
965
+ });
966
+
967
+ function getClientIp(req) {
968
+ return (
969
+ req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() ||
970
+ req.socket.remoteAddress ||
971
+ "unknown"
972
+ );
973
+ }
974
+
975
+ function sendSseError(res, message) {
976
+ res.write(`event: error\ndata: ${JSON.stringify({ error: message })}\n\n`);
977
+ res.write("event: done\ndata: {}\n\n");
978
+ res.end();
979
+ }
980
+
981
+ function extractAiApiKeyFromRequest(req) {
982
+ const headerValue = req.headers.authorization || "";
983
+ if (typeof headerValue === "string" && headerValue.toLowerCase().startsWith("bearer ")) {
984
+ const token = headerValue.slice(7).trim();
985
+ if (token) {
986
+ return token;
987
+ }
988
+ }
989
+
990
+ const tokenHeader = req.headers["x-chatbot-token"];
991
+ if (typeof tokenHeader === "string" && tokenHeader.trim()) {
992
+ return tokenHeader.trim();
993
+ }
994
+
995
+ return String(req.body?.aiApiKey || req.body?.ai_apikey || "").trim();
996
+ }
997
+
998
+ function enforceRequestOrigin(req, res) {
999
+ if (!enforceOriginLock || allowAnyOrigin) {
1000
+ return true;
1001
+ }
1002
+
1003
+ const origin = req.headers.origin;
1004
+ if (!origin || !allowedOrigins.includes(origin)) {
1005
+ res.status(403).json({ error: "Origin blocked" });
1006
+ return false;
1007
+ }
1008
+
1009
+ return true;
1010
+ }
1011
+
1012
+ function enforceRateLimit(req, res) {
1013
+ const now = Date.now();
1014
+ const key = getClientIp(req);
1015
+ const bucket = rateLimitBuckets.get(key) || { count: 0, resetAt: now + rateLimitWindowMs };
1016
+
1017
+ if (now > bucket.resetAt) {
1018
+ bucket.count = 0;
1019
+ bucket.resetAt = now + rateLimitWindowMs;
1020
+ }
1021
+
1022
+ bucket.count += 1;
1023
+ rateLimitBuckets.set(key, bucket);
1024
+
1025
+ const remaining = Math.max(rateLimitMaxRequests - bucket.count, 0);
1026
+ res.setHeader("X-RateLimit-Limit", String(rateLimitMaxRequests));
1027
+ res.setHeader("X-RateLimit-Remaining", String(remaining));
1028
+ res.setHeader("X-RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1000)));
1029
+
1030
+ if (bucket.count > rateLimitMaxRequests) {
1031
+ res.status(429).json({ error: "Rate limit exceeded" });
1032
+ return false;
1033
+ }
1034
+
1035
+ return true;
1036
+ }
1037
+
1038
+
1039
+
1040
+ async function streamClaudeResponse({ config, model, systemPrompt, messages, onToken, onError }) {
1041
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
1042
+ method: "POST",
1043
+ headers: {
1044
+ "Content-Type": "application/json",
1045
+ "x-api-key": config.apiKey,
1046
+ "anthropic-version": "2023-06-01"
1047
+ },
1048
+ body: JSON.stringify({
1049
+ model,
1050
+ max_tokens: 1024,
1051
+ system: systemPrompt,
1052
+ stream: true,
1053
+ messages
1054
+ })
1055
+ });
1056
+
1057
+ if (!response.ok || !response.body) {
1058
+ throw new Error(await response.text() || "Anthropic API error");
1059
+ }
1060
+
1061
+ const decoder = new TextDecoder();
1062
+ let buffer = "";
1063
+
1064
+ for await (const chunk of response.body) {
1065
+ buffer += decoder.decode(chunk, { stream: true });
1066
+ const events = buffer.split("\n\n");
1067
+ buffer = events.pop() || "";
1068
+
1069
+ for (const eventBlock of events) {
1070
+ const lines = eventBlock.split("\n");
1071
+ const eventTypeLine = lines.find((line) => line.startsWith("event:"));
1072
+ const dataLine = lines.find((line) => line.startsWith("data:"));
1073
+ if (!dataLine) {
1074
+ continue;
1075
+ }
1076
+
1077
+ const eventType = eventTypeLine ? eventTypeLine.replace("event:", "").trim() : "message";
1078
+ const dataRaw = dataLine.replace("data:", "").trim();
1079
+ if (dataRaw === "[DONE]") {
1080
+ continue;
1081
+ }
1082
+
1083
+ let parsed;
1084
+ try {
1085
+ parsed = JSON.parse(dataRaw);
1086
+ } catch {
1087
+ continue;
1088
+ }
1089
+
1090
+ if (eventType === "content_block_delta") {
1091
+ const token = parsed?.delta?.text;
1092
+ if (token) {
1093
+ onToken(token);
1094
+ }
1095
+ }
1096
+
1097
+ if (eventType === "error") {
1098
+ onError(parsed?.error?.message || "Anthropic stream error");
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ async function streamOpenAiCompatibleResponse({ config, model, systemPrompt, messages, onToken }) {
1105
+ const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1106
+ method: "POST",
1107
+ headers: {
1108
+ "Content-Type": "application/json",
1109
+ Authorization: `Bearer ${config.apiKey}`
1110
+ },
1111
+ body: JSON.stringify({
1112
+ model,
1113
+ stream: true,
1114
+ messages: [{ role: "system", content: systemPrompt }, ...messages]
1115
+ })
1116
+ });
1117
+
1118
+ if (!response.ok || !response.body) {
1119
+ throw new Error(await response.text() || "Upstream API error");
1120
+ }
1121
+
1122
+ const decoder = new TextDecoder();
1123
+ let buffer = "";
1124
+
1125
+ for await (const chunk of response.body) {
1126
+ buffer += decoder.decode(chunk, { stream: true });
1127
+ const lines = buffer.split("\n");
1128
+ buffer = lines.pop() || "";
1129
+
1130
+ for (const line of lines) {
1131
+ const trimmed = line.trim();
1132
+ if (!trimmed.startsWith("data:")) {
1133
+ continue;
1134
+ }
1135
+
1136
+ const payload = trimmed.slice(5).trim();
1137
+ if (payload === "[DONE]") {
1138
+ continue;
1139
+ }
1140
+
1141
+ let parsed;
1142
+ try {
1143
+ parsed = JSON.parse(payload);
1144
+ } catch {
1145
+ continue;
1146
+ }
1147
+
1148
+ const token = parsed?.choices?.[0]?.delta?.content;
1149
+ if (typeof token === "string" && token) {
1150
+ onToken(token);
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ function parseGuardLabel(text) {
1157
+ const raw = String(text || "").trim();
1158
+ if (!raw) {
1159
+ return "unknown";
1160
+ }
1161
+
1162
+ try {
1163
+ const parsed = JSON.parse(raw);
1164
+ const label = String(parsed?.label || "").trim().toLowerCase();
1165
+ if (label === "safe" || label === "unsafe") {
1166
+ return label;
1167
+ }
1168
+ } catch {
1169
+ // Fallback to keyword matching.
1170
+ }
1171
+
1172
+ const lower = raw.toLowerCase();
1173
+ if (/\bunsafe\b/.test(lower)) {
1174
+ return "unsafe";
1175
+ }
1176
+ if (/\bsafe\b/.test(lower)) {
1177
+ return "safe";
1178
+ }
1179
+ return "unknown";
1180
+ }
1181
+
1182
+ async function runGroqPromptGuard({ config, latestUserMessage }) {
1183
+ if (!enableGroqPromptGuard || !latestUserMessage) {
1184
+ return { allowed: true, reason: "" };
1185
+ }
1186
+
1187
+ const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1188
+ method: "POST",
1189
+ headers: {
1190
+ "Content-Type": "application/json",
1191
+ Authorization: `Bearer ${config.apiKey}`
1192
+ },
1193
+ body: JSON.stringify({
1194
+ model: groqGuardModel,
1195
+ stream: false,
1196
+ temperature: 0,
1197
+ max_tokens: 32,
1198
+ messages: [
1199
+ {
1200
+ role: "user",
1201
+ content: [
1202
+ "Classify the following user input for policy risk.",
1203
+ "Reply with strict JSON only: {\"label\":\"safe\"} or {\"label\":\"unsafe\"}.",
1204
+ "User input:",
1205
+ latestUserMessage
1206
+ ].join("\n")
1207
+ }
1208
+ ]
1209
+ })
1210
+ });
1211
+
1212
+ if (!response.ok) {
1213
+ throw new Error(await response.text() || "Prompt guard request failed");
1214
+ }
1215
+
1216
+ const payload = await response.json();
1217
+ const content = payload?.choices?.[0]?.message?.content || "";
1218
+ const label = parseGuardLabel(content);
1219
+ if (label === "unsafe") {
1220
+ return {
1221
+ allowed: false,
1222
+ reason: "Your request was blocked by safety policy. Please rephrase your question."
1223
+ };
1224
+ }
1225
+
1226
+ return { allowed: true, reason: "" };
1227
+ }
1228
+
1229
+ function normalizeMessages(messages) {
1230
+ if (!Array.isArray(messages)) {
1231
+ return [];
1232
+ }
1233
+
1234
+ return messages
1235
+ .filter((message) => message && typeof message.content === "string")
1236
+ .map((message) => ({
1237
+ role: message.role === "assistant" ? "assistant" : "user",
1238
+ content: message.content
1239
+ }))
1240
+ .slice(-40);
1241
+ }
1242
+
1243
+ function toNullableText(value, maxLength) {
1244
+ const text = String(value || "").trim();
1245
+ if (!text) {
1246
+ return null;
1247
+ }
1248
+ if (!maxLength || text.length <= maxLength) {
1249
+ return text;
1250
+ }
1251
+ return text.slice(0, maxLength);
1252
+ }
1253
+
1254
+ function getRequestClientIp(req) {
1255
+ const forwarded = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
1256
+ if (forwarded) {
1257
+ return forwarded;
1258
+ }
1259
+ return toNullableText(req.socket?.remoteAddress || "", 128);
1260
+ }
1261
+
1262
+ function generateHistoryRequestId() {
1263
+ return `req_${Date.now()}_${crypto.randomBytes(8).toString("hex")}`;
1264
+ }
1265
+
1266
+ async function createChatHistoryEntry(payload) {
1267
+ if (!enableChatHistoryStorage || !isMssqlConfigured()) {
1268
+ return null;
1269
+ }
1270
+
1271
+ try {
1272
+ await ensureMssqlTables();
1273
+ const pool = await getMssqlPool();
1274
+ if (!pool) {
1275
+ return null;
1276
+ }
1277
+
1278
+ const requestId = generateHistoryRequestId();
1279
+ const siteConfig = payload.siteConfig || {};
1280
+ const requestMessagesJson = JSON.stringify(Array.isArray(payload.messages) ? payload.messages : []);
1281
+
1282
+ await pool.request()
1283
+ .input("requestId", sql.NVarChar(64), requestId)
1284
+ .input("sessionId", sql.NVarChar(160), toNullableText(payload.sessionId, 160))
1285
+ .input("botId", sql.NVarChar(120), String(payload.botId || "").trim())
1286
+ .input("websiteId", sql.NVarChar(255), toNullableText(siteConfig.siteId, 255))
1287
+ .input("siteKey", sql.NVarChar(255), toNullableText(siteConfig.siteKey, 255))
1288
+ .input("siteName", sql.NVarChar(255), toNullableText(siteConfig.siteName, 255))
1289
+ .input("siteUrl", sql.NVarChar(2048), toNullableText(siteConfig.siteUrl, 2048))
1290
+ .input("pageUrl", sql.NVarChar(sql.MAX), toNullableText(siteConfig.pageUrl, 0))
1291
+ .input("pageOrigin", sql.NVarChar(255), toNullableText(siteConfig.pageOrigin, 255))
1292
+ .input("referrer", sql.NVarChar(sql.MAX), toNullableText(siteConfig.referrer, 0))
1293
+ .input("provider", sql.NVarChar(64), toNullableText(payload.provider, 64))
1294
+ .input("model", sql.NVarChar(255), toNullableText(payload.model, 255))
1295
+ .input("userHandle", sql.NVarChar(120), toNullableText(siteConfig.userHandle, 120))
1296
+ .input("tenantId", sql.NVarChar(120), toNullableText(siteConfig.tenantId, 120))
1297
+ .input("widgetUserId", sql.NVarChar(120), toNullableText(siteConfig.userId, 120))
1298
+ .input("clientIp", sql.NVarChar(128), toNullableText(payload.clientIp, 128))
1299
+ .input("userAgent", sql.NVarChar(512), toNullableText(payload.userAgent, 512))
1300
+ .input("requestMessagesJson", sql.NVarChar(sql.MAX), requestMessagesJson)
1301
+ .input("latestUserMessage", sql.NVarChar(sql.MAX), toNullableText(payload.latestUserMessage, 0))
1302
+ .input("knowledgeContext", sql.NVarChar(sql.MAX), toNullableText(payload.knowledgeContext, 0))
1303
+ .input("systemPrompt", sql.NVarChar(sql.MAX), toNullableText(payload.systemPrompt, 0))
1304
+ .input("responseStatus", sql.NVarChar(40), toNullableText(payload.responseStatus || "started", 40) || "started")
1305
+ .input("startedAt", sql.DateTime2, payload.startedAt || new Date())
1306
+ .query(`
1307
+ INSERT INTO dbo.tblChatHistory (
1308
+ request_id, session_id, bot_id, website_id, site_key, site_name, site_url,
1309
+ page_url, page_origin, referrer, provider, model, user_handle, tenant_id,
1310
+ widget_user_id, client_ip, user_agent, request_messages_json, latest_user_message,
1311
+ knowledge_context, system_prompt, response_status, started_at
1312
+ )
1313
+ VALUES (
1314
+ @requestId, @sessionId, @botId, @websiteId, @siteKey, @siteName, @siteUrl,
1315
+ @pageUrl, @pageOrigin, @referrer, @provider, @model, @userHandle, @tenantId,
1316
+ @widgetUserId, @clientIp, @userAgent, @requestMessagesJson, @latestUserMessage,
1317
+ @knowledgeContext, @systemPrompt, @responseStatus, @startedAt
1318
+ )
1319
+ `);
1320
+
1321
+ return requestId;
1322
+ } catch (error) {
1323
+ console.warn("Failed to insert chat history:", error?.message || error);
1324
+ return null;
1325
+ }
1326
+ }
1327
+
1328
+ async function finalizeChatHistoryEntry(requestId, payload) {
1329
+ if (!requestId || !enableChatHistoryStorage || !isMssqlConfigured()) {
1330
+ return;
1331
+ }
1332
+
1333
+ try {
1334
+ const pool = await getMssqlPool();
1335
+ if (!pool) {
1336
+ return;
1337
+ }
1338
+
1339
+ const completedAt = payload.completedAt || new Date();
1340
+
1341
+ await pool.request()
1342
+ .input("requestId", sql.NVarChar(64), requestId)
1343
+ .input("assistantResponse", sql.NVarChar(sql.MAX), toNullableText(payload.assistantResponse, 0))
1344
+ .input("responseStatus", sql.NVarChar(40), toNullableText(payload.responseStatus || "completed", 40) || "completed")
1345
+ .input("errorMessage", sql.NVarChar(sql.MAX), toNullableText(payload.errorMessage, 0))
1346
+ .input("knowledgeContext", sql.NVarChar(sql.MAX), toNullableText(payload.knowledgeContext, 0))
1347
+ .input("systemPrompt", sql.NVarChar(sql.MAX), toNullableText(payload.systemPrompt, 0))
1348
+ .input("completedAt", sql.DateTime2, completedAt)
1349
+ .query(`
1350
+ UPDATE dbo.tblChatHistory
1351
+ SET assistant_response = @assistantResponse,
1352
+ response_status = @responseStatus,
1353
+ error_message = @errorMessage,
1354
+ knowledge_context = COALESCE(@knowledgeContext, knowledge_context),
1355
+ system_prompt = COALESCE(@systemPrompt, system_prompt),
1356
+ completed_at = @completedAt,
1357
+ duration_ms = DATEDIFF(MILLISECOND, started_at, @completedAt)
1358
+ WHERE request_id = @requestId
1359
+ `);
1360
+ } catch (error) {
1361
+ console.warn("Failed to finalize chat history:", error?.message || error);
1362
+ }
1363
+ }
1364
+
1365
+ app.get("/health", (_req, res) => {
1366
+ res.json({ ok: true });
1367
+ });
1368
+
1369
+ // ============================================================================
1370
+ // ADMIN ENDPOINTS: Bot management (create, read, update, delete)
1371
+ // ============================================================================
1372
+
1373
+ app.get("/admin/bots", (req, res) => {
1374
+ // Return all bots without API keys for security
1375
+ res.json(botsRegistry.map((bot) => ({
1376
+ id: bot.id,
1377
+ name: bot.name,
1378
+ description: bot.description,
1379
+ provider: bot.provider,
1380
+ model: bot.model,
1381
+ guard_model: bot.guardModel,
1382
+ system_prompt: bot.systemPrompt,
1383
+ enable_guard: bot.enableGuard,
1384
+ is_active: bot.isActive !== false,
1385
+ created_at: bot.createdAt,
1386
+ updated_at: bot.updatedAt
1387
+ })));
1388
+ });
1389
+
1390
+ app.get("/admin/bots/:botId", (req, res) => {
1391
+ const bot = getBotById(req.params.botId);
1392
+ if (!bot) {
1393
+ res.status(404).json({ error: "Bot not found" });
1394
+ return;
1395
+ }
1396
+
1397
+ // Return bot without API key
1398
+ res.json({
1399
+ id: bot.id,
1400
+ name: bot.name,
1401
+ description: bot.description,
1402
+ provider: bot.provider,
1403
+ model: bot.model,
1404
+ guard_model: bot.guardModel,
1405
+ system_prompt: bot.systemPrompt,
1406
+ enable_guard: bot.enableGuard,
1407
+ is_active: bot.isActive !== false,
1408
+ created_at: bot.createdAt,
1409
+ updated_at: bot.updatedAt
1410
+ });
1411
+ });
1412
+
1413
+ app.post("/admin/bots", express.json(), async (req, res) => {
1414
+ try {
1415
+ const { id, name, description, provider, model, apiKey } = req.body;
1416
+ const guardModel = req.body?.guardModel ?? req.body?.guard_model;
1417
+ const enableGuard = req.body?.enableGuard ?? req.body?.enable_guard;
1418
+ const isActive = req.body?.isActive ?? req.body?.is_active;
1419
+ const systemPrompt = req.body?.systemPrompt ?? req.body?.system_prompt;
1420
+
1421
+ // Validate required fields
1422
+ if (!id || typeof id !== "string" || !id.trim()) {
1423
+ res.status(400).json({ error: "Bot id is required and must be a non-empty string" });
1424
+ return;
1425
+ }
1426
+
1427
+ if (getBotById(id)) {
1428
+ res.status(409).json({ error: "Bot with this id already exists" });
1429
+ return;
1430
+ }
1431
+
1432
+ // Generate API key if not provided
1433
+ const generatedApiKey = apiKey || `sk_${Math.random().toString(36).slice(2, 32)}`;
1434
+
1435
+ const newBot = {
1436
+ id: String(id).trim(),
1437
+ apiKey: String(generatedApiKey).trim(),
1438
+ name: String(name || id).trim(),
1439
+ description: String(description || "").trim(),
1440
+ provider: String(provider || defaultProvider).toLowerCase().trim(),
1441
+ model: String(model || "").trim(),
1442
+ guardModel: String(guardModel || groqGuardModel).trim(),
1443
+ enableGuard: !(enableGuard === false || enableGuard === 0 || String(enableGuard || "").toLowerCase() === "false" || String(enableGuard || "") === "0"),
1444
+ isActive: !(isActive === false || isActive === 0 || String(isActive || "").toLowerCase() === "false" || String(isActive || "") === "0"),
1445
+ systemPrompt: String(systemPrompt || defaultSystemPrompt).trim(),
1446
+ knowledge: [],
1447
+ createdAt: new Date().toISOString(),
1448
+ updatedAt: new Date().toISOString()
1449
+ };
1450
+
1451
+ botsRegistry.push(newBot);
1452
+ if (isMssqlConfigured()) {
1453
+ await upsertBotInMssql(newBot);
1454
+ }
1455
+
1456
+ res.status(201).json({
1457
+ id: newBot.id,
1458
+ apiKey: newBot.apiKey,
1459
+ api_key: newBot.apiKey,
1460
+ name: newBot.name,
1461
+ description: newBot.description,
1462
+ provider: newBot.provider,
1463
+ model: newBot.model,
1464
+ is_active: newBot.isActive,
1465
+ createdAt: newBot.createdAt
1466
+ });
1467
+ } catch (error) {
1468
+ res.status(500).json({ error: error?.message || "Unable to create bot" });
1469
+ }
1470
+ });
1471
+
1472
+ app.put("/admin/bots/:botId", express.json(), async (req, res) => {
1473
+ try {
1474
+ const bot = getBotById(req.params.botId);
1475
+ if (!bot) {
1476
+ res.status(404).json({ error: "Bot not found" });
1477
+ return;
1478
+ }
1479
+
1480
+ const { name, description, provider, model } = req.body;
1481
+ const guardModel = req.body?.guardModel ?? req.body?.guard_model;
1482
+ const enableGuard = req.body?.enableGuard ?? req.body?.enable_guard;
1483
+ const isActive = req.body?.isActive ?? req.body?.is_active;
1484
+ const systemPrompt = req.body?.systemPrompt ?? req.body?.system_prompt;
1485
+
1486
+ if (name !== undefined) bot.name = String(name).trim();
1487
+ if (description !== undefined) bot.description = String(description).trim();
1488
+ if (provider !== undefined) bot.provider = String(provider).toLowerCase().trim();
1489
+ if (model !== undefined) bot.model = String(model).trim();
1490
+ if (guardModel !== undefined) bot.guardModel = String(guardModel).trim();
1491
+ if (enableGuard !== undefined) bot.enableGuard = !(enableGuard === false || enableGuard === 0 || String(enableGuard || "").toLowerCase() === "false" || String(enableGuard || "") === "0");
1492
+ if (isActive !== undefined) bot.isActive = !(isActive === false || isActive === 0 || String(isActive || "").toLowerCase() === "false" || String(isActive || "") === "0");
1493
+ if (systemPrompt !== undefined) bot.systemPrompt = String(systemPrompt).trim();
1494
+
1495
+ bot.updatedAt = new Date().toISOString();
1496
+
1497
+ if (isMssqlConfigured()) {
1498
+ await upsertBotInMssql(bot);
1499
+ }
1500
+
1501
+ res.json({
1502
+ id: bot.id,
1503
+ name: bot.name,
1504
+ description: bot.description,
1505
+ provider: bot.provider,
1506
+ model: bot.model,
1507
+ is_active: bot.isActive !== false,
1508
+ updatedAt: bot.updatedAt
1509
+ });
1510
+ } catch (error) {
1511
+ res.status(500).json({ error: error?.message || "Unable to update bot" });
1512
+ }
1513
+ });
1514
+
1515
+ app.delete("/admin/bots/:botId", async (req, res) => {
1516
+ try {
1517
+ const index = botsRegistry.findIndex((bot) => bot.id === req.params.botId);
1518
+ if (index === -1) {
1519
+ res.status(404).json({ error: "Bot not found" });
1520
+ return;
1521
+ }
1522
+
1523
+ const deletedBot = botsRegistry.splice(index, 1)[0];
1524
+ if (isMssqlConfigured()) {
1525
+ await deleteBotInMssql(deletedBot.id);
1526
+ }
1527
+
1528
+ res.json({
1529
+ message: "Bot deleted successfully",
1530
+ id: deletedBot.id
1531
+ });
1532
+ } catch (error) {
1533
+ res.status(500).json({ error: error?.message || "Unable to delete bot" });
1534
+ }
1535
+ });
1536
+
1537
+ function normalizeAdminBotIds(value) {
1538
+ if (!Array.isArray(value)) {
1539
+ return [];
1540
+ }
1541
+ const unique = new Set();
1542
+ for (const item of value) {
1543
+ const id = String(item || "").trim();
1544
+ if (id) {
1545
+ unique.add(id);
1546
+ }
1547
+ }
1548
+ return Array.from(unique);
1549
+ }
1550
+
1551
+ app.get("/admin/users", async (_req, res) => {
1552
+ if (!isMssqlConfigured()) {
1553
+ res.status(400).json({ error: "MSSQL is not configured" });
1554
+ return;
1555
+ }
1556
+
1557
+ try {
1558
+ await ensurePortalUserTables();
1559
+ const pool = await getMssqlPool();
1560
+ if (!pool) {
1561
+ res.status(500).json({ error: "MSSQL pool unavailable" });
1562
+ return;
1563
+ }
1564
+
1565
+ const usersResult = await pool.request().query(`
1566
+ SELECT id, username, is_active, created_at, updated_at
1567
+ FROM dbo.tblUsers
1568
+ ORDER BY username ASC
1569
+ `);
1570
+
1571
+ const mapResult = await pool.request().query(`
1572
+ SELECT user_id, bot_id
1573
+ FROM dbo.tblBotUsers
1574
+ `);
1575
+
1576
+ const botIdsByUser = new Map();
1577
+ for (const row of mapResult.recordset || []) {
1578
+ const userId = Number(row.user_id);
1579
+ const botId = String(row.bot_id || "").trim();
1580
+ if (!userId || !botId) {
1581
+ continue;
1582
+ }
1583
+ if (!botIdsByUser.has(userId)) {
1584
+ botIdsByUser.set(userId, []);
1585
+ }
1586
+ botIdsByUser.get(userId).push(botId);
1587
+ }
1588
+
1589
+ const users = (usersResult.recordset || []).map((user) => ({
1590
+ id: Number(user.id),
1591
+ username: String(user.username || "").trim(),
1592
+ is_active: Number(user.is_active) === 1 || user.is_active === true,
1593
+ bot_ids: botIdsByUser.get(Number(user.id)) || [],
1594
+ created_at: user.created_at,
1595
+ updated_at: user.updated_at
1596
+ }));
1597
+
1598
+ res.json(users);
1599
+ } catch (error) {
1600
+ res.status(500).json({ error: error?.message || "Unable to fetch users" });
1601
+ }
1602
+ });
1603
+
1604
+ app.post("/admin/users", express.json(), async (req, res) => {
1605
+ if (!isMssqlConfigured()) {
1606
+ res.status(400).json({ error: "MSSQL is not configured" });
1607
+ return;
1608
+ }
1609
+
1610
+ const username = String(req.body?.username || "").trim();
1611
+ const password = String(req.body?.password || "").trim();
1612
+ const isActive = !(req.body?.is_active === false || req.body?.is_active === 0 || String(req.body?.is_active || "").toLowerCase() === "false" || String(req.body?.is_active || "") === "0");
1613
+ const botIds = normalizeAdminBotIds(req.body?.bot_ids);
1614
+
1615
+ if (!username) {
1616
+ res.status(400).json({ error: "username is required" });
1617
+ return;
1618
+ }
1619
+ if (!password) {
1620
+ res.status(400).json({ error: "password is required" });
1621
+ return;
1622
+ }
1623
+
1624
+ const unknownBotIds = botIds.filter((id) => !getBotById(id));
1625
+ if (unknownBotIds.length) {
1626
+ res.status(400).json({ error: `Unknown bot id(s): ${unknownBotIds.join(", ")}` });
1627
+ return;
1628
+ }
1629
+
1630
+ try {
1631
+ await ensurePortalUserTables();
1632
+ const pool = await getMssqlPool();
1633
+ if (!pool) {
1634
+ res.status(500).json({ error: "MSSQL pool unavailable" });
1635
+ return;
1636
+ }
1637
+
1638
+ const existing = await pool.request()
1639
+ .input("username", sql.NVarChar(120), username)
1640
+ .query("SELECT TOP 1 id FROM dbo.tblUsers WHERE username = @username");
1641
+ if (existing.recordset?.length) {
1642
+ res.status(409).json({ error: "Username already exists" });
1643
+ return;
1644
+ }
1645
+
1646
+ const tx = new sql.Transaction(pool);
1647
+ await tx.begin();
1648
+ try {
1649
+ const insertResult = await new sql.Request(tx)
1650
+ .input("username", sql.NVarChar(120), username)
1651
+ .input("passwordHash", sql.NVarChar(255), hashPassword(password))
1652
+ .input("isActive", sql.Bit, isActive ? 1 : 0)
1653
+ .query(`
1654
+ INSERT INTO dbo.tblUsers (username, password_hash, is_active, created_at, updated_at)
1655
+ OUTPUT INSERTED.id
1656
+ VALUES (@username, @passwordHash, @isActive, SYSUTCDATETIME(), SYSUTCDATETIME())
1657
+ `);
1658
+
1659
+ const userId = Number(insertResult.recordset?.[0]?.id);
1660
+
1661
+ for (const botId of botIds) {
1662
+ await new sql.Request(tx)
1663
+ .input("userId", sql.Int, userId)
1664
+ .input("botId", sql.NVarChar(120), botId)
1665
+ .query("INSERT INTO dbo.tblBotUsers (bot_id, user_id) VALUES (@botId, @userId)");
1666
+ }
1667
+
1668
+ await tx.commit();
1669
+
1670
+ res.status(201).json({
1671
+ id: userId,
1672
+ username,
1673
+ is_active: isActive,
1674
+ bot_ids: botIds
1675
+ });
1676
+ } catch (innerError) {
1677
+ await tx.rollback();
1678
+ throw innerError;
1679
+ }
1680
+ } catch (error) {
1681
+ res.status(500).json({ error: error?.message || "Unable to create user" });
1682
+ }
1683
+ });
1684
+
1685
+ app.put("/admin/users/:userId", express.json(), async (req, res) => {
1686
+ if (!isMssqlConfigured()) {
1687
+ res.status(400).json({ error: "MSSQL is not configured" });
1688
+ return;
1689
+ }
1690
+
1691
+ const userId = Number(req.params.userId);
1692
+ if (!Number.isInteger(userId) || userId <= 0) {
1693
+ res.status(400).json({ error: "Invalid userId" });
1694
+ return;
1695
+ }
1696
+
1697
+ const username = req.body?.username === undefined ? undefined : String(req.body.username || "").trim();
1698
+ const password = req.body?.password === undefined ? undefined : String(req.body.password || "").trim();
1699
+ const hasIsActive = req.body?.is_active !== undefined;
1700
+ const isActive = !(req.body?.is_active === false || req.body?.is_active === 0 || String(req.body?.is_active || "").toLowerCase() === "false" || String(req.body?.is_active || "") === "0");
1701
+ const hasBotIds = Array.isArray(req.body?.bot_ids);
1702
+ const botIds = hasBotIds ? normalizeAdminBotIds(req.body?.bot_ids) : [];
1703
+
1704
+ if (username !== undefined && !username) {
1705
+ res.status(400).json({ error: "username cannot be empty" });
1706
+ return;
1707
+ }
1708
+ if (password !== undefined && !password) {
1709
+ res.status(400).json({ error: "password cannot be empty" });
1710
+ return;
1711
+ }
1712
+
1713
+ const unknownBotIds = botIds.filter((id) => !getBotById(id));
1714
+ if (unknownBotIds.length) {
1715
+ res.status(400).json({ error: `Unknown bot id(s): ${unknownBotIds.join(", ")}` });
1716
+ return;
1717
+ }
1718
+
1719
+ try {
1720
+ await ensurePortalUserTables();
1721
+ const pool = await getMssqlPool();
1722
+ if (!pool) {
1723
+ res.status(500).json({ error: "MSSQL pool unavailable" });
1724
+ return;
1725
+ }
1726
+
1727
+ const existsResult = await pool.request()
1728
+ .input("userId", sql.Int, userId)
1729
+ .query("SELECT TOP 1 id FROM dbo.tblUsers WHERE id = @userId");
1730
+ if (!existsResult.recordset?.length) {
1731
+ res.status(404).json({ error: "User not found" });
1732
+ return;
1733
+ }
1734
+
1735
+ if (username !== undefined) {
1736
+ const duplicate = await pool.request()
1737
+ .input("username", sql.NVarChar(120), username)
1738
+ .input("userId", sql.Int, userId)
1739
+ .query("SELECT TOP 1 id FROM dbo.tblUsers WHERE username = @username AND id <> @userId");
1740
+ if (duplicate.recordset?.length) {
1741
+ res.status(409).json({ error: "Username already exists" });
1742
+ return;
1743
+ }
1744
+ }
1745
+
1746
+ const tx = new sql.Transaction(pool);
1747
+ await tx.begin();
1748
+ try {
1749
+ const updates = [];
1750
+ const updateReq = new sql.Request(tx).input("userId", sql.Int, userId);
1751
+
1752
+ if (username !== undefined) {
1753
+ updates.push("username = @username");
1754
+ updateReq.input("username", sql.NVarChar(120), username);
1755
+ }
1756
+ if (password !== undefined) {
1757
+ updates.push("password_hash = @passwordHash");
1758
+ updateReq.input("passwordHash", sql.NVarChar(255), hashPassword(password));
1759
+ }
1760
+ if (hasIsActive) {
1761
+ updates.push("is_active = @isActive");
1762
+ updateReq.input("isActive", sql.Bit, isActive ? 1 : 0);
1763
+ }
1764
+
1765
+ updates.push("updated_at = SYSUTCDATETIME()");
1766
+
1767
+ await updateReq.query(`
1768
+ UPDATE dbo.tblUsers
1769
+ SET ${updates.join(", ")}
1770
+ WHERE id = @userId
1771
+ `);
1772
+
1773
+ if (hasBotIds) {
1774
+ await new sql.Request(tx)
1775
+ .input("userId", sql.Int, userId)
1776
+ .query("DELETE FROM dbo.tblBotUsers WHERE user_id = @userId");
1777
+
1778
+ for (const botId of botIds) {
1779
+ await new sql.Request(tx)
1780
+ .input("userId", sql.Int, userId)
1781
+ .input("botId", sql.NVarChar(120), botId)
1782
+ .query("INSERT INTO dbo.tblBotUsers (bot_id, user_id) VALUES (@botId, @userId)");
1783
+ }
1784
+ }
1785
+
1786
+ await tx.commit();
1787
+ } catch (innerError) {
1788
+ await tx.rollback();
1789
+ throw innerError;
1790
+ }
1791
+
1792
+ const mapResult = await pool.request()
1793
+ .input("userId", sql.Int, userId)
1794
+ .query("SELECT bot_id FROM dbo.tblBotUsers WHERE user_id = @userId ORDER BY bot_id ASC");
1795
+
1796
+ const userResult = await pool.request()
1797
+ .input("userId", sql.Int, userId)
1798
+ .query("SELECT id, username, is_active FROM dbo.tblUsers WHERE id = @userId");
1799
+ const user = userResult.recordset?.[0];
1800
+
1801
+ res.json({
1802
+ id: Number(user.id),
1803
+ username: String(user.username || "").trim(),
1804
+ is_active: Number(user.is_active) === 1 || user.is_active === true,
1805
+ bot_ids: (mapResult.recordset || []).map((row) => String(row.bot_id || "").trim()).filter(Boolean)
1806
+ });
1807
+ } catch (error) {
1808
+ res.status(500).json({ error: error?.message || "Unable to update user" });
1809
+ }
1810
+ });
1811
+
1812
+ app.delete("/admin/users/:userId", async (req, res) => {
1813
+ if (!isMssqlConfigured()) {
1814
+ res.status(400).json({ error: "MSSQL is not configured" });
1815
+ return;
1816
+ }
1817
+
1818
+ const userId = Number(req.params.userId);
1819
+ if (!Number.isInteger(userId) || userId <= 0) {
1820
+ res.status(400).json({ error: "Invalid userId" });
1821
+ return;
1822
+ }
1823
+
1824
+ try {
1825
+ await ensurePortalUserTables();
1826
+ const pool = await getMssqlPool();
1827
+ if (!pool) {
1828
+ res.status(500).json({ error: "MSSQL pool unavailable" });
1829
+ return;
1830
+ }
1831
+
1832
+ const existing = await pool.request()
1833
+ .input("userId", sql.Int, userId)
1834
+ .query("SELECT TOP 1 id, username FROM dbo.tblUsers WHERE id = @userId");
1835
+ if (!existing.recordset?.length) {
1836
+ res.status(404).json({ error: "User not found" });
1837
+ return;
1838
+ }
1839
+
1840
+ await pool.request()
1841
+ .input("userId", sql.Int, userId)
1842
+ .query("DELETE FROM dbo.tblUsers WHERE id = @userId");
1843
+
1844
+ res.json({
1845
+ ok: true,
1846
+ id: userId
1847
+ });
1848
+ } catch (error) {
1849
+ res.status(500).json({ error: error?.message || "Unable to delete user" });
1850
+ }
1851
+ });
1852
+
1853
+ app.get("/portal/api/health", async (_req, res) => {
1854
+ if (!isMssqlConfigured()) {
1855
+ res.status(400).json({
1856
+ ok: false,
1857
+ error: "MSSQL is not configured. Set MSSQL_ENABLED=true and MSSQL_* env vars."
1858
+ });
1859
+ return;
1860
+ }
1861
+
1862
+ try {
1863
+ await ensureMssqlKnowledgeTable();
1864
+ res.json({ ok: true, storage: "mssql" });
1865
+ } catch (error) {
1866
+ res.status(500).json({ ok: false, error: error?.message || "MSSQL connection failed" });
1867
+ }
1868
+ });
1869
+
1870
+ app.get("/portal/api/knowledge", async (req, res) => {
1871
+ try {
1872
+ const records = await getKnowledgeFromMssql({
1873
+ botId: req.query?.botId || ""
1874
+ });
1875
+ res.json({ items: records });
1876
+ } catch (error) {
1877
+ res.status(500).json({ error: error?.message || "Unable to fetch knowledge from MSSQL" });
1878
+ }
1879
+ });
1880
+
1881
+ app.post("/portal/api/knowledge", async (req, res) => {
1882
+ const botId = String(req.body?.botId || "").trim();
1883
+ const content = String(req.body?.content || req.body?.answer || "").trim();
1884
+
1885
+ if (!botId) {
1886
+ res.status(400).json({ error: "botId is required" });
1887
+ return;
1888
+ }
1889
+ if (!content) {
1890
+ res.status(400).json({ error: "content is required" });
1891
+ return;
1892
+ }
1893
+
1894
+ try {
1895
+ await ensureMssqlKnowledgeTable();
1896
+ const pool = await getMssqlPool();
1897
+ if (!pool) {
1898
+ res.status(500).json({ error: "MSSQL is not configured" });
1899
+ return;
1900
+ }
1901
+
1902
+ const tags = normalizeTags(req.body?.tags);
1903
+ const faqQuestion = String(req.body?.question || req.body?.keyword || req.body?.title || "").trim();
1904
+ const insertResult = await pool.request()
1905
+ .input("botId", sql.NVarChar(120), botId)
1906
+ .input("keyword", sql.NVarChar(255), faqQuestion || null)
1907
+ .input("title", sql.NVarChar(255), faqQuestion || null)
1908
+ .input("content", sql.NVarChar(sql.MAX), content)
1909
+ .input("tagsJson", sql.NVarChar(sql.MAX), JSON.stringify(tags))
1910
+ .input("source", sql.NVarChar(80), String(req.body?.source || "portal").trim() || "portal")
1911
+ .query(`
1912
+ INSERT INTO dbo.tblKnowledge (bot_id, keyword, title, content, tags_json, source, is_active, updated_at)
1913
+ OUTPUT INSERTED.*
1914
+ VALUES (@botId, @keyword, @title, @content, @tagsJson, @source, 1, SYSUTCDATETIME())
1915
+ `);
1916
+
1917
+ const created = mapDbKnowledgeRow(insertResult.recordset[0]);
1918
+ res.status(201).json(created);
1919
+ } catch (error) {
1920
+ res.status(500).json({ error: error?.message || "Unable to create knowledge record" });
1921
+ }
1922
+ });
1923
+
1924
+ app.post("/portal/api/knowledge/import", async (req, res) => {
1925
+ const botId = String(req.body?.botId || "").trim();
1926
+ const source = String(req.body?.source || "portal-upload").trim() || "portal-upload";
1927
+ const items = Array.isArray(req.body?.items) ? req.body.items : [];
1928
+
1929
+ if (!botId) {
1930
+ res.status(400).json({ error: "botId is required" });
1931
+ return;
1932
+ }
1933
+
1934
+ if (!items.length) {
1935
+ res.status(400).json({ error: "items[] is required for import" });
1936
+ return;
1937
+ }
1938
+
1939
+ try {
1940
+ await ensureMssqlKnowledgeTable();
1941
+ const pool = await getMssqlPool();
1942
+ if (!pool) {
1943
+ res.status(500).json({ error: "MSSQL is not configured" });
1944
+ return;
1945
+ }
1946
+
1947
+ const inserted = [];
1948
+ let skipped = 0;
1949
+
1950
+ for (const item of items) {
1951
+ const faqQuestion = String(item?.question || item?.keyword || item?.title || "").trim();
1952
+ const answer = String(item?.answer || item?.content || "").trim();
1953
+ const tags = normalizeTags(item?.tags);
1954
+
1955
+ if (!answer) {
1956
+ skipped += 1;
1957
+ continue;
1958
+ }
1959
+
1960
+ const result = await pool.request()
1961
+ .input("botId", sql.NVarChar(120), botId)
1962
+ .input("keyword", sql.NVarChar(255), faqQuestion || null)
1963
+ .input("title", sql.NVarChar(255), faqQuestion || null)
1964
+ .input("content", sql.NVarChar(sql.MAX), answer)
1965
+ .input("tagsJson", sql.NVarChar(sql.MAX), JSON.stringify(tags))
1966
+ .input("source", sql.NVarChar(80), source)
1967
+ .query(`
1968
+ INSERT INTO dbo.tblKnowledge (bot_id, keyword, title, content, tags_json, source, is_active, updated_at)
1969
+ OUTPUT INSERTED.*
1970
+ VALUES (@botId, @keyword, @title, @content, @tagsJson, @source, 1, SYSUTCDATETIME())
1971
+ `);
1972
+
1973
+ if (result.recordset.length) {
1974
+ inserted.push(mapDbKnowledgeRow(result.recordset[0]));
1975
+ }
1976
+ }
1977
+
1978
+ res.status(201).json({
1979
+ ok: true,
1980
+ importedCount: inserted.length,
1981
+ skippedCount: skipped,
1982
+ items: inserted
1983
+ });
1984
+ } catch (error) {
1985
+ res.status(500).json({ error: error?.message || "Unable to import knowledge records" });
1986
+ }
1987
+ });
1988
+
1989
+ app.put("/portal/api/knowledge/:id", async (req, res) => {
1990
+ const id = Number(req.params.id);
1991
+ if (!Number.isFinite(id) || id <= 0) {
1992
+ res.status(400).json({ error: "Invalid knowledge id" });
1993
+ return;
1994
+ }
1995
+
1996
+ try {
1997
+ await ensureMssqlKnowledgeTable();
1998
+ const pool = await getMssqlPool();
1999
+ if (!pool) {
2000
+ res.status(500).json({ error: "MSSQL is not configured" });
2001
+ return;
2002
+ }
2003
+
2004
+ const content = String(req.body?.content || req.body?.answer || "").trim();
2005
+ if (!content) {
2006
+ res.status(400).json({ error: "content is required" });
2007
+ return;
2008
+ }
2009
+
2010
+ const tags = normalizeTags(req.body?.tags);
2011
+ const faqQuestion = String(req.body?.question || req.body?.keyword || req.body?.title || "").trim();
2012
+ const result = await pool.request()
2013
+ .input("id", sql.Int, id)
2014
+ .input("keyword", sql.NVarChar(255), faqQuestion || null)
2015
+ .input("title", sql.NVarChar(255), faqQuestion || null)
2016
+ .input("content", sql.NVarChar(sql.MAX), content)
2017
+ .input("tagsJson", sql.NVarChar(sql.MAX), JSON.stringify(tags))
2018
+ .input("source", sql.NVarChar(80), String(req.body?.source || "portal").trim() || "portal")
2019
+ .query(`
2020
+ UPDATE dbo.tblKnowledge
2021
+ SET keyword = @keyword,
2022
+ title = @title,
2023
+ content = @content,
2024
+ tags_json = @tagsJson,
2025
+ source = @source,
2026
+ updated_at = SYSUTCDATETIME()
2027
+ OUTPUT INSERTED.*
2028
+ WHERE id = @id
2029
+ `);
2030
+
2031
+ if (!result.recordset.length) {
2032
+ res.status(404).json({ error: "Knowledge record not found" });
2033
+ return;
2034
+ }
2035
+
2036
+ res.json(mapDbKnowledgeRow(result.recordset[0]));
2037
+ } catch (error) {
2038
+ res.status(500).json({ error: error?.message || "Unable to update knowledge record" });
2039
+ }
2040
+ });
2041
+
2042
+ app.delete("/portal/api/knowledge/:id", async (req, res) => {
2043
+ const id = Number(req.params.id);
2044
+ if (!Number.isFinite(id) || id <= 0) {
2045
+ res.status(400).json({ error: "Invalid knowledge id" });
2046
+ return;
2047
+ }
2048
+
2049
+ try {
2050
+ await ensureMssqlKnowledgeTable();
2051
+ const pool = await getMssqlPool();
2052
+ if (!pool) {
2053
+ res.status(500).json({ error: "MSSQL is not configured" });
2054
+ return;
2055
+ }
2056
+
2057
+ const result = await pool.request()
2058
+ .input("id", sql.Int, id)
2059
+ .query(`
2060
+ UPDATE dbo.tblKnowledge
2061
+ SET is_active = 0,
2062
+ updated_at = SYSUTCDATETIME()
2063
+ OUTPUT INSERTED.id
2064
+ WHERE id = @id
2065
+ `);
2066
+
2067
+ if (!result.recordset.length) {
2068
+ res.status(404).json({ error: "Knowledge record not found" });
2069
+ return;
2070
+ }
2071
+
2072
+ res.json({ ok: true, id });
2073
+ } catch (error) {
2074
+ res.status(500).json({ error: error?.message || "Unable to delete knowledge record" });
2075
+ }
2076
+ });
2077
+
2078
+ app.get("/portal/api/export/knowledge", async (req, res) => {
2079
+ try {
2080
+ const records = await getKnowledgeFromMssql({
2081
+ botId: req.query?.botId || ""
2082
+ });
2083
+
2084
+ res.json({
2085
+ knowledge: records.map((entry) => ({
2086
+ question: entry.keyword,
2087
+ keyword: entry.keyword,
2088
+ answer: entry.answer,
2089
+ tags: entry.tags,
2090
+ botId: entry.botId,
2091
+ source: entry.source,
2092
+ updatedAt: entry.updatedAt
2093
+ }))
2094
+ });
2095
+ } catch (error) {
2096
+ res.status(500).json({ error: error?.message || "Unable to export knowledge" });
2097
+ }
2098
+ });
2099
+
2100
+ app.get("/knowledge.json", async (req, res) => {
2101
+ const botId = String(req.query?.botId || "").trim();
2102
+ if (!botId) {
2103
+ res.json({ knowledge: [] });
2104
+ return;
2105
+ }
2106
+
2107
+ const bot = botId ? getBotById(botId) : null;
2108
+ const botKnowledge = Array.isArray(bot?.knowledge) ? bot.knowledge : [];
2109
+
2110
+ let mssqlKnowledge = [];
2111
+ try {
2112
+ mssqlKnowledge = await getKnowledgeFromMssql({
2113
+ botId
2114
+ });
2115
+ } catch (error) {
2116
+ console.warn("MSSQL knowledge fetch failed:", error?.message || error);
2117
+ }
2118
+
2119
+ const mergedKnowledge = [
2120
+ ...botKnowledge,
2121
+ ...mssqlKnowledge
2122
+ ];
2123
+
2124
+ // Bot-only FAQ response shape for client sync.
2125
+ res.json({
2126
+ knowledge: mergedKnowledge.map((entry) => ({
2127
+ question: String(entry.title || entry.question || entry.keyword || entry.label || "Knowledge").trim() || "Knowledge",
2128
+ keyword: String(entry.title || entry.question || entry.label || "Knowledge").trim() || "Knowledge",
2129
+ answer: String(entry.content || entry.answer || "").trim(),
2130
+ tags: Array.isArray(entry.tags) ? entry.tags : []
2131
+ }))
2132
+ });
2133
+ });
2134
+
2135
+ app.post("/api/chat/stream", async (req, res) => {
2136
+ if (!enforceRequestOrigin(req, res)) {
2137
+ return;
2138
+ }
2139
+
2140
+ // Bot-based authentication (required)
2141
+ const { botId, apiKey } = extractBotIdAndApiKey(req);
2142
+ if (!botId || !apiKey) {
2143
+ res.status(400).json({ error: "botId and apiKey are required" });
2144
+ return;
2145
+ }
2146
+
2147
+ const validation = validateBotApiKey(botId, apiKey);
2148
+ if (!validation.valid) {
2149
+ res.status(401).json({ error: validation.error });
2150
+ return;
2151
+ }
2152
+
2153
+ const botConfig = validation.bot;
2154
+
2155
+ // Rate limiting
2156
+ if (!enforceRateLimit(req, res)) {
2157
+ return;
2158
+ }
2159
+
2160
+ const incomingMessages = normalizeMessages(req.body?.messages);
2161
+ if (!incomingMessages.length) {
2162
+ res.status(400).json({ error: "messages[] is required" });
2163
+ return;
2164
+ }
2165
+
2166
+ const latestUserMessage = getLatestUserMessage(incomingMessages);
2167
+ const siteConfig = req.body?.siteConfig && typeof req.body.siteConfig === "object"
2168
+ ? req.body.siteConfig
2169
+ : {};
2170
+ const sessionId = String(req.body?.sessionId || req.headers["x-chat-session-id"] || "").trim();
2171
+ const requestStartedAt = new Date();
2172
+ let historyRequestId = null;
2173
+ let assistantResponseText = "";
2174
+
2175
+ const provider = botConfig.provider;
2176
+ const providerConfigBase = providerConfigs[provider];
2177
+ const preliminaryModel = botConfig.model || normalizeModelForProvider(provider, providerConfigBase?.model);
2178
+
2179
+ historyRequestId = await createChatHistoryEntry({
2180
+ sessionId,
2181
+ botId,
2182
+ provider,
2183
+ model: preliminaryModel,
2184
+ siteConfig,
2185
+ messages: incomingMessages,
2186
+ latestUserMessage,
2187
+ clientIp: getRequestClientIp(req),
2188
+ userAgent: String(req.headers["user-agent"] || "").trim(),
2189
+ startedAt: requestStartedAt,
2190
+ responseStatus: "started"
2191
+ });
2192
+
2193
+ const requestAiApiKey = extractAiApiKeyFromRequest(req);
2194
+ const providerConfig = providerConfigBase
2195
+ ? {
2196
+ ...providerConfigBase,
2197
+ apiKey: providerConfigBase.apiKey || requestAiApiKey
2198
+ }
2199
+ : null;
2200
+
2201
+ if (!providerConfig || !providerConfig.apiKey) {
2202
+ await finalizeChatHistoryEntry(historyRequestId, {
2203
+ responseStatus: "error",
2204
+ errorMessage: `Missing API key for provider: ${provider}`
2205
+ });
2206
+ res.status(500).json({
2207
+ error: `Missing API key for provider: ${provider}. Set ${provider.toUpperCase()}_API_KEY in .env or pass data-ai-apikey in embed script.`
2208
+ });
2209
+ return;
2210
+ }
2211
+
2212
+ // Groq prompt guard
2213
+ if (provider === "groq" && enableGroqPromptGuard) {
2214
+ try {
2215
+ const guardResult = await runGroqPromptGuard({
2216
+ config: providerConfig,
2217
+ latestUserMessage
2218
+ });
2219
+
2220
+ if (!guardResult.allowed) {
2221
+ await finalizeChatHistoryEntry(historyRequestId, {
2222
+ responseStatus: "blocked",
2223
+ assistantResponse: guardResult.reason,
2224
+ errorMessage: guardResult.reason
2225
+ });
2226
+ res.status(400).json({ error: guardResult.reason });
2227
+ return;
2228
+ }
2229
+ } catch (error) {
2230
+ console.warn("Prompt guard request failed:", error?.message || error);
2231
+ }
2232
+ }
2233
+
2234
+ // Load bot knowledge from MSSQL
2235
+ let botKnowledge = [];
2236
+ try {
2237
+ botKnowledge = await getKnowledgeFromMssql({ botId });
2238
+ } catch (error) {
2239
+ console.warn("Failed to load bot knowledge:", error?.message || error);
2240
+ }
2241
+
2242
+ // Build knowledge context
2243
+ const knowledgeContext = buildKnowledgeContext(botKnowledge, latestUserMessage);
2244
+ const systemPrompt = buildSystemPrompt(botConfig.systemPrompt, knowledgeContext);
2245
+
2246
+ const requestedModel = botConfig.model || normalizeModelForProvider(provider, providerConfig.model);
2247
+
2248
+ res.setHeader("Content-Type", "text/event-stream");
2249
+ res.setHeader("Cache-Control", "no-cache, no-transform");
2250
+ res.setHeader("Connection", "keep-alive");
2251
+ res.flushHeaders?.();
2252
+
2253
+ let closed = false;
2254
+ req.on("close", () => {
2255
+ closed = true;
2256
+ });
2257
+
2258
+ try {
2259
+ if (provider === "claude") {
2260
+ await streamClaudeResponse({
2261
+ config: providerConfig,
2262
+ model: requestedModel,
2263
+ systemPrompt,
2264
+ messages: incomingMessages,
2265
+ onToken(token) {
2266
+ if (!closed) {
2267
+ assistantResponseText += token;
2268
+ res.write(`event: token\ndata: ${JSON.stringify({ token })}\n\n`);
2269
+ }
2270
+ },
2271
+ onError(message) {
2272
+ if (!closed) {
2273
+ res.write(`event: error\ndata: ${JSON.stringify({ error: message })}\n\n`);
2274
+ }
2275
+ }
2276
+ });
2277
+ } else {
2278
+ await streamOpenAiCompatibleResponse({
2279
+ config: providerConfig,
2280
+ model: requestedModel,
2281
+ systemPrompt,
2282
+ messages: incomingMessages,
2283
+ onToken(token) {
2284
+ if (!closed) {
2285
+ assistantResponseText += token;
2286
+ res.write(`event: token\ndata: ${JSON.stringify({ token })}\n\n`);
2287
+ }
2288
+ }
2289
+ });
2290
+ }
2291
+
2292
+ if (!closed) {
2293
+ res.write("event: done\ndata: {}\n\n");
2294
+ res.end();
2295
+ }
2296
+
2297
+ await finalizeChatHistoryEntry(historyRequestId, {
2298
+ assistantResponse: assistantResponseText,
2299
+ responseStatus: "completed",
2300
+ knowledgeContext,
2301
+ systemPrompt
2302
+ });
2303
+ } catch (error) {
2304
+ await finalizeChatHistoryEntry(historyRequestId, {
2305
+ assistantResponse: assistantResponseText,
2306
+ responseStatus: "error",
2307
+ errorMessage: error?.message || "Unexpected proxy error",
2308
+ knowledgeContext,
2309
+ systemPrompt
2310
+ });
2311
+
2312
+ if (!closed) {
2313
+ sendSseError(res, error?.message || "Unexpected proxy error");
2314
+ }
2315
+ }
2316
+ });
2317
+
2318
+ async function startServer() {
2319
+ try {
2320
+ await initializeBotsRegistry();
2321
+ app.listen(port, () => {
2322
+ console.log(`Chatbot proxy listening on http://localhost:${port}`);
2323
+ console.log(`Embed URL: http://localhost:${port}/embed/chatbot.js`);
2324
+ });
2325
+ } catch (error) {
2326
+ console.error("Failed to initialize server:", error?.message || error);
2327
+ process.exit(1);
2328
+ }
2329
+ }
2330
+
2331
+ startServer();