Compare commits

...

16 Commits

Author SHA1 Message Date
6682b476ef Merge pull request 'main' (#7) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#7
2026-09-15 12:58:56 +03:30
73806f91b2 merge upstream 2026-09-15 12:58:37 +03:30
a4815b37b3 widget improvement + install for customer sites guideline 2026-09-15 12:56:13 +03:30
a91b3aeabc Merge pull request 'update the client name' (#6) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#6
2026-09-14 17:31:20 +03:30
1378dcdd11 update the client name 2026-09-14 17:30:24 +03:30
cbd0cce504 Merge pull request 'update for the so many errors' (#5) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#5
2026-09-13 17:07:04 +03:30
cec72740ab update for the so many errors 2026-09-13 17:06:36 +03:30
578384d64e Merge pull request 'main' (#4) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#4
2026-09-13 16:40:06 +03:30
f87d1a384e merge upstream 2026-09-13 16:39:46 +03:30
7302951a25 tsconfig.json 2026-09-13 16:39:09 +03:30
52c0994032 Merge pull request 'package.json file' (#3) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#3
2026-09-13 16:15:19 +03:30
2ed30d401f package.json file 2026-09-13 16:13:57 +03:30
d46fe40a7e Merge pull request 'main' (#2) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#2
2026-09-09 16:42:50 +03:30
e61d2b7ec0 merge upstream 2026-09-09 16:41:11 +03:30
49e67b3a82 threads now support suggested_questions 2026-09-09 16:40:42 +03:30
0abc8f4988 Merge pull request 'main' (#1) from s.hajizadeh/v3-api:main into main
Reviewed-on: Chatbot/v3-api#1
2026-09-08 16:36:59 +03:30
23 changed files with 14035 additions and 129 deletions

1
docker/test.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

121
package.json Normal file
View 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"
}
}

View File

@@ -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 || {}, {

View File

@@ -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();
});
});

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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
});

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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 });

View 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}}"
}
}

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

22
tsconfig.json Normal file
View 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
}
}