add fake channel
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 抖音渠道(预留)
|
||||
|
||||
此目录预留给抖音渠道对接。当前未实现。
|
||||
|
||||
抖音渠道需要对接抖音开放平台(巨量引擎)API,包括:
|
||||
- 私信消息收发
|
||||
- 创作者回复
|
||||
- 视频评论消息
|
||||
|
||||
GoChat 后端的抖音 provider 实现将位于 `backend/internal/channel/provider/douyin.go`。
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)'}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# QQ 渠道(预留)
|
||||
|
||||
此目录预留给 QQ 渠道对接。当前未实现。
|
||||
|
||||
QQ 渠道需要对接腾讯 QQ 开放平台 API,包括:
|
||||
- 消息收发(文本/图片/文件)
|
||||
- 群消息/私聊消息
|
||||
- 好友列表同步
|
||||
|
||||
GoChat 后端的 QQ provider 实现将位于 `backend/internal/channel/provider/qq.go`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 商务通渠道(预留)
|
||||
|
||||
此目录预留给商务通渠道对接。当前未实现。
|
||||
|
||||
商务通是常用的在线客服 SaaS 平台,对接需要:
|
||||
- 客服上下线状态同步
|
||||
- 客户对话消息桥接
|
||||
- 正在输入/停止输入状态传递
|
||||
|
||||
GoChat 后端的商务通 provider 实现将位于 `backend/internal/channel/provider/shangwutong.go`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 微信渠道(预留)
|
||||
|
||||
此目录预留给微信渠道对接。当前未实现。
|
||||
|
||||
微信渠道分为三种形态,各自需要独立的 provider 实现:
|
||||
- **微信公众号** — 对接微信公众平台 API(订阅号/服务号消息)
|
||||
- **微信小程序** — 对接小程序客服消息接口
|
||||
- **企业微信** — 对接企业微信应用消息/客户消息 API
|
||||
|
||||
GoChat 后端的微信 provider 实现将位于 `backend/internal/channel/provider/weixin.go`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 小红书渠道(预留)
|
||||
|
||||
此目录预留给小红书渠道对接。当前未实现。
|
||||
|
||||
小红书渠道需要对接小红书开放平台 API,包括:
|
||||
- 私信消息收发
|
||||
- 笔记评论消息
|
||||
- 客服会话管理
|
||||
|
||||
GoChat 后端的小红书 provider 实现将位于 `backend/internal/channel/provider/xiaohongshu.go`。
|
||||
Reference in New Issue
Block a user