forked from Chatbot/v3-api
Compare commits
16 Commits
0234abd082
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6682b476ef | |||
| 73806f91b2 | |||
| a4815b37b3 | |||
| a91b3aeabc | |||
| 1378dcdd11 | |||
| cbd0cce504 | |||
| cec72740ab | |||
| 578384d64e | |||
| f87d1a384e | |||
| 7302951a25 | |||
| 52c0994032 | |||
| 2ed30d401f | |||
| d46fe40a7e | |||
| e61d2b7ec0 | |||
| 49e67b3a82 | |||
| 0abc8f4988 |
1
docker/test.json
Normal file
1
docker/test.json
Normal file
@@ -0,0 +1 @@
|
||||
{"metadata":{"page":1,"per":10,"total":2},"items":[{"isAdmin":false,"id":"A1BFC6D5-04B7-4623-BA03-D597122FDB22","name":"chatbot-v2-dev","username":"chatbot-dev"},{"isAdmin":true,"id":"FD5A2BAB-CC19-4717-94BD-76E9F66249AF","name":"Admin User","username":"alarik"}]}⏎
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
13304
package-lock.json
generated
Normal file
13304
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
121
package.json
Normal file
121
package.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "chatbot-v2-api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch --host 0.0.0.0",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"start:init": "ts-node src/cli.ts ask"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fraybabak/kavenegar_nest": "^1.0.5",
|
||||
"@nestjs-modules/ioredis": "^2.0.2",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/class-validator": "^0.13.4",
|
||||
"@nestjs/common": "^11.0.5",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.5",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/mongoose": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.3",
|
||||
"@nestjs/platform-socket.io": "^11.0.5",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/websockets": "^11.0.7",
|
||||
"@types/form-data": "^2.2.1",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"commander": "^13.1.0",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-parser": "^3.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-csv": "^5.0.2",
|
||||
"form-data": "^4.0.4",
|
||||
"inquirer": "^12.3.2",
|
||||
"ioredis": "^5.6.1",
|
||||
"kavenegar": "^1.1.4",
|
||||
"moment-jalaali": "^0.10.4",
|
||||
"mongodb": "^6.18.0",
|
||||
"mongoose": "8.9.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"npm-check-updates": "^17.1.14",
|
||||
"papaparse": "^5.5.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"react": "^19.1.0",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"tunnel-ssh": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.5",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/exceljs": "^0.5.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/kavenegar": "^1.1.3",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.10",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,9 @@ export class AclService implements OnModuleInit {
|
||||
throw new BadRequestException('Cannot delete a system role');
|
||||
}
|
||||
|
||||
const usersCount = await this.adminModel.countDocuments({ role: roleName });
|
||||
const usersCount = await this.adminModel.countDocuments({
|
||||
role: roleName as Role,
|
||||
});
|
||||
if (usersCount > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete role while ${usersCount} user(s) are assigned; reassign them first`,
|
||||
@@ -378,12 +380,16 @@ export class AclService implements OnModuleInit {
|
||||
mobile: dto.mobile,
|
||||
name: dto.name,
|
||||
family: dto.family,
|
||||
role: dto.role,
|
||||
role: dto.role as Role,
|
||||
isActive: true,
|
||||
permissionGrants: [],
|
||||
permissionDenies: [],
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
throw new BadRequestException('staff_create_failed');
|
||||
}
|
||||
|
||||
const { password, ...safe } = created.toObject();
|
||||
|
||||
await this.auditLogService.logHttpRequest('acl.staff_created', req || {}, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Sender } from '../common/types/sender.type';
|
||||
import {
|
||||
buildBotMessage,
|
||||
isEscalationOffered,
|
||||
normalizeSuggestedQuestions,
|
||||
resolveAskText,
|
||||
resolveSessionId,
|
||||
toAskCompatiblePayload,
|
||||
@@ -37,6 +39,7 @@ describe('ai-v2 ask mapper', () => {
|
||||
messageId: '64f1a2b3c4d5e6f7a8b9c0d2',
|
||||
runId: 'bea53a8d-3745-420f-a50d-fb700de5611d',
|
||||
status: 'escalation_offered',
|
||||
suggested_questions: ['الف', ' الف ', '', null, 'ب'] as any,
|
||||
escalation: { summary: 'not in docs', handoff_context: {} },
|
||||
isNewSession: true,
|
||||
});
|
||||
@@ -44,5 +47,29 @@ describe('ai-v2 ask mapper', () => {
|
||||
expect(payload.messageId).toBe('64f1a2b3c4d5e6f7a8b9c0d2');
|
||||
expect(payload.runId).toBe('bea53a8d-3745-420f-a50d-fb700de5611d');
|
||||
expect(payload.offerOnlineChat).toBe(true);
|
||||
expect(payload.suggested_questions).toEqual(['الف', 'ب']);
|
||||
});
|
||||
|
||||
it('normalizes missing suggested_questions to an empty array', () => {
|
||||
expect(normalizeSuggestedQuestions(undefined)).toEqual([]);
|
||||
expect(normalizeSuggestedQuestions(null)).toEqual([]);
|
||||
expect(normalizeSuggestedQuestions([])).toEqual([]);
|
||||
expect(normalizeSuggestedQuestions('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores suggested_questions on the bot message only when non-empty', () => {
|
||||
const withQuestions = buildBotMessage({
|
||||
text: 'پاسخ',
|
||||
now: Date.now() / 1000,
|
||||
suggested_questions: [' الف ', 'الف', 'ب'],
|
||||
});
|
||||
expect(withQuestions.suggested_questions).toEqual(['الف', 'ب']);
|
||||
|
||||
const without = buildBotMessage({
|
||||
text: 'پاسخ',
|
||||
now: Date.now() / 1000,
|
||||
suggested_questions: [],
|
||||
});
|
||||
expect(without.suggested_questions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AskCompatiblePayload = {
|
||||
runId: string | null;
|
||||
status: 'answered' | 'escalation_offered' | 'ai_unavailable';
|
||||
offerOnlineChat: boolean;
|
||||
suggested_questions: string[];
|
||||
escalation: AiV2RunResult['escalation'];
|
||||
};
|
||||
|
||||
@@ -33,6 +34,20 @@ export function resolveSessionId(sessionId?: string): string | undefined {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function normalizeSuggestedQuestions(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const text = item.trim();
|
||||
if (!text || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isEscalationOffered(result: Pick<AiV2RunResult, 'status'>): boolean {
|
||||
return result.status === 'escalation_offered';
|
||||
}
|
||||
@@ -53,8 +68,12 @@ export function buildBotMessage(params: {
|
||||
now: number;
|
||||
runId?: string | null;
|
||||
status?: AskCompatiblePayload['status'];
|
||||
suggested_questions?: string[];
|
||||
escalation?: AiV2RunResult['escalation'];
|
||||
}) {
|
||||
const suggested_questions = normalizeSuggestedQuestions(
|
||||
params.suggested_questions,
|
||||
);
|
||||
return {
|
||||
messageId: new Types.ObjectId(),
|
||||
text: params.text,
|
||||
@@ -64,6 +83,8 @@ export function buildBotMessage(params: {
|
||||
createdAt: TimeHelper.unix2PersianTimeAndDate(params.now),
|
||||
createdISO: Date.now(),
|
||||
aiStatus: params.status,
|
||||
suggested_questions:
|
||||
suggested_questions.length > 0 ? suggested_questions : undefined,
|
||||
escalation: params.escalation || undefined,
|
||||
};
|
||||
}
|
||||
@@ -80,6 +101,7 @@ export function toAskCompatiblePayload(params: {
|
||||
messageId: string;
|
||||
runId?: string | null;
|
||||
status: AskCompatiblePayload['status'];
|
||||
suggested_questions?: unknown;
|
||||
escalation?: AiV2RunResult['escalation'];
|
||||
isNewSession: boolean;
|
||||
}): AskCompatiblePayload {
|
||||
@@ -100,6 +122,7 @@ export function toAskCompatiblePayload(params: {
|
||||
runId: params.runId ?? null,
|
||||
status: params.status,
|
||||
offerOnlineChat,
|
||||
suggested_questions: normalizeSuggestedQuestions(params.suggested_questions),
|
||||
escalation: params.escalation ?? null,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { Request, Response } from 'express';
|
||||
import { CurrentIdentity } from 'src/common/decorators/Identity.decorator';
|
||||
import { AiV2Service } from './ai-v2.service';
|
||||
import { CreateFeedbackDto, CreateRunDto } from './dto/threads.dto';
|
||||
import { AskRunDataDto, CreateFeedbackDto, CreateRunDto } from './dto/threads.dto';
|
||||
|
||||
@ApiTags('v2 AI')
|
||||
@ApiBearerAuth()
|
||||
@@ -24,13 +24,16 @@ export class AiV2ThreadsController {
|
||||
description:
|
||||
'Body: `{ question, sessionId? }`. Empty/omitted sessionId starts a new session. ' +
|
||||
'We generate our own messageId and store the AI run_id on the bot message. ' +
|
||||
'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`.',
|
||||
'When AI_V2_STREAM=true, emits SSE `meta` → `token*` → `result`. ' +
|
||||
'`suggested_questions` is on the JSON/SSE result payload and on the stored Bot message.',
|
||||
})
|
||||
@ApiBody({ type: CreateRunDto })
|
||||
@ApiProduces('application/json', 'text/event-stream')
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Ask-compatible JSON, or SSE when AI_V2_STREAM=true.',
|
||||
description:
|
||||
'Ask-compatible JSON (`data` includes `suggested_questions`), or SSE when AI_V2_STREAM=true. In stream mode the same object is the `event: result` data (after tokens).',
|
||||
type: AskRunDataDto,
|
||||
})
|
||||
async createRun(
|
||||
@Body() body: CreateRunDto,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AiV2RunResult = {
|
||||
run_id: string;
|
||||
status: 'answered' | 'escalation_offered';
|
||||
message: string;
|
||||
suggested_questions?: string[] | null;
|
||||
escalation: { summary?: string; handoff_context?: Record<string, unknown> } | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export class AiV2Service {
|
||||
now,
|
||||
runId,
|
||||
status,
|
||||
suggested_questions: result?.suggested_questions,
|
||||
escalation: result?.escalation,
|
||||
});
|
||||
await this.session.updateOne(
|
||||
@@ -146,6 +147,7 @@ export class AiV2Service {
|
||||
messageId: String(botMessage.messageId),
|
||||
runId,
|
||||
status,
|
||||
suggested_questions: botMessage.suggested_questions,
|
||||
escalation: result?.escalation,
|
||||
isNewSession,
|
||||
});
|
||||
|
||||
@@ -57,3 +57,45 @@ export class CreateFeedbackDto {
|
||||
@MaxLength(2000)
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export class AskRunDataDto {
|
||||
@ApiProperty({ example: '64f1a2b3c4d5e6f7a8b9c0d1' })
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({ example: '1/20' })
|
||||
count: string;
|
||||
|
||||
@ApiProperty()
|
||||
question: string;
|
||||
|
||||
@ApiProperty({ description: 'Full assistant markdown. Always render this.' })
|
||||
answer: string;
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
history: unknown[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Our Mongo ObjectId for the new bot message. Use for feedback.',
|
||||
})
|
||||
messageId: string;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Upstream AI run id. Ignore in UI.' })
|
||||
runId: string | null;
|
||||
|
||||
@ApiProperty({ enum: ['answered', 'escalation_offered', 'ai_unavailable'] })
|
||||
status: 'answered' | 'escalation_offered' | 'ai_unavailable';
|
||||
|
||||
@ApiProperty()
|
||||
offerOnlineChat: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['چگونه وارد پورتال درمانت شوم؟', 'چطور درخواست معرفینامه ثبت کنم؟'],
|
||||
description:
|
||||
'Clickable follow-up questions for this assistant turn. Always an array; may be empty. Also stored on the Bot message in history as suggested_questions.',
|
||||
})
|
||||
suggested_questions: string[];
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
escalation: { summary?: string; handoff_context?: Record<string, unknown> } | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpException, HttpStatus, Injectable, Optional, Inject } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import type { FilterQuery } from 'mongoose';
|
||||
import { BaseResponseDTO } from 'src/common/dto/base-response.dto';
|
||||
import { Role } from 'src/common/types/role.type';
|
||||
import { AdminDocument, AdminModel } from 'src/database/model/admin.model';
|
||||
@@ -151,7 +152,7 @@ export class AdminService {
|
||||
mobile: body.mobile,
|
||||
name: body.name,
|
||||
family: body.family,
|
||||
role: 'expert',
|
||||
role: Role.Expert,
|
||||
username: body.email, // Set username to email
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import type { FilterQuery, UpdateQuery } from 'mongoose';
|
||||
import { BaseResponseDTO } from 'src/common/dto/base-response.dto';
|
||||
import { TimeHelper } from 'src/common/tools/time-helper';
|
||||
import { Sender } from 'src/common/types/sender.type';
|
||||
|
||||
@@ -71,6 +71,9 @@ export class AuthController {
|
||||
} else if(req.client && req.client.enName === 'Asia-Insurance'){
|
||||
console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`);
|
||||
return this.authService.loginAsia(body);
|
||||
}else if(req.client && req.client.enName === 'Darmanet'){
|
||||
console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`);
|
||||
return this.authService.loginV4(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +101,9 @@ export class AuthController {
|
||||
}else if(req.client && req.client.enName === 'Asia-Insurance'){
|
||||
console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`);
|
||||
return this.authService.userVerify(body);
|
||||
}else if(req.client && req.client.enName === 'Darmanet'){
|
||||
console.log(`Request from client: ${req.client.enName}, Origin: ${origin}`);
|
||||
return this.authService.userVerify(body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -943,7 +943,7 @@ const url = `https://api.kavenegar.com/v1/${process.env.KN_KEY}/verify/lookup.js
|
||||
try {
|
||||
const now = new Date();
|
||||
const otp = this.generateOtp();
|
||||
const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن بیمه سامان\nCode: ${otp}`;
|
||||
const message = `کاربر محترم رمز یکبار مصرف شما برای اپلیکیشن درمانت\nCode: ${otp}`;
|
||||
|
||||
let user = await this.user.findOne({ mobile: userData.mobile });
|
||||
|
||||
|
||||
89
src/business-hours/example-request-body.json
Normal file
89
src/business-hours/example-request-body.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"timezone": "Asia/Tehran",
|
||||
"globalToggle": true,
|
||||
"weeklyWindows": [
|
||||
{
|
||||
"day": "saturday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "09:00",
|
||||
"end": "19:30"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "sunday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "09:00",
|
||||
"end": "19:30"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "monday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "09:00",
|
||||
"end": "19:30"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "tuesday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "09:00",
|
||||
"end": "19:30"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "wednesday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "08:00",
|
||||
"end": "19:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "thursday",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "08:00",
|
||||
"end": "12:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "friday",
|
||||
"intervals": []
|
||||
}
|
||||
],
|
||||
"exceptions": [
|
||||
{
|
||||
"date": "2025-03-20",
|
||||
"intervals": [],
|
||||
"reason": "Holiday Nowruz"
|
||||
},
|
||||
{
|
||||
"date": "2025-03-21",
|
||||
"intervals": [
|
||||
{
|
||||
"start": "10:00",
|
||||
"end": "14:00"
|
||||
}
|
||||
],
|
||||
"reason": "Special hours"
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"onClose": "allow_existing_until_end"
|
||||
},
|
||||
"messageTemplate": {
|
||||
"key": "errors.online_conversation_unavailable",
|
||||
"default": "Online conversation is not available now. Next opening: {{nextOpen}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export class SessionModel extends Document {
|
||||
react: { type: String, default: 'Nothing' },
|
||||
runId: { type: String, required: false },
|
||||
aiStatus: { type: String, required: false },
|
||||
suggested_questions: { type: [String], required: false, default: undefined },
|
||||
escalation: { type: Object, required: false },
|
||||
createdAt: { type: [String], required: true }, // [time, date]
|
||||
createdISO: { type: Date, required: true },
|
||||
@@ -104,6 +105,7 @@ export class SessionModel extends Document {
|
||||
edited: boolean;
|
||||
runId?: string;
|
||||
aiStatus?: string;
|
||||
suggested_questions?: string[];
|
||||
escalation?: {
|
||||
summary?: string;
|
||||
handoff_context?: Record<string, unknown>;
|
||||
|
||||
@@ -10,8 +10,9 @@ export class UsersBaseModel {
|
||||
@Prop()
|
||||
family: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
role: Role;
|
||||
/** System role (`admin`/`expert`/…) or a custom ACL role name. */
|
||||
@Prop({ required: true, type: String })
|
||||
role: Role | string;
|
||||
|
||||
@Prop()
|
||||
fatherName: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model } from 'mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import type { FilterQuery } from 'mongoose';
|
||||
import {
|
||||
BaseResponseDTO,
|
||||
PageMetaDto,
|
||||
|
||||
@@ -1,59 +1,54 @@
|
||||
import { Controller, Get, Query, Header, BadRequestException, Req, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Query, BadRequestException, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { Public } from 'src/auth/auth.decorator';
|
||||
import { WidgetService } from './widget.service';
|
||||
|
||||
@Controller('widget')
|
||||
@Public()
|
||||
export class WidgetController {
|
||||
constructor(private readonly widgetService: WidgetService) { }
|
||||
constructor(private readonly widgetService: WidgetService) {}
|
||||
|
||||
@Get('script')
|
||||
@Header('Content-Type', 'application/javascript')
|
||||
@Header('Access-Control-Allow-Origin', 'https://staging.hdmplus.ir/, https://si24.ir')
|
||||
@Header('X-Frame-Options', 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir')
|
||||
@Header(
|
||||
'Content-Security-Policy',
|
||||
"frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir",
|
||||
)
|
||||
@Header('X-Content-Type-Options', 'nosniff')
|
||||
getWidgetScript(
|
||||
@Query('apiKey') apiKey: string,
|
||||
@Req() req: Request,
|
||||
): string {
|
||||
if (!apiKey) {
|
||||
throw new BadRequestException('API key is required');
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!this.widgetService.isValidApiKey(apiKey)) {
|
||||
throw new BadRequestException(
|
||||
apiKey ? 'Invalid API key' : 'API key is required',
|
||||
);
|
||||
}
|
||||
|
||||
const isValidApiKey =
|
||||
Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length &&
|
||||
Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0;
|
||||
|
||||
if (!isValidApiKey) {
|
||||
throw new BadRequestException('Invalid API key');
|
||||
}
|
||||
|
||||
return this.widgetService.generateWidgetScript();
|
||||
this.widgetService.applyScriptHeaders(req, res);
|
||||
return res.send(this.widgetService.generateWidgetScript());
|
||||
}
|
||||
|
||||
@Get('iframe')
|
||||
async getIframeScript(
|
||||
getIframeScript(
|
||||
@Query('apiKey') apiKey: string,
|
||||
@Req() req: Request,
|
||||
@Res() res,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!apiKey || apiKey !== 'si24samanwebsite') {
|
||||
if (!this.widgetService.isValidApiKey(apiKey)) {
|
||||
throw new BadRequestException('API key is invalid or missing');
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/javascript',
|
||||
'Access-Control-Allow-Origin': 'https://staging.hdmplus.ir/, https://si24.ir',
|
||||
'X-Frame-Options': 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir',
|
||||
'Content-Security-Policy': "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir",
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
const script = this.widgetService.generateWidgetScriptWithoutStyle();
|
||||
return res.send(script);
|
||||
this.widgetService.applyScriptHeaders(req, res);
|
||||
return res.send(this.widgetService.generateWidgetScriptWithoutStyle());
|
||||
}
|
||||
}
|
||||
|
||||
@Get('demo')
|
||||
getWidgetDemo(@Res() res: Response) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.send(this.widgetService.generateDemoHtml());
|
||||
}
|
||||
|
||||
@Get('frame')
|
||||
getWidgetFrame(@Res() res: Response) {
|
||||
this.widgetService.applyFrameHeaders(res);
|
||||
return res.send(this.widgetService.generateFrameHtml());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetService {
|
||||
/** Full iframe src, may include a path (e.g. /widget/frame). */
|
||||
private readonly frontendUrl: string;
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'https://chatbot.si24.ir';
|
||||
// Khahesh mikonam
|
||||
// 1. az env estefadeh konid
|
||||
// 2. agar chizi hardcode hast hamahang konid
|
||||
// -------- az env estefade shude ---------
|
||||
// c------ chiziam hardcode nist ----
|
||||
/** Origin only — required by postMessage targetOrigin. */
|
||||
private readonly frontendOrigin: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly iconUrl: string;
|
||||
private readonly allowedParentOrigins: string[];
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.frontendUrl = WidgetService.requireUrl(
|
||||
this.configService.get<string>('FRONTEND_URL'),
|
||||
'FRONTEND_URL',
|
||||
);
|
||||
this.frontendOrigin = new URL(this.frontendUrl).origin;
|
||||
this.apiKey = WidgetService.requireValue(
|
||||
this.configService.get<string>('WIDGET_API_KEY'),
|
||||
'WIDGET_API_KEY',
|
||||
);
|
||||
this.iconUrl =
|
||||
WidgetService.optionalValue(
|
||||
this.configService.get<string>('WIDGET_ICON_URL'),
|
||||
) || WidgetService.defaultIconDataUri();
|
||||
this.allowedParentOrigins = WidgetService.parseOrigins(
|
||||
this.configService.get<string>('WIDGET_ALLOWED_ORIGINS'),
|
||||
);
|
||||
}
|
||||
|
||||
isValidApiKey(apiKey?: string): boolean {
|
||||
return Boolean(apiKey) && apiKey === this.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers accept only a single origin in Access-Control-Allow-Origin.
|
||||
* Echo the request Origin when it is in WIDGET_ALLOWED_ORIGINS (or FRONTEND_URL).
|
||||
*
|
||||
* Do not set CSP frame-ancestors here. That header answers "who may iframe
|
||||
* THIS response?" Nobody iframes the JS file. Who may iframe the SPA is
|
||||
* decided by chatbot HTML/nginx, e.g.:
|
||||
* Content-Security-Policy: frame-ancestors https://anothersite.com
|
||||
*
|
||||
* Previous loader header (wrong place):
|
||||
* res.setHeader(
|
||||
* 'Content-Security-Policy',
|
||||
* `frame-ancestors 'self' ${this.allowedParentOrigins.join(' ')}`,
|
||||
* );
|
||||
*/
|
||||
applyScriptHeaders(req: Request, res: Response): void {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
const origin = WidgetService.normalizeOrigin(req.headers.origin);
|
||||
if (origin && this.corsOrigins().includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers for the document that sits inside the widget iframe.
|
||||
* Global middleware sets X-Frame-Options: DENY; that must be removed
|
||||
* or the panel stays blank.
|
||||
*/
|
||||
applyFrameHeaders(res: Response): void {
|
||||
res.removeHeader('X-Frame-Options');
|
||||
const ancestors = ["'self'", ...this.allowedParentOrigins].join(' ');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors}`);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
generateIframe(): string {
|
||||
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||
return `
|
||||
(function () {
|
||||
if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = 'saman-widget-iframe';
|
||||
iframe.src = '${this.frontendUrl}';
|
||||
iframe.src = ${frontendUrl};
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.top = '0';
|
||||
iframe.style.left = '0';
|
||||
@@ -40,7 +103,51 @@ export class WidgetService {
|
||||
})();
|
||||
`;
|
||||
}
|
||||
/**
|
||||
* Previous iframe attributes (kept for rollback if third-party cookies
|
||||
* break again). Added while trying to make login/storage work when the
|
||||
* SPA is embedded on another site. `credentialless="false"` is a boolean
|
||||
* HTML attribute: presence turns it ON, so it could strip cookies.
|
||||
* The second `allow` also overwrote microphone/camera.
|
||||
*
|
||||
* iframe.allow = 'microphone; camera; storage-access-api';
|
||||
* iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
|
||||
* iframe.setAttribute('loading', 'lazy');
|
||||
* iframe.setAttribute('credentialless', 'false');
|
||||
* iframe.setAttribute('allow', 'storage-access-api *');
|
||||
*
|
||||
* Parent-document Storage Access API (removed). Parent site
|
||||
* (e.g. anothername.com) auth is unrelated to chatbot iframe auth.
|
||||
* requestStorageAccess() only helps if the iframe document calls it.
|
||||
* The loader still postMessages REQUEST_STORAGE_ACCESS into the iframe.
|
||||
*
|
||||
* if (event.data.type === 'REQUEST_STORAGE_ACCESS' &&
|
||||
* 'requestStorageAccess' in document &&
|
||||
* typeof document.requestStorageAccess === 'function') {
|
||||
* document.requestStorageAccess().then(() => {
|
||||
* iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_GRANTED' }, event.origin);
|
||||
* }).catch(() => {
|
||||
* iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_DENIED' }, event.origin);
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* Eager iframe (removed): iframe was created and src set on script load.
|
||||
* loading=lazy does not help a position:fixed iframe. SPA now loads on
|
||||
* first bubble click via ensureIframe().
|
||||
*
|
||||
* Parent navigation (removed). Widget must not send anothersite.com
|
||||
* elsewhere via postMessage:
|
||||
* if (event.data.type === 'REDIRECT_REQUEST') {
|
||||
* window.top.location.href = event.data.url;
|
||||
* }
|
||||
*/
|
||||
generateWidgetScript(): string {
|
||||
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||
const frontendOrigin = JSON.stringify(this.frontendOrigin);
|
||||
const iconHtml = JSON.stringify(
|
||||
`<img src="${this.iconUrl}" alt="chat">`,
|
||||
);
|
||||
const allowedOrigins = JSON.stringify(this.messageOrigins());
|
||||
return `
|
||||
(function () {
|
||||
if (window.samanChatbotLoaded) return;
|
||||
@@ -105,29 +212,44 @@ export class WidgetService {
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'saman-widget-btn';
|
||||
btn.innerHTML = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" alt="chat">';
|
||||
btn.innerHTML = ${iconHtml};
|
||||
document.body.appendChild(btn);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = 'saman-widget-iframe';
|
||||
iframe.src = '${this.frontendUrl}';
|
||||
iframe.allow = 'microphone; camera; storage-access-api';
|
||||
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
iframe.setAttribute('credentialless', 'false');
|
||||
iframe.setAttribute('allow', 'storage-access-api *');
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let iframe = null;
|
||||
let closeBtn;
|
||||
const allowedOrigins = ${allowedOrigins};
|
||||
|
||||
function ensureIframe() {
|
||||
if (iframe) return iframe;
|
||||
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'saman-widget-iframe';
|
||||
iframe.allow = 'microphone; camera; storage-access-api';
|
||||
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
|
||||
iframe.addEventListener('load', function() {
|
||||
if (!iframe.contentWindow) return;
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'IFRAME_LOADED'
|
||||
}, ${frontendOrigin});
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'REQUEST_STORAGE_ACCESS'
|
||||
}, ${frontendOrigin});
|
||||
});
|
||||
document.body.appendChild(iframe);
|
||||
iframe.src = ${frontendUrl};
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function openWidget() {
|
||||
iframe.classList.add('visible');
|
||||
const alreadyCreated = Boolean(iframe);
|
||||
const frame = ensureIframe();
|
||||
frame.classList.add('visible');
|
||||
if (window.innerWidth < 768) {
|
||||
iframe.style.top = '0';
|
||||
iframe.style.left = '0';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.borderRadius = '0';
|
||||
frame.style.top = '0';
|
||||
frame.style.left = '0';
|
||||
frame.style.width = '100%';
|
||||
frame.style.height = '100%';
|
||||
frame.style.borderRadius = '0';
|
||||
|
||||
closeBtn = document.createElement('button');
|
||||
closeBtn.id = 'saman-close-btn';
|
||||
@@ -142,20 +264,16 @@ export class WidgetService {
|
||||
btn.style.setProperty('pointer-events', 'none', 'important');
|
||||
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (iframe.contentWindow &&
|
||||
'requestStorageAccess' in document &&
|
||||
typeof document.requestStorageAccess === 'function') {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'REQUEST_STORAGE_ACCESS'
|
||||
}, '${this.frontendUrl}');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
if (alreadyCreated && frame.contentWindow) {
|
||||
frame.contentWindow.postMessage({
|
||||
type: 'REQUEST_STORAGE_ACCESS'
|
||||
}, ${frontendOrigin});
|
||||
}
|
||||
}
|
||||
|
||||
function closeWidget() {
|
||||
iframe.classList.remove('visible');
|
||||
if (iframe) iframe.classList.remove('visible');
|
||||
if (closeBtn) {
|
||||
closeBtn.remove();
|
||||
closeBtn = null;
|
||||
@@ -163,21 +281,19 @@ export class WidgetService {
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
|
||||
// Show the widget button again when closing
|
||||
btn.style.setProperty('visibility', 'visible', 'important');
|
||||
btn.style.setProperty('opacity', '1', 'important');
|
||||
btn.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (iframe.classList.contains('visible')) closeWidget();
|
||||
if (iframe && iframe.classList.contains('visible')) closeWidget();
|
||||
else openWidget();
|
||||
});
|
||||
|
||||
const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir'];
|
||||
|
||||
window.addEventListener('message', function(event) {
|
||||
if (!allowedOrigins.includes(event.origin)) return;
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
|
||||
if (event.data.type === 'REQUEST_DATA') {
|
||||
iframe.contentWindow.postMessage({
|
||||
@@ -185,37 +301,15 @@ export class WidgetService {
|
||||
payload: event.data.payload
|
||||
}, event.origin);
|
||||
}
|
||||
|
||||
if (event.data.type === 'REDIRECT_REQUEST') {
|
||||
window.top.location.href = event.data.url;
|
||||
}
|
||||
|
||||
if (event.data.type === 'REQUEST_STORAGE_ACCESS' &&
|
||||
'requestStorageAccess' in document &&
|
||||
typeof document.requestStorageAccess === 'function') {
|
||||
document.requestStorageAccess().then(() => {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'STORAGE_ACCESS_GRANTED'
|
||||
}, event.origin);
|
||||
}).catch(() => {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'STORAGE_ACCESS_DENIED'
|
||||
}, event.origin);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
iframe.addEventListener('load', function() {
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'IFRAME_LOADED'
|
||||
}, '${this.frontendUrl}');
|
||||
}, 500);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
generateWidgetScriptWithoutStyle(): string {
|
||||
const frontendUrl = JSON.stringify(this.frontendUrl);
|
||||
const iconHtml = JSON.stringify(
|
||||
`<img src="${this.iconUrl}" style="width:22px;height:22px">`,
|
||||
);
|
||||
return `
|
||||
(function() {
|
||||
if (window.samanChatbotLoaded) return;
|
||||
@@ -223,24 +317,31 @@ export class WidgetService {
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease";
|
||||
btn.innerHTML = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" style="width:22px;height:22px">';
|
||||
btn.innerHTML = ${iconHtml};
|
||||
document.body.appendChild(btn);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = '${this.frontendUrl}';
|
||||
iframe.allow = 'microphone;camera';
|
||||
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups';
|
||||
iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)";
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let iframe = null;
|
||||
let closeBtn;
|
||||
|
||||
function ensureIframe() {
|
||||
if (iframe) return iframe;
|
||||
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.allow = 'microphone;camera';
|
||||
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups';
|
||||
iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)";
|
||||
document.body.appendChild(iframe);
|
||||
iframe.src = ${frontendUrl};
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function openWidget() {
|
||||
iframe.style.display = 'block';
|
||||
const frame = ensureIframe();
|
||||
frame.style.display = 'block';
|
||||
if (window.innerWidth < 768) {
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.borderRadius = '0';
|
||||
frame.style.width = '100%';
|
||||
frame.style.height = '100%';
|
||||
frame.style.borderRadius = '0';
|
||||
|
||||
closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '✕';
|
||||
@@ -258,7 +359,7 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
function closeWidget() {
|
||||
iframe.style.display = 'none';
|
||||
if (iframe) iframe.style.display = 'none';
|
||||
if (closeBtn) { closeBtn.remove(); closeBtn = null; }
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
@@ -268,7 +369,7 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (iframe.style.display === 'block') closeWidget();
|
||||
if (iframe && iframe.style.display === 'block') closeWidget();
|
||||
else openWidget();
|
||||
});
|
||||
})();
|
||||
@@ -280,4 +381,149 @@ export class WidgetService {
|
||||
getScript(): string {
|
||||
return `<script>${this.generateWidgetScript()}</script>`;
|
||||
}
|
||||
|
||||
generateDemoHtml(): string {
|
||||
const scriptSrc = `/widget/script?apiKey=${encodeURIComponent(this.apiKey)}`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Widget parent demo (anothersite.com)</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 0; background: #f4f4f5; color: #18181b; }
|
||||
header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #e4e4e7; }
|
||||
main { max-width: 720px; margin: 40px auto; padding: 0 24px; }
|
||||
h1 { font-size: 22px; }
|
||||
p { line-height: 1.5; color: #3f3f46; }
|
||||
code { background: #e4e4e7; padding: 2px 6px; border-radius: 4px; }
|
||||
.note { background: #fff; border: 1px solid #e4e4e7; padding: 16px; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>anothersite.com — fake customer page</header>
|
||||
<main>
|
||||
<h1>Parent site</h1>
|
||||
<p>This page is the host (like example.com). Auth here is unrelated to chat login inside the widget.</p>
|
||||
<div class="note">
|
||||
<p>Look at the bottom-right bubble. Click it.</p>
|
||||
<ul>
|
||||
<li>Bubble only on load — pass</li>
|
||||
<li>Panel opens on click — pass</li>
|
||||
<li>This URL stays <code>/widget/demo</code> — pass (widget must not navigate the parent)</li>
|
||||
</ul>
|
||||
<p>Iframe src is <code>${this.escapeHtml(this.frontendUrl)}</code></p>
|
||||
</div>
|
||||
</main>
|
||||
<script src="${this.escapeHtml(scriptSrc)}" async></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
generateFrameHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Widget iframe stub</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 0; background: #0f766e; color: #fff; }
|
||||
.panel { padding: 16px; }
|
||||
h1 { font-size: 16px; margin: 0 0 8px; }
|
||||
p, li { font-size: 13px; line-height: 1.4; }
|
||||
#log { margin-top: 12px; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 6px; font-family: monospace; font-size: 12px; min-height: 48px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>Chat iframe loaded</h1>
|
||||
<p>If you see this after clicking the bubble, the loader works. This stub is not the production SPA.</p>
|
||||
<p>Set <code>FRONTEND_URL</code> to the real chatbot app when embedding on a customer site. That app must send <code>frame-ancestors</code> for the parent origin.</p>
|
||||
<div id="log">waiting for postMessage…</div>
|
||||
</div>
|
||||
<script>
|
||||
const log = document.getElementById('log');
|
||||
window.addEventListener('message', function (event) {
|
||||
const type = event.data && event.data.type;
|
||||
log.textContent = 'from ' + event.origin + ' → ' + (type || JSON.stringify(event.data));
|
||||
if (type === 'REQUEST_STORAGE_ACCESS' && document.requestStorageAccess) {
|
||||
document.requestStorageAccess().catch(function () {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private messageOrigins(): string[] {
|
||||
return Array.from(
|
||||
new Set([this.frontendOrigin, ...this.allowedParentOrigins]),
|
||||
);
|
||||
}
|
||||
|
||||
private corsOrigins(): string[] {
|
||||
return this.messageOrigins();
|
||||
}
|
||||
|
||||
private static requireUrl(raw: string | undefined, name: string): string {
|
||||
const value = WidgetService.normalizeOrigin(raw);
|
||||
if (!value || !/^https?:\/\//i.test(value)) {
|
||||
throw new Error(`${name} environment variable not configured`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static requireValue(raw: string | undefined, name: string): string {
|
||||
const value = WidgetService.optionalValue(raw);
|
||||
if (!value) {
|
||||
throw new Error(`${name} environment variable not configured`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static optionalValue(raw: string | undefined): string {
|
||||
if (raw == null) return '';
|
||||
let value = String(raw).trim();
|
||||
if (
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
) {
|
||||
value = value.slice(1, -1).trim();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parseOrigins(raw: string | undefined): string[] {
|
||||
const value = WidgetService.optionalValue(raw);
|
||||
if (!value) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => WidgetService.normalizeOrigin(item))
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private static normalizeOrigin(raw: string | undefined): string {
|
||||
const value = WidgetService.optionalValue(raw);
|
||||
if (!value) return '';
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private static defaultIconDataUri(): string {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>';
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
}
|
||||
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"jsx": "react",
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user