add fake channel

This commit is contained in:
2026-07-09 18:14:45 +08:00
parent 8fed449b95
commit 9c852cd99b
37 changed files with 4300 additions and 679 deletions
+24
View File
@@ -0,0 +1,24 @@
# Channels
This directory houses external channel platform integrations for GoChat testing and future platform support.
## Structure
```
channels/
├── fake/ # FakeMessagePlatform — implemented (test channel)
├── qq/ # Reserved — QQ
├── weixin/ # Reserved — WeChat (微信公众号/小程序/企业微信)
├── shangwutong/ # Reserved — 商务通
├── douyin/ # Reserved — 抖音
└── xiaohongshu/ # Reserved — 小红书
```
## Current status
- **fake** — Fully implemented. A programmable fake message platform for automated end-to-end integration testing. See `fake/README.md`.
- **qq/weixin/shangwutong/douyin/xiaohongshu** — Reserved only. No code implemented. These platforms require real API credentials and per-platform provider implementations. The directories are pre-created so future extensions have a stable path convention.
## GoChat backend channel providers
Each channel's backend provider lives in `backend/internal/channel/provider/`. The fake channel's provider is at `backend/internal/channel/provider/fake.go`.
+10
View File
@@ -0,0 +1,10 @@
# 抖音渠道(预留)
此目录预留给抖音渠道对接。当前未实现。
抖音渠道需要对接抖音开放平台(巨量引擎)API,包括:
- 私信消息收发
- 创作者回复
- 视频评论消息
GoChat 后端的抖音 provider 实现将位于 `backend/internal/channel/provider/douyin.go`。
+65
View File
@@ -0,0 +1,65 @@
# FakeMessagePlatform
A programmable fake message platform for GoChat automated integration testing.
## What it does
FakeMessagePlatform simulates an external messaging channel. It:
1. Sends messages to GoChat's `/webhooks/fake/:identifier` webhook endpoint (simulating customer-initiated messages)
2. Receives GoChat's outbound messages at `/receive` (messages sent by agents via FakeProvider.SendMessage)
3. Tracks all sent/received messages and agent states in memory for test assertions
4. Provides REST API for test scripts to orchestrate message flows
## Quick start
```bash
# From repo root
pnpm install
pnpm fake:start
# Health check
curl http://127.0.0.1:9100/health
# → {"status":"ok","service":"fake-message-platform"}
```
## Configuration
Environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `9100` | HTTP listen port |
| `GOCHAT_WEBHOOK_URL` | `http://127.0.0.1:3000/webhooks/fake/fake_inbox_1` | GoChat webhook URL |
| `GOCHAT_FAKE_TOKEN` | `(empty)` | X-Fake-Token shared secret |
## REST API
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/send` | Send a message to GoChat (simulate customer) |
| `POST` | `/api/reply` | Reply to a message (simulate customer reply) |
| `POST` | `/api/close` | Close a chat session (session.end event) |
| `POST` | `/api/typing` | Toggle typing indicator |
| `POST` | `/api/agent/online` | Mark an agent as online |
| `POST` | `/api/agent/offline` | Mark an agent as offline |
| `GET` | `/api/messages` | Query outbound messages received from GoChat |
| `GET` | `/api/messages/:id` | Query a single message |
| `GET` | `/api/status` | Platform status (agents, counts) |
| `POST` | `/api/reset` | Reset all in-memory state |
| `POST` | `/receive` | Receive GoChat outbound message |
| `GET` | `/health` | Health check |
## Message flow
```
Test script → FakeMessagePlatform → GoChat webhook → Broker → Pipeline → DB → WS push
↑
Agent reply → FakeProvider.SendMessage → POST /receive → FakeMessagePlatform store
```
## Related
- GoChat FakeProvider: `backend/internal/channel/provider/fake.go`
- GoChat FakeWebhookHandler: `backend/internal/handler/webhook/fake_webhook.go`
- Test plan: `docs/qa/2026-07-09-test-plan-round5.md`
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@gochat/fake-platform",
"version": "0.1.0",
"private": true,
"description": "FakeMessagePlatform — programmable fake message platform for GoChat automated integration testing",
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"test": "vitest run"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=20"
}
}
+54
View File
@@ -0,0 +1,54 @@
// GoChat webhook client — sends HTTP POST to GoChat's /webhooks/fake/:identifier
// endpoint, simulating an external channel sending messages to GoChat.
import type { GoChatWebhookPayload } from '../types.js';
export class GoChatClient {
private webhookUrl: string;
private token: string;
constructor(webhookUrl: string, token: string = '') {
this.webhookUrl = webhookUrl;
this.token = token;
}
setWebhookUrl(url: string): void {
this.webhookUrl = url;
}
setToken(token: string): void {
this.token = token;
}
async sendToGoChat(payload: GoChatWebhookPayload): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['X-Fake-Token'] = this.token;
}
const response = await fetch(this.webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
return response;
}
async verifyWebhook(challenge: string = 'test_challenge'): Promise<boolean> {
const url = `${this.webhookUrl}?hub.challenge=${encodeURIComponent(challenge)}`;
const headers: Record<string, string> = {};
if (this.token) {
headers['X-Fake-Token'] = this.token;
}
try {
const response = await fetch(url, { method: 'GET', headers });
return response.ok;
} catch {
return false;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// FakeMessagePlatform — entry point.
//
// Reads configuration from environment variables:
// PORT — HTTP listen port (default 9100)
// GOCHAT_WEBHOOK_URL — GoChat webhook URL (default http://127.0.0.1:3000/webhooks/fake/fake_inbox_1)
// GOCHAT_FAKE_TOKEN — X-Fake-Token shared secret (default empty, no verification)
import { createServer } from './server.js';
const port = parseInt(process.env.PORT || '9100', 10);
const gochatWebhookUrl =
process.env.GOCHAT_WEBHOOK_URL ||
'http://127.0.0.1:3000/webhooks/fake/fake_inbox_1';
const gochatFakeToken = process.env.GOCHAT_FAKE_TOKEN || '';
const app = createServer({
port,
gochatWebhookUrl,
gochatFakeToken,
});
app.listen(port, () => {
console.log(`[FakeMessagePlatform] listening on :${port}`);
console.log(`[FakeMessagePlatform] GoChat webhook: ${gochatWebhookUrl}`);
console.log(`[FakeMessagePlatform] Token: ${gochatFakeToken ? '(set)' : '(none)'}`);
});
+305
View File
@@ -0,0 +1,305 @@
// FakeMessagePlatform — Express HTTP server.
//
// REST API:
// POST /api/send — send a message to GoChat webhook (simulate customer)
// POST /api/reply — reply to a message (simulate customer reply)
// POST /api/close — close a chat session (session.end event)
// POST /api/typing — toggle typing indicator
// POST /api/agent/online — mark an agent as online (observation)
// POST /api/agent/offline — mark an agent as offline
// GET /api/messages — query outbound messages received from GoChat
// GET /api/messages/:id — query a single message
// GET /api/status — platform status (agents, counts)
// POST /api/reset — reset all in-memory state
// POST /receive — receive GoChat outbound message (from FakeProvider.SendMessage)
// GET /health — health check
import express from 'express';
import type {
AgentOnlineRequest,
AgentOfflineRequest,
CloseSessionRequest,
GoChatOutboundMessage,
GoChatWebhookPayload,
MessageRecord,
ReplyMessageRequest,
SendMessageRequest,
TypingRequest,
} from './types.js';
import { store } from './store/memory-store.js';
import { GoChatClient } from './client/gochat-client.js';
export interface ServerConfig {
port: number;
gochatWebhookUrl: string;
gochatFakeToken: string;
}
export function createServer(config: ServerConfig): express.Application {
const app = express();
app.use(express.json());
const gochatClient = new GoChatClient(
config.gochatWebhookUrl,
config.gochatFakeToken
);
// Allow runtime config updates
app.post('/api/config', (req, res) => {
const { webhook_url, token } = req.body || {};
if (webhook_url) gochatClient.setWebhookUrl(webhook_url);
if (token !== undefined) gochatClient.setToken(token);
res.json({ status: 'ok' });
});
// --- Health ---
app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'fake-message-platform' });
});
// --- Send message (simulate customer sending to GoChat) ---
app.post('/api/send', async (req, res) => {
const body = req.body as SendMessageRequest;
if (!body || !body.inbox_identifier || !body.sender_id || !body.content) {
res.status(400).json({ error: 'inbox_identifier, sender_id, and content are required' });
return;
}
const messageId = `fake_msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const payload: GoChatWebhookPayload = {
event: 'message.incoming',
message_id: messageId,
sender_id: body.sender_id,
sender_name: body.sender_name || body.sender_id,
content: body.content,
content_type: body.content_type || 'text',
conversation_id: body.conversation_id,
reply_to_id: body.reply_to_id,
timestamp: Math.floor(Date.now() / 1000),
attachments: body.attachments,
};
try {
const gResponse = await gochatClient.sendToGoChat(payload);
const record: MessageRecord = {
id: messageId,
direction: 'incoming',
inbox_identifier: body.inbox_identifier,
message_id: messageId,
sender_id: body.sender_id,
sender_name: body.sender_name,
content: body.content,
content_type: payload.content_type,
conversation_id: body.conversation_id,
timestamp: payload.timestamp,
};
store.recordSent(record);
res.json({
status: 'sent',
message_id: messageId,
gochat_status: gResponse.status,
});
} catch (err) {
res.status(502).json({
error: 'Failed to send to GoChat',
detail: (err as Error).message,
});
}
});
// --- Reply message (simulate customer replying) ---
app.post('/api/reply', async (req, res) => {
const body = req.body as ReplyMessageRequest;
if (!body || !body.inbox_identifier || !body.sender_id || !body.reply_to_id) {
res.status(400).json({ error: 'inbox_identifier, sender_id, and reply_to_id are required' });
return;
}
const messageId = `fake_reply_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const payload: GoChatWebhookPayload = {
event: 'message.incoming',
message_id: messageId,
sender_id: body.sender_id,
sender_name: body.sender_name || body.sender_id,
content: body.content,
content_type: body.content_type || 'text',
reply_to_id: body.reply_to_id,
conversation_id: body.conversation_id,
timestamp: Math.floor(Date.now() / 1000),
attachments: body.attachments,
};
try {
const gResponse = await gochatClient.sendToGoChat(payload);
const record: MessageRecord = {
id: messageId,
direction: 'incoming',
inbox_identifier: body.inbox_identifier,
message_id: messageId,
sender_id: body.sender_id,
sender_name: body.sender_name,
content: body.content,
content_type: payload.content_type,
conversation_id: body.conversation_id,
timestamp: payload.timestamp,
};
store.recordSent(record);
res.json({
status: 'sent',
message_id: messageId,
gochat_status: gResponse.status,
});
} catch (err) {
res.status(502).json({
error: 'Failed to send reply to GoChat',
detail: (err as Error).message,
});
}
});
// --- Close session ---
app.post('/api/close', async (req, res) => {
const body = req.body as CloseSessionRequest;
if (!body || !body.inbox_identifier || !body.sender_id) {
res.status(400).json({ error: 'inbox_identifier and sender_id are required' });
return;
}
const messageId = `fake_close_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const payload: GoChatWebhookPayload = {
event: 'session.end',
message_id: messageId,
sender_id: body.sender_id,
sender_name: '',
content: '[session ended]',
content_type: 'text',
conversation_id: body.conversation_id,
timestamp: Math.floor(Date.now() / 1000),
};
try {
const gResponse = await gochatClient.sendToGoChat(payload);
const record: MessageRecord = {
id: messageId,
direction: 'incoming',
inbox_identifier: body.inbox_identifier,
message_id: messageId,
sender_id: body.sender_id,
content: '[session ended]',
conversation_id: body.conversation_id,
timestamp: payload.timestamp,
};
store.recordSent(record);
res.json({
status: 'sent',
message_id: messageId,
gochat_status: gResponse.status,
});
} catch (err) {
res.status(502).json({
error: 'Failed to send session.end to GoChat',
detail: (err as Error).message,
});
}
});
// --- Typing indicator ---
app.post('/api/typing', async (req, res) => {
const body = req.body as TypingRequest;
if (!body || !body.inbox_identifier || !body.sender_id) {
res.status(400).json({ error: 'inbox_identifier and sender_id are required' });
return;
}
const messageId = `fake_typing_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const payload: GoChatWebhookPayload = {
event: body.typing ? 'typing.start' : 'typing.stop',
message_id: messageId,
sender_id: body.sender_id,
sender_name: '',
content: '',
content_type: 'text',
timestamp: Math.floor(Date.now() / 1000),
};
try {
await gochatClient.sendToGoChat(payload);
res.json({ status: 'sent', typing: body.typing });
} catch (err) {
res.status(502).json({
error: 'Failed to send typing event to GoChat',
detail: (err as Error).message,
});
}
});
// --- Agent online ---
app.post('/api/agent/online', (req, res) => {
const body = req.body as AgentOnlineRequest;
if (!body || !body.agent_id) {
res.status(400).json({ error: 'agent_id is required' });
return;
}
store.agentOnline(body.agent_id, body.agent_name || '');
res.json({ status: 'ok', agent_id: body.agent_id, online: true });
});
// --- Agent offline ---
app.post('/api/agent/offline', (req, res) => {
const body = req.body as AgentOfflineRequest;
if (!body || !body.agent_id) {
res.status(400).json({ error: 'agent_id is required' });
return;
}
store.agentOffline(body.agent_id);
res.json({ status: 'ok', agent_id: body.agent_id, online: false });
});
// --- Query messages ---
app.get('/api/messages', (req, res) => {
const inboxIdentifier = (req.query.inbox_identifier as string) || undefined;
const received = store.getReceivedMessages(inboxIdentifier);
const sent = store.getSentMessages(inboxIdentifier);
res.json({
received: received,
sent: sent,
});
});
// --- Query single message ---
app.get('/api/messages/:id', (req, res) => {
const msg = store.getMessageById(req.params.id);
if (!msg) {
res.status(404).json({ error: 'message not found' });
return;
}
res.json(msg);
});
// --- Platform status ---
app.get('/api/status', (_req, res) => {
res.json(store.getStatus());
});
// --- Reset all state ---
app.post('/api/reset', (_req, res) => {
store.reset();
res.json({ status: 'ok' });
});
// --- Receive GoChat outbound message (from FakeProvider.SendMessage) ---
app.post('/receive', (req, res) => {
const body = req.body as GoChatOutboundMessage;
if (!body || !body.message_id) {
res.status(400).json({ error: 'message_id is required' });
return;
}
store.recordReceived(body);
res.json({ status: 'ok', message_id: body.message_id });
});
return app;
}
+110
View File
@@ -0,0 +1,110 @@
// In-memory state store for FakeMessagePlatform.
// All sent/received messages, agent states, and conversation counts are
// tracked here. No persistence — the store resets on process restart.
import type {
AgentInfo,
GoChatOutboundMessage,
MessageRecord,
PlatformStatus,
} from '../types.js';
export class MemoryStore {
private sentMessages: MessageRecord[] = [];
private receivedMessages: GoChatOutboundMessage[] = [];
private agents: Map<string, AgentInfo> = new Map();
private sentCount = 0;
private receivedCount = 0;
// --- Sent messages (FakeMessagePlatform → GoChat) ---
recordSent(msg: MessageRecord): void {
this.sentMessages.push(msg);
this.sentCount++;
}
getSentMessages(inboxIdentifier?: string): MessageRecord[] {
if (!inboxIdentifier) return [...this.sentMessages];
return this.sentMessages.filter((m) => m.inbox_identifier === inboxIdentifier);
}
// --- Received messages (GoChat → FakeMessagePlatform, outbound) ---
recordReceived(msg: GoChatOutboundMessage): void {
this.receivedMessages.push(msg);
this.receivedCount++;
}
getReceivedMessages(inboxIdentifier?: string): GoChatOutboundMessage[] {
if (!inboxIdentifier) return [...this.receivedMessages];
// We don't have inbox_identifier on the outbound payload, but the test
// harness typically filters by the single active fake inbox.
return [...this.receivedMessages];
}
getMessageById(id: string): MessageRecord | undefined {
return this.sentMessages.find((m) => m.id === id) ||
this.receivedMessages.find((m) => `out_${m.message_id}` === id);
}
// --- Agent state ---
agentOnline(agentId: string, agentName: string): void {
const existing = this.agents.get(agentId);
this.agents.set(agentId, {
agent_id: agentId,
agent_name: agentName,
online: true,
online_at: Date.now(),
offline_at: existing?.offline_at ?? null,
});
}
agentOffline(agentId: string): void {
const existing = this.agents.get(agentId);
if (existing) {
existing.online = false;
existing.offline_at = Date.now();
} else {
this.agents.set(agentId, {
agent_id: agentId,
agent_name: '',
online: false,
online_at: null,
offline_at: Date.now(),
});
}
}
getOnlineAgents(): AgentInfo[] {
return Array.from(this.agents.values()).filter((a) => a.online);
}
getAllAgents(): AgentInfo[] {
return Array.from(this.agents.values());
}
// --- Status ---
getStatus(): PlatformStatus {
const activeConversations = new Set(
this.sentMessages
.filter((m) => m.conversation_id)
.map((m) => m.conversation_id!)
).size;
return {
online_agents: this.getOnlineAgents(),
total_sent: this.sentCount,
total_received: this.receivedCount,
active_conversations: activeConversations,
};
}
// --- Reset (for testing) ---
reset(): void {
this.sentMessages = [];
this.receivedMessages = [];
this.agents.clear();
this.sentCount = 0;
this.receivedCount = 0;
}
}
export const store = new MemoryStore();
+99
View File
@@ -0,0 +1,99 @@
// Type definitions for FakeMessagePlatform
export interface SendMessageRequest {
inbox_identifier: string;
sender_id: string;
sender_name: string;
content: string;
content_type?: string;
reply_to_id?: string;
conversation_id?: string;
attachments?: FakeAttachment[];
}
export interface FakeAttachment {
url: string;
content_type: string;
filename?: string;
file_size?: number;
}
export interface ReplyMessageRequest extends SendMessageRequest {
reply_to_id: string;
}
export interface CloseSessionRequest {
inbox_identifier: string;
conversation_id?: string;
sender_id: string;
}
export interface TypingRequest {
inbox_identifier: string;
sender_id: string;
typing: boolean;
}
export interface AgentOnlineRequest {
agent_id: string;
agent_name: string;
}
export interface AgentOfflineRequest {
agent_id: string;
}
// The payload sent to GoChat's /webhooks/fake/:identifier endpoint
export interface GoChatWebhookPayload {
event: string;
message_id: string;
sender_id: string;
sender_name: string;
content: string;
content_type: string;
conversation_id?: string;
reply_to_id?: string;
timestamp: number;
attachments?: FakeAttachment[];
}
// The payload received from GoChat's FakeProvider.SendMessage (outbound)
export interface GoChatOutboundMessage {
message_id: number;
conversation_id: number;
content: string;
content_type: string;
sender: {
id: number;
name: string;
type: string;
};
}
export interface AgentInfo {
agent_id: string;
agent_name: string;
online: boolean;
online_at: number | null;
offline_at: number | null;
}
export interface PlatformStatus {
online_agents: AgentInfo[];
total_sent: number;
total_received: number;
active_conversations: number;
}
export interface MessageRecord {
id: string;
direction: 'incoming' | 'outgoing';
inbox_identifier: string;
message_id: string;
sender_id?: string;
sender_name?: string;
content: string;
content_type?: string;
conversation_id?: string;
timestamp: number;
}
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { createServer } from '../src/server.js';
import { store } from '../src/store/memory-store.js';
// Start a mock GoChat webhook server to capture the POSTs from FakeMessagePlatform.
let mockGochatPort: number;
let mockGochatServer: http.Server;
let receivedByGochat: any[] = [];
function startMockGochat(): Promise<http.Server> {
return new Promise((resolve) => {
const srv = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
if (req.method === 'POST') {
try {
receivedByGochat.push(JSON.parse(body));
} catch { /* ignore */ }
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
});
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address() as AddressInfo;
mockGochatPort = addr.port;
resolve(srv);
});
});
}
let fakeApp: http.Server;
let fakePort: number;
function startFakeServer(webhookUrl: string): Promise<http.Server> {
const app = createServer({
port: 0,
gochatWebhookUrl: webhookUrl,
gochatFakeToken: '',
});
return new Promise((resolve) => {
const srv = app.listen(0, '127.0.0.1', () => {
const addr = srv.address() as AddressInfo;
fakePort = addr.port;
resolve(srv as unknown as http.Server);
});
});
}
async function fakeRequest(path: string, method: string = 'GET', body?: any): Promise<any> {
const res = await fetch(`http://127.0.0.1:${fakePort}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json() };
}
beforeAll(async () => {
mockGochatServer = await startMockGochat();
fakeApp = await startFakeServer(`http://127.0.0.1:${mockGochatPort}/webhooks/fake/fake_test_1`);
});
afterAll(async () => {
await new Promise<void>((resolve) => fakeApp.close(() => resolve()));
await new Promise<void>((resolve) => mockGochatServer.close(() => resolve()));
});
beforeEach(() => {
store.reset();
receivedByGochat = [];
});
describe('FakeMessagePlatform', () => {
describe('/health', () => {
it('returns ok status', async () => {
const res = await fakeRequest('/health');
expect(res.status).toBe(200);
expect(res.data.status).toBe('ok');
expect(res.data.service).toBe('fake-message-platform');
});
});
describe('/api/send', () => {
it('sends a message to GoChat webhook', async () => {
const res = await fakeRequest('/api/send', 'POST', {
inbox_identifier: 'fake_test_1',
sender_id: 'customer_001',
sender_name: 'Test Customer',
content: 'Hello, I need help',
});
expect(res.status).toBe(200);
expect(res.data.status).toBe('sent');
expect(res.data.message_id).toBeDefined();
expect(res.data.gochat_status).toBe(200);
// Verify GoChat received the webhook
expect(receivedByGochat).toHaveLength(1);
expect(receivedByGochat[0].event).toBe('message.incoming');
expect(receivedByGochat[0].content).toBe('Hello, I need help');
expect(receivedByGochat[0].sender_id).toBe('customer_001');
});
it('returns 400 for missing required fields', async () => {
const res = await fakeRequest('/api/send', 'POST', {
inbox_identifier: 'fake_test_1',
});
expect(res.status).toBe(400);
expect(res.data.error).toContain('required');
});
});
describe('/api/agent/online + /api/status', () => {
it('marks an agent as online and shows in status', async () => {
const res = await fakeRequest('/api/agent/online', 'POST', {
agent_id: '1',
agent_name: 'Admin',
});
expect(res.status).toBe(200);
expect(res.data.online).toBe(true);
const statusRes = await fakeRequest('/api/status');
expect(statusRes.data.online_agents).toHaveLength(1);
expect(statusRes.data.online_agents[0].agent_id).toBe('1');
expect(statusRes.data.online_agents[0].agent_name).toBe('Admin');
});
it('marks an agent as offline', async () => {
await fakeRequest('/api/agent/online', 'POST', {
agent_id: '2',
agent_name: 'Agent2',
});
const offlineRes = await fakeRequest('/api/agent/offline', 'POST', {
agent_id: '2',
});
expect(offlineRes.status).toBe(200);
expect(offlineRes.data.online).toBe(false);
const statusRes = await fakeRequest('/api/status');
expect(statusRes.data.online_agents).toHaveLength(0);
});
});
describe('/api/reset', () => {
it('clears all state', async () => {
await fakeRequest('/api/agent/online', 'POST', {
agent_id: '1',
agent_name: 'Admin',
});
await fakeRequest('/api/send', 'POST', {
inbox_identifier: 'fake_test_1',
sender_id: 'cust_1',
sender_name: 'C',
content: 'hi',
});
const res = await fakeRequest('/api/reset', 'POST');
expect(res.status).toBe(200);
const statusRes = await fakeRequest('/api/status');
expect(statusRes.data.online_agents).toHaveLength(0);
expect(statusRes.data.total_sent).toBe(0);
});
});
describe('/receive', () => {
it('stores GoChat outbound messages', async () => {
const res = await fakeRequest('/receive', 'POST', {
message_id: 123,
conversation_id: 456,
content: 'Hello from agent',
content_type: 'text',
sender: { id: 1, name: 'Agent Wang', type: 'agent' },
});
expect(res.status).toBe(200);
expect(res.data.message_id).toBe(123);
const msgRes = await fakeRequest('/api/messages');
expect(msgRes.data.received).toHaveLength(1);
expect(msgRes.data.received[0].content).toBe('Hello from agent');
expect(msgRes.data.received[0].sender.type).toBe('agent');
});
});
describe('/api/typing', () => {
it('sends typing event to GoChat', async () => {
const res = await fakeRequest('/api/typing', 'POST', {
inbox_identifier: 'fake_test_1',
sender_id: 'customer_001',
typing: true,
});
expect(res.status).toBe(200);
expect(res.data.typing).toBe(true);
expect(receivedByGochat[0].event).toBe('typing.start');
});
it('sends typing stop event', async () => {
const res = await fakeRequest('/api/typing', 'POST', {
inbox_identifier: 'fake_test_1',
sender_id: 'customer_001',
typing: false,
});
expect(res.status).toBe(200);
expect(res.data.typing).toBe(false);
expect(receivedByGochat[0].event).toBe('typing.stop');
});
});
describe('/api/close', () => {
it('sends session.end event', async () => {
const res = await fakeRequest('/api/close', 'POST', {
inbox_identifier: 'fake_test_1',
conversation_id: 'ext_conv_1',
sender_id: 'customer_001',
});
expect(res.status).toBe(200);
expect(receivedByGochat[0].event).toBe('session.end');
expect(receivedByGochat[0].content).toBe('[session ended]');
});
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
+10
View File
@@ -0,0 +1,10 @@
# QQ 渠道(预留)
此目录预留给 QQ 渠道对接。当前未实现。
QQ 渠道需要对接腾讯 QQ 开放平台 API,包括:
- 消息收发(文本/图片/文件)
- 群消息/私聊消息
- 好友列表同步
GoChat 后端的 QQ provider 实现将位于 `backend/internal/channel/provider/qq.go`。
+10
View File
@@ -0,0 +1,10 @@
# 商务通渠道(预留)
此目录预留给商务通渠道对接。当前未实现。
商务通是常用的在线客服 SaaS 平台,对接需要:
- 客服上下线状态同步
- 客户对话消息桥接
- 正在输入/停止输入状态传递
GoChat 后端的商务通 provider 实现将位于 `backend/internal/channel/provider/shangwutong.go`。
+10
View File
@@ -0,0 +1,10 @@
# 微信渠道(预留)
此目录预留给微信渠道对接。当前未实现。
微信渠道分为三种形态,各自需要独立的 provider 实现:
- **微信公众号** — 对接微信公众平台 API(订阅号/服务号消息)
- **微信小程序** — 对接小程序客服消息接口
- **企业微信** — 对接企业微信应用消息/客户消息 API
GoChat 后端的微信 provider 实现将位于 `backend/internal/channel/provider/weixin.go`。
+10
View File
@@ -0,0 +1,10 @@
# 小红书渠道(预留)
此目录预留给小红书渠道对接。当前未实现。
小红书渠道需要对接小红书开放平台 API,包括:
- 私信消息收发
- 笔记评论消息
- 客服会话管理
GoChat 后端的小红书 provider 实现将位于 `backend/internal/channel/provider/xiaohongshu.go`。