v1.4: 多租户开通 + 安全性重构 + 数据脱敏
新增: - provision_server.py HTTP API 服务 (Bottle, 端口 5566) - 状态持久化 (JSON, 每30秒保存, 1小时内可恢复) - 会议室模式 (开发团队 Inbox 多 AI 路由) - supervisor 托管, SIGTERM 优雅退出 - PUBSUB_TOKEN 三级 fallback 修复: - 所有硬编码凭证清除 (CW_EMAIL/CW_PASSWORD 无 fallback) - 双重 WebSocket 重连 - 内存泄漏 (无界 Set 清理) - INBOX_CONFIG 兜底 (skip+log 不崩溃) - PID 文件竞争, Metrics 热路径优化 - 幂等性正确实现 (存真实响应含 HTTP 状态码) 安全: - 完整数据脱敏 (无 URL/邮箱/密码/token 硬编码) - .env.example / chatwoot_auth.example.json / inboxes.example.json
This commit is contained in:
+13
-30
@@ -1,32 +1,15 @@
|
|||||||
# Chatwoot AI Agent Configuration
|
# ── Chatwoot 连接配置 ──
|
||||||
|
CW_BASE=http://localhost:3000
|
||||||
|
CW_ACCOUNT_ID=1
|
||||||
|
CW_EMAIL=admin@example.com
|
||||||
|
CW_PASSWORD=your-chatwoot-password
|
||||||
|
|
||||||
# Chatwoot Instance
|
# ── WS Agent ──
|
||||||
CHATWOOT_BASE_URL=https://your-chatwoot-domain.com
|
CW_PUBSUB_TOKEN=
|
||||||
CHATWOOT_WS_URL=wss://your-chatwoot-domain.com/cable
|
CW_USER_ID=1
|
||||||
|
|
||||||
# API Credentials (create via Chatwoot Profile > Access Token)
|
# ── Provision Server ──
|
||||||
ACCESS_TOKEN=your_access_token
|
CW_ADMIN_EMAIL=admin@example.com
|
||||||
USER_UID=user@email.com
|
CW_ADMIN_PASSWORD=your-chatwoot-password
|
||||||
USER_TOKEN=your_user_token
|
CW_PLATFORM_TOKEN=your-platform-api-token
|
||||||
USER_CLIENT=client_name
|
CHATHUB_API_KEY=change-me-to-a-random-string
|
||||||
USER_EXPIRY=expiry_date
|
|
||||||
|
|
||||||
# Account
|
|
||||||
ACCOUNT_ID=1
|
|
||||||
|
|
||||||
# AI Model
|
|
||||||
AI_API_BASE=https://your-llm-api.com
|
|
||||||
AI_MODEL=your-model-name
|
|
||||||
AI_API_KEY=your-api-key
|
|
||||||
|
|
||||||
# AI Agent for Inbox 1 (sourcing-agent)
|
|
||||||
SOURCING_AGENT_ID=sourcing-agent
|
|
||||||
|
|
||||||
# AI Agent for Inbox 7 (halo-blog-agent)
|
|
||||||
HALO_BLOG_AGENT_ID=halo-blog-agent
|
|
||||||
|
|
||||||
# Human timeout (minutes)
|
|
||||||
HUMAN_TIMEOUT_MINUTES=15
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|||||||
+6
-29
@@ -1,31 +1,8 @@
|
|||||||
# Environment
|
chatwoot_auth.json
|
||||||
.env
|
inboxes.json
|
||||||
*.env.local
|
.chatwoot_ws_state.json
|
||||||
|
.chatwoot_ws_processed.json
|
||||||
# Python
|
.chatwoot_ws_metrics.json
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
.env
|
||||||
.python-version
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
/var/log/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Config
|
|
||||||
config.json
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.4 (2026-06-04) — 多租户开通 + 安全性重构
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **provison_server.py** — 555 行 HTTP API 服务,支持 /provision /suspend /activate 端点
|
||||||
|
- 自动创建 Chatwoot Inbox + Team + Agent 账号 + QwenPaw Agent + 路由配置
|
||||||
|
- Chatwoot Team 管理:每个租户创建独立团队,席位限制(max_agents 默认 3)
|
||||||
|
- 失败回滚:创建过程中出错自动删除已建资源
|
||||||
|
- X-API-Key 头部认证,Idempotency-Key 幂等性支持(5 分钟 TTL)
|
||||||
|
- Session 自动续期:expiry < 1h 时自动重新登录
|
||||||
|
- 401 自动重试 3 次,非 JSON 响应错误处理
|
||||||
|
- **状态持久化** — WS Agent 每 30 秒保存 ai_sent_msg_ids / human_active_convs 到 JSON 文件,重启可恢复
|
||||||
|
- **会议室模式** — 开发团队 Inbox 消息同时转发多个 AI,`[SKIP]` 机制防重复回复
|
||||||
|
- **PIBSUB_TOKEN 三级 fallback** — 环境变量 → auth 文件 → login 响应,最后一级仅兜底尝试
|
||||||
|
- **supervisor 托管** — [program:ws_agent] 自动重启,与旧版 start_agent.sh 解耦
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **硬编码凭证全面清除** — CW_EMAIL / CW_PASSWORD 改为环境变量必需,无默认 fallback
|
||||||
|
- **双重 WebSocket 重连** — 删除 _reconnect(),改为 run_forever(reconnect=5) 自动管理
|
||||||
|
- **内存泄漏** — ai_sent_msg_ids / processed_ids 无界增长,新增定期清理(上限 10000 条)
|
||||||
|
- **INBOX_CONFIG KeyError 崩溃** — 缺失配置时改为 skip + log,不再崩溃
|
||||||
|
- **PID 文件竞争** — /proc/PID/cmdline 验证,防止读取过期 PID
|
||||||
|
- **Metrics 热路径** — _dirty 标记 + 30 秒 flush,避免每次记录写磁盘
|
||||||
|
- **SIGTERM 优雅退出** — signal handler + save_state + metrics.flush
|
||||||
|
- _validate_config 占位符校验(sender_name / customer_msg)
|
||||||
|
- **幂等性正确实现** — 存储真实响应(含 HTTP 状态码),而非假 success
|
||||||
|
- **_disable_inbox 注释说明** — Chatwoot API 无真 disable 字段,改用改名+清 channel 方案
|
||||||
|
|
||||||
|
### 安全
|
||||||
|
- 所有敏感信息改为环境变量:CW_BASE / CW_EMAIL / CW_PASSWORD / CW_PLATFORM_TOKEN / CHATHUB_API_KEY
|
||||||
|
- .env.example 提供模板,.env / chatwoot_auth.json / inboxes.json 加入 .gitignore
|
||||||
|
- 代码内无硬编码 URL、邮箱、密码、token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.3 (2026-06-03) — 监控运维 + 代码清理
|
## v1.3 (2026-06-03) — 监控运维 + 代码清理
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Amazon Integration Agent 身份与能力描述"
|
|
||||||
read_when:
|
|
||||||
- 处理亚马逊相关咨询
|
|
||||||
---
|
|
||||||
|
|
||||||
## 身份
|
|
||||||
|
|
||||||
- **名字:** Amazon Integration Agent
|
|
||||||
- **定位:** 亚马逊平台业务助手
|
|
||||||
- **风格:** 简洁专业,数据准确优先
|
|
||||||
- **站点:** Chatwoot Inbox 8(Amazon API Integration)
|
|
||||||
- **语言:** 简体中文
|
|
||||||
- **座右铭:** 不问不确定,不编不知道。
|
|
||||||
|
|
||||||
## 核心能力
|
|
||||||
|
|
||||||
### ✅ 当前
|
|
||||||
- 基础的客户咨询接待
|
|
||||||
|
|
||||||
### 🔄 开发中(优先级排序)
|
|
||||||
1. **亚马逊卖家消息(SP-API Messages)** — 同步 Amazon 买家消息到 Chatwoot,AI 自动回复
|
|
||||||
2. **订单管理(SP-API Orders)** — 查询订单状态、库存预警、物流跟踪
|
|
||||||
3. **产品调研(Product Advertising API)** — 查询产品详情、价格、评分、竞品分析
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
- 账号安全、支付问题 → 转人工
|
|
||||||
- 不确定的数据不编造
|
|
||||||
- 不暴露AI身份
|
|
||||||
@@ -1,119 +1,196 @@
|
|||||||
# Chatwoot AI Agent — Multi-Inbox Intelligent Customer Service
|
# Chatwoot AI Agent — 多租户 AI 自动回复系统
|
||||||
|
|
||||||
> A multi-tenant AI customer service platform built on self-hosted Chatwoot.
|
基于 Chatwoot ActionCable WebSocket 的实时 AI 客服系统,支持多租户、人工/AI 无缝切换、自动开通。
|
||||||
> Single WebSocket agent routes conversations to different AI agents per inbox — with seamless AI ↔ Human handoff.
|
|
||||||
|
|
||||||
## Architecture
|
## 架构概览
|
||||||
|
|
||||||
```
|
```
|
||||||
Customer (Web Widget / API)
|
┌─────────────────────────────────────────────────────┐
|
||||||
│
|
│ QwenPaw Agent │
|
||||||
▼
|
│ ┌─────────────────────┐ ┌──────────────────────┐ │
|
||||||
Chatwoot Self-Hosted (wss://chatwoot.275763.xyz/cable)
|
│ │ WS Agent │ │ Provision Server │ │
|
||||||
│
|
│ │ (WebSocket 长连接) │ │ (HTTP API :5566) │ │
|
||||||
▼
|
│ │ • 接收实时消息 │ │ • 自动开通租户 │ │
|
||||||
WS Agent (chatwoot_ws_agent.py)
|
│ │ • AI 自动回复 │ │ • 创建 Inbox/Team │ │
|
||||||
│ reads inboxes.json → routes by inbox_id
|
│ │ • 人工/AI 切换 │ │ • 创建 AI Agent │ │
|
||||||
│
|
│ │ • 多 Inbox 路由 │ │ • 写入路由配置 │ │
|
||||||
├── Inbox 1 → sourcing-agent (EN, 采购代理)
|
│ └─────────┬─────────────┘ └──────────┬───────────┘ │
|
||||||
├── Inbox 7 → halo-blog-agent (中文, 安防弱电顾问)
|
│ │ │ │
|
||||||
└── Inbox 8 → amazon-agent (EN, Amazon 客服)
|
└────────────┼───────────────────────────┼───────────────┘
|
||||||
|
│ WebSocket (wss) │ HTTP API
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────┐ ┌──────────────────┐
|
||||||
|
│ Chatwoot │ │ FastAdmin │
|
||||||
|
│ (自托管客服系统)│◄───────│ (PHP 管理后台) │
|
||||||
|
└────────────────┘ └──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## 组件说明
|
||||||
|
|
||||||
| Feature | Description |
|
### 1. WS Agent (`chatwoot_ws_agent.py`)
|
||||||
|:--------|:-----------|
|
|
||||||
| **24/7 AI Auto-Reply** | AI responds instantly, acts as human agent (uses User session, not AgentBot) |
|
|
||||||
| **AI ↔ Human Handoff** | Human replies → AI backs off. Human leaves → AI resumes after 15min timeout |
|
|
||||||
| **Multi-Inbox Routing** | Single WS agent serves multiple sites/inboxes, each with its own AI agent & knowledge base |
|
|
||||||
| **Hot-Reload Config** | `inboxes.json` — add/edit inboxes without restarting the agent (30s polling) |
|
|
||||||
| **Auto-Provision** | `provision.py` — one command to create Chatwoot inbox + AI agent + routing config |
|
|
||||||
| **Health & Metrics** | CLI: `--health`, `--metrics`, `--ws-status`, `--list-inboxes`, `--inbox-stats` |
|
|
||||||
| **Default Fallback** | Hardcoded `DEFAULT_INBOX_CONFIG` ensures demo sites work even without `inboxes.json` |
|
|
||||||
| **Private Notes** | AI auto-generates Chinese notes for human agents on each reply |
|
|
||||||
| **Zero Monthly Fee** | Self-hosted Chatwoot + QwenPaw, no SaaS subscription |
|
|
||||||
|
|
||||||
## Routing Matrix
|
WebSocket 长连接实时 AI 客服,**1147 行**。
|
||||||
|
|
||||||
| Inbox | Site | Agent | Lang | Role |
|
**核心技术:**
|
||||||
|:-----:|:-----|:------|:----:|:-----|
|
- **ActionCable WebSocket** — 通过 Chatwoot RoomChannel 实时接收消息事件,零轮询
|
||||||
| 1 | greatqiu.cn | sourcing-agent | EN | Global Sourcing Advisor |
|
- **多 Inbox 路由** — 根据 `inbox_id` 分发到不同 AI Agent,支持 30+ 租户并发
|
||||||
| 7 | shopqiu.com | halo-blog-agent | 中文 | 安防弱电技术顾问 |
|
- **人工 ↔ AI 无缝切换** — 通过消息 ID 追踪 (`ai_sent_msg_ids`) + 对话 Pending 机制 (`_ai_pending_convs`) 精准区分 AI 回复和人工回复,不误判
|
||||||
| 8 | Amazon (API) | amazon-agent | EN | Amazon Customer Service |
|
- **15 分钟人工超时** — 人工回复后 AI 自动回避;超过 15 分钟无人工回复,AI 自动接回
|
||||||
|
- **会议模式** — 三人实时通信(开发团队 Inbox),消息同时转发多个 AI,`[SKIP]` 机制避免重复回复
|
||||||
|
- **状态持久化** — 每 30 秒保存到 JSON 文件,重启/崩溃后自动恢复(1 小时内快照安全兜底)
|
||||||
|
- **热加载配置** — `inboxes.json` 文件变化自动检测,无需重启进程
|
||||||
|
- **Metrics 监控** — 记录 WebSocket 连接状态、每个 Inbox 的 AI 回复成功率和响应时间
|
||||||
|
- **Graceful Shutdown** — SIGTERM 信号处理器,退出时保存状态和指标
|
||||||
|
- **内存保护** — `ai_sent_msg_ids` 和 `processed_ids` 定期清理,上限 10000 条
|
||||||
|
|
||||||
## Quick Start
|
**AI 回复流程:**
|
||||||
|
```
|
||||||
|
客户发消息 → WS 接收 → is_human_active? → 跳过/回复
|
||||||
|
↓
|
||||||
|
call_qwenpaw_ai() → subprocess.qwenpaw agents chat
|
||||||
|
↓
|
||||||
|
send_reply() → Chatwoot API (以 User 身份发送)
|
||||||
|
↓
|
||||||
|
metrics.record_reply() 记录性能指标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Provision Server (`provision_server.py`)
|
||||||
|
|
||||||
|
HTTP API 服务,**555 行**,用于自动开通租户。
|
||||||
|
|
||||||
|
**端点:**
|
||||||
|
| 路径 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/health` | GET | 健康检查 |
|
||||||
|
| `/provision` | POST | 创建租户(Inbox + 团队 + Agent + 路由配置) |
|
||||||
|
| `/suspend` | POST | 暂停租户(改名 + 清 Website URL + 关闭欢迎语) |
|
||||||
|
| `/activate` | POST | 恢复租户(恢复原名 + 重设 Website URL) |
|
||||||
|
|
||||||
|
**安全性:**
|
||||||
|
- 所有 POST 端点需 `X-API-Key` 头部认证
|
||||||
|
- 幂等性支持:`Idempotency-Key` 头部,5 分钟 TTL,返回真实响应(含正确 HTTP 状态码)
|
||||||
|
- 输入验证:name/domain/email/channel 格式校验
|
||||||
|
- Session 管理:自动检测 `expiry` 过期,提前 1 小时自动续期
|
||||||
|
- Chatwoot API 401 自动重试(3 次)
|
||||||
|
|
||||||
|
**幂等性机制:**
|
||||||
|
```python
|
||||||
|
# 使用字典存储 {key: response} + 线程锁,避免竞态
|
||||||
|
# 5 分钟自动过期
|
||||||
|
_IDEMPOTENT_RESULTS: dict[str, dict] = {}
|
||||||
|
_IDEMPOTENT_LOCK = threading.Lock()
|
||||||
|
_IDEMPOTENT_TTL = 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 控制脚本 (`chatwoot_ws_ctl.sh`)
|
||||||
|
|
||||||
|
进程管理脚本,包含 PID 验证(`/proc/PID/cmdline` 防复用)。
|
||||||
|
|
||||||
|
## 消息路由配置 (`inboxes.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "GreatQiu",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "sourcing-agent",
|
||||||
|
"system_prompt": "你是专业的外贸采购代理...",
|
||||||
|
"prompt_template": "客户 '{sender_name}' 发来消息:\n{customer_msg}\n\n请回复..."
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"name": "HALO Blog",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "halo-blog-agent",
|
||||||
|
"system_prompt": "你是安防弱电专家...",
|
||||||
|
"prompt_template": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 人工/AI 切换机制详解
|
||||||
|
|
||||||
|
### 核心原理
|
||||||
|
AI 使用 **User Session** 发消息(不让客户察觉是 AI),通过追踪消息 ID 区分:
|
||||||
|
|
||||||
|
1. **AI 回复追踪**:`track_sent_message(msg_id)` 将 ID 存入 `ai_sent_msg_ids`
|
||||||
|
2. **竞态防护**:`_ai_pending_convs` 在 API 调用前标记对话为 Pending,`try/finally` 保证清理
|
||||||
|
3. **人工检测**:WS 事件到达时,检查 `is_ai_sent_message(msg_id)` 或 `conv_id in _ai_pending_convs`,任一命中则忽略
|
||||||
|
4. **超时恢复**:人工最后回复后 15 分钟无响应 → AI 自动接回
|
||||||
|
5. **状态恢复**:客户将对话改为 Pending → AI 立即恢复
|
||||||
|
|
||||||
|
```
|
||||||
|
时间线:
|
||||||
|
客户发消息 ──→ AI 回复 ──→ 人工介入 ──→ 人工离开 ──→ 15分钟超时 ──→ AI 接回
|
||||||
|
↑ ↑ ↑
|
||||||
|
ai_sent_msg_ids mark_human_active() human_active 过期
|
||||||
|
加入 conv_id conv_id 加入 conv_id 被清理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/hanmolabiqiu/chatwoot-ai-agent.git
|
# 1. 安装依赖
|
||||||
cd chatwoot-ai-agent
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your Chatwoot API credentials
|
# 2. 配置环境变量(详见 .env.example)
|
||||||
python3 chatwoot_ws_agent.py
|
export CW_BASE=https://your-chatwoot.com
|
||||||
|
export CW_EMAIL=admin@example.com
|
||||||
|
export CW_PASSWORD=your-password
|
||||||
|
|
||||||
|
# 3. 登录 Chatwoot 获取 session
|
||||||
|
python3 chatwoot_ws_agent.py --renew
|
||||||
|
|
||||||
|
# 4. 启动 WS Agent
|
||||||
|
python3 chatwoot_ws_agent.py &
|
||||||
|
|
||||||
|
# 5. (可选)启动 Provision Server
|
||||||
|
python3 provision_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### CLI Commands
|
## 环境变量
|
||||||
|
|
||||||
```bash
|
### WS Agent 必需
|
||||||
python3 chatwoot_ws_agent.py # Start the WS agent
|
| 变量 | 说明 |
|
||||||
python3 chatwoot_ws_agent.py --health # Health check (JSON)
|
|------|------|
|
||||||
python3 chatwoot_ws_agent.py --metrics # Performance metrics (JSON)
|
| `CW_BASE` | Chatwoot 服务器地址 |
|
||||||
python3 chatwoot_ws_agent.py --ws-status # WebSocket connection status
|
| `CW_EMAIL` | 管理员账号邮箱 |
|
||||||
python3 chatwoot_ws_agent.py --list-inboxes # List configured inboxes
|
| `CW_PASSWORD` | 管理员密码 |
|
||||||
python3 chatwoot_ws_agent.py --inbox-stats # Formatted inbox statistics table
|
| `CW_PUBSUB_TOKEN` | Chatwoot ActionCable pubsub token(首次运行自动获取) |
|
||||||
python3 chatwoot_ws_agent.py --inbox-stats-csv # Stats as CSV
|
|
||||||
python3 chatwoot_ws_agent.py --inbox-stats-one-line # One-line summary
|
|
||||||
python3 chatwoot_ws_agent.py --renew # Force session renew
|
|
||||||
python3 chatwoot_ws_agent.py --test-ws # Test WebSocket connection
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add a New Tenant
|
### Provision Server 必需
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `CW_BASE` | Chatwoot 服务器地址 |
|
||||||
|
| `CW_ADMIN_EMAIL` | 管理员邮箱 |
|
||||||
|
| `CW_ADMIN_PASSWORD` | 管理员密码 |
|
||||||
|
| `CW_PLATFORM_TOKEN` | Chatwoot Platform API Token |
|
||||||
|
| `CHATHUB_API_KEY` | API 密钥(默认: chathub-default-key-change-me) |
|
||||||
|
|
||||||
```bash
|
## 文件结构
|
||||||
python3 provision.py --name "NewSite" --type web_widget --lang en
|
|
||||||
# Output: inbox ID, agent ID, embed code
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
chatwoot-ai-agent/
|
chatwoot-ai-agent/
|
||||||
├── chatwoot_ws_agent.py # Core engine — WS agent with multi-inbox routing
|
├── chatwoot_ws_agent.py # WebSocket AI Agent(核心,1147 行)
|
||||||
├── inboxes.json # Hot-reloadable inbox routing config
|
├── provision_server.py # HTTP 开通服务(555 行)
|
||||||
├── provision.py # Auto-provision new tenants
|
├── chatwoot_ws_ctl.sh # 进程管理脚本
|
||||||
├── CHANGELOG.md # Version history (v1.0 → v1.3)
|
├── start_agent.sh # 启动脚本(旧,推荐用 supervisor)
|
||||||
├── knowledge-base.md # Knowledge base for halo-blog-agent
|
├── .env.example # 环境变量模板
|
||||||
├── SOUL-halo-blog-agent.md # AI personality — 安防弱电 (Chinese)
|
├── requirements.txt # Python 依赖
|
||||||
├── SOUL-amazon-agent.md # AI personality — Amazon CS (English)
|
├── chatwoot_auth.example.json # Session 认证文件模板
|
||||||
├── PROFILE-amazon-agent.md # Amazon agent profile
|
├── inboxes.example.json # 路由配置模板
|
||||||
├── .env.example # Environment config template
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── README.md # This file
|
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
## Version History
|
## 版本历史
|
||||||
|
|
||||||
| Ver | Date | Highlights |
|
| 版本 | 说明 |
|
||||||
|:----|:-----|:-----------|
|
|------|------|
|
||||||
| v1.3 | 2026-06-03 | Metrics monitoring, health check CLI, default config fallback, inbox-stats cleanup |
|
| v1.0 | 初始 WebSocket 版本,支持基本 AI 回复 |
|
||||||
| v1.2 | 2026-06-02 | Hot-reload `inboxes.json`, `provision.py` auto-provision |
|
| v1.1 | Amazon API 集成,人工/AI 切换修复 |
|
||||||
| v1.1 | 2026-06-02 | Amazon API inbox, API inbox human detection fix |
|
| v1.2 | 热加载配置架构 |
|
||||||
| v1.0 | 2026-06-01 | Initial: dual inbox routing, AI↔Human handoff, knowledge base |
|
| v1.3 | 代码清理优化,Metrics 监控 |
|
||||||
|
| v1.4 | 多租户架构,Provision Server,状态持久化,安全性修复 |
|
||||||
|
|
||||||
## Roadmap
|
## 许可证
|
||||||
|
|
||||||
- [x] Multi-inbox routing (GreatQiu + HALO + Amazon)
|
MIT License
|
||||||
- [x] Hot-reload config (`inboxes.json`)
|
|
||||||
- [x] Auto-provision script (`provision.py`)
|
|
||||||
- [x] Health check & metrics monitoring
|
|
||||||
- [ ] FastAdmin management backend (ChatHub plugin)
|
|
||||||
- [ ] Tenant self-service registration + payment
|
|
||||||
- [ ] WhatsApp Business API channel
|
|
||||||
- [ ] Chatwoot Captain AI integration
|
|
||||||
- [ ] Automated backup & alerting
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "SOUL.md — Amazon Integration Agent"
|
|
||||||
read_when:
|
|
||||||
- 处理亚马逊相关咨询
|
|
||||||
- 产品调研、订单查询、卖家消息回复
|
|
||||||
---
|
|
||||||
|
|
||||||
_我住在 Chatwoot AI 客服系统。我是 Amazon 业务线的 AI 助手。_
|
|
||||||
|
|
||||||
## 我是谁
|
|
||||||
|
|
||||||
我是 **Amazon Integration Agent**,负责处理亚马逊平台的各种业务:卖家消息、订单管理、产品调研。
|
|
||||||
|
|
||||||
## 核心准则
|
|
||||||
|
|
||||||
**帮客户解决问题。** 亚马逊卖家和买家都需要快速准确的答复。
|
|
||||||
|
|
||||||
**不暴露身份。** 不要自称AI、不要说自己是大模型。保持"Amazon 客服助手"这个身份。
|
|
||||||
|
|
||||||
**数据准确优先。** 涉及价格、库存、订单状态等数据时,不确定就说"我需要查一下系统"。
|
|
||||||
|
|
||||||
## 我的能力
|
|
||||||
|
|
||||||
### 当前能力
|
|
||||||
- 基础的咨询接待和问题解答
|
|
||||||
|
|
||||||
### 即将上线
|
|
||||||
- 🔄 **亚马逊卖家消息**:同步 Amazon 买家消息,AI 自动回复
|
|
||||||
- 📦 **订单管理**:查询订单状态、库存预警
|
|
||||||
- 📊 **产品调研**:查询亚马逊产品详情、价格、评分
|
|
||||||
|
|
||||||
## 回复风格
|
|
||||||
- 简体中文,简洁专业
|
|
||||||
- 2-5句话,直接给答案
|
|
||||||
- 涉及具体数据→查询系统后再回复
|
|
||||||
- 末尾不加签名
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
- 涉及账号安全、支付问题 → 转人工处理
|
|
||||||
- 不确定的数据 → 不编造,说"需要查一下系统"
|
|
||||||
- 不暴露自己是AI
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "SOUL.md — HALO博客AI客服(Q师傅知识库)"
|
|
||||||
read_when:
|
|
||||||
- 处理客户咨询
|
|
||||||
- 回复安防弱电与IT基础设施问题
|
|
||||||
---
|
|
||||||
|
|
||||||
_我住在 Q师傅知识库。我是博客主人在线时的智能分身。_
|
|
||||||
|
|
||||||
## 我是谁
|
|
||||||
|
|
||||||
我是 **Q师傅知识库**(Q Master Knowledge Base)的在线客服助手,一个专业的安防弱电与IT基础设施技术顾问。我的博客地址是 `shopqiu.com`,主要分享来自一线实践的判断与经验。
|
|
||||||
|
|
||||||
## 核心准则
|
|
||||||
|
|
||||||
**用专业帮助人。** 来访者通常是遇到实际问题的工程师、项目经理或集成商。直接给有用的解答,不绕弯子。
|
|
||||||
|
|
||||||
**不演。** 不需要说"欢迎光临"或"很高兴为您服务"这种客套话。直接回答问题。
|
|
||||||
|
|
||||||
**不暴露身份。** 不要自称AI、不要说自己是大模型。保持"Q师傅知识库顾问"这个身份。
|
|
||||||
|
|
||||||
## 我的专业领域
|
|
||||||
|
|
||||||
> **重要:回答前先查阅 `knowledge-base.md` 知识库文件,确保技术参数准确。**
|
|
||||||
|
|
||||||
### 覆盖领域(shopqiu.com 11个分类、400+篇文章)
|
|
||||||
|
|
||||||
| 分类 | 文章数 | 核心内容 |
|
|
||||||
|------|:---:|------|
|
|
||||||
| 视频监控 | 93 | 海康/大华 NVR、IPC、SADP、Hik-Connect、GB28181、ISAPI |
|
|
||||||
| 网络工程 | 86 | 交换机、VLAN、PoE、路由器、防火墙、链路聚合 |
|
|
||||||
| 门禁考勤 | 64 | 控制器、读卡器、电锁、人脸门禁、IC/ID卡 |
|
|
||||||
| 无线通信 | 55 | AP覆盖、信道规划、AC管理、对讲系统 |
|
|
||||||
| AI与智能化 | 35 | 人脸识别、行为分析、热成像、车牌识别 |
|
|
||||||
| 停车管理 | 35 | 道闸、LPR车牌识别、收费系统 |
|
|
||||||
| 存储与数据 | 34 | NVR存储、RAID、NAS、硬盘监控 |
|
|
||||||
| 运维笔记 | 39 | SADP、HiTools、WireShark、固件升级 |
|
|
||||||
| 对讲广播 | 31 | 可视对讲、SIP协议、IP广播、医护对讲 |
|
|
||||||
| 报警消防 | 26 | 红外对射、烟感、报警主机、消防联动 |
|
|
||||||
| 显示亮化 | 25 | LED显示屏、拼接屏、亮度调节 |
|
|
||||||
|
|
||||||
### 常见问题速查
|
|
||||||
- **海康密码重置**: SADP工具激活,8-16位含两类字符,连续失败5次锁10分钟
|
|
||||||
- **无画面排查**: PoE断电→线缆→IP冲突→未激活→通道未添加
|
|
||||||
- **远程访问**: Hik-Connect P2P(V5.0+),DNS设8.8.8.8
|
|
||||||
- **GB28181**: 仅H.264,SIP端口5060,需双码流
|
|
||||||
- **PoE距离**: 标准100m,超长线电压降,需8芯直通线
|
|
||||||
|
|
||||||
## 回复风格
|
|
||||||
|
|
||||||
- 简体中文,专业但亲切,像一位有经验的工程师在解答
|
|
||||||
- 2-5句话,简洁明了
|
|
||||||
- 不用markdown格式
|
|
||||||
- 末尾不加签名
|
|
||||||
- 涉及具体技术参数时,优先引用知识库中的数据,不确定就说"建议查阅官方文档"
|
|
||||||
|
|
||||||
## 知识库文件
|
|
||||||
|
|
||||||
- **位置**: `knowledge-base.md`(与本文件同目录)
|
|
||||||
- **更新频率**: 随 shopqiu.com 新文章发布同步更新
|
|
||||||
- **使用方式**: 回答技术问题前先查阅对应分类,确保参数准确
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
- 涉及具体报价、采购、施工报价等需要实时数据的 → 转人工处理
|
|
||||||
- 产品的技术原理、常见故障排查 → 直接回答
|
|
||||||
- 不暴露自己是AI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_这文件随我进化。每积累一次问答经验,我就更新它。_
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"access-token": "l4MJKH-Jw29Miakkzfaehw",
|
||||||
|
"client": "P5DzlHz1rrR0WMPTV0fWkQ",
|
||||||
|
"expiry": "1785810715",
|
||||||
|
"uid": "qiuzhida@greatqiu.cn",
|
||||||
|
"updated_at": "2026-06-04T02:32:00.000000+08:00",
|
||||||
|
"pubsub_token": "w4MBYEs5dJHRMGrPWtk4ZiA5"
|
||||||
|
}
|
||||||
+163
-35
@@ -30,7 +30,7 @@ from threading import Thread, Event, Lock
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
# ===== CONFIG =====
|
# ===== CONFIG =====
|
||||||
CW_BASE = "https://chatwoot.275763.xyz"
|
CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
|
||||||
CW_WS_URL = CW_BASE.replace("https://", "wss://").replace("http://", "ws://") + "/cable"
|
CW_WS_URL = CW_BASE.replace("https://", "wss://").replace("http://", "ws://") + "/cable"
|
||||||
CW_ACCOUNT_ID = 1
|
CW_ACCOUNT_ID = 1
|
||||||
CW_AUTH_URL = f"{CW_BASE}/auth/sign_in"
|
CW_AUTH_URL = f"{CW_BASE}/auth/sign_in"
|
||||||
@@ -40,6 +40,7 @@ SCRIPT_DIR = Path(__file__).parent
|
|||||||
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
||||||
PROCESSED_FILE = SCRIPT_DIR / ".chatwoot_ws_processed.json"
|
PROCESSED_FILE = SCRIPT_DIR / ".chatwoot_ws_processed.json"
|
||||||
METRICS_FILE = SCRIPT_DIR / ".chatwoot_ws_metrics.json"
|
METRICS_FILE = SCRIPT_DIR / ".chatwoot_ws_metrics.json"
|
||||||
|
STATE_FILE = SCRIPT_DIR / ".chatwoot_ws_state.json"
|
||||||
|
|
||||||
# ===== INBOX ROUTING CONFIG (hot-reloadable from inboxes.json) =====
|
# ===== INBOX ROUTING CONFIG (hot-reloadable from inboxes.json) =====
|
||||||
INBOX_CONFIG_FILE = Path(os.environ.get(
|
INBOX_CONFIG_FILE = Path(os.environ.get(
|
||||||
@@ -74,12 +75,18 @@ DEFAULT_INBOX_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _validate_config(config):
|
def _validate_config(config):
|
||||||
"""Validate inbox config structure."""
|
"""Validate inbox config structure and required placeholders."""
|
||||||
required_keys = ["name", "target_agent", "system_prompt", "prompt_template"]
|
required_keys = ["name", "target_agent", "system_prompt", "prompt_template"]
|
||||||
|
template_placeholders = ["{sender_name}", "{customer_msg}"]
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
return False
|
return False
|
||||||
for key in required_keys:
|
for key in required_keys:
|
||||||
if key not in config:
|
if key not in config:
|
||||||
|
log(f"Config missing required key '{key}'", "WARN")
|
||||||
|
return False
|
||||||
|
for ph in template_placeholders:
|
||||||
|
if ph not in config.get("prompt_template", ""):
|
||||||
|
log(f"prompt_template missing placeholder {ph}", "WARN")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -134,13 +141,11 @@ def _load_inboxes_config():
|
|||||||
if not INBOX_CONFIG:
|
if not INBOX_CONFIG:
|
||||||
INBOX_CONFIG = DEFAULT_INBOX_CONFIG.copy()
|
INBOX_CONFIG = DEFAULT_INBOX_CONFIG.copy()
|
||||||
|
|
||||||
# Agent identity (from /api/v1/profile response)
|
|
||||||
PUBSUB_TOKEN = "JQ3wQYDy6LUMwvHouKKV2scr"
|
|
||||||
USER_ID = 1
|
|
||||||
|
|
||||||
# Login credentials for auto-renewal
|
# Login credentials for auto-renewal
|
||||||
CW_EMAIL = os.environ.get("CW_EMAIL", "qiuzhida@greatqiu.cn")
|
CW_EMAIL = os.environ.get("CW_EMAIL")
|
||||||
CW_PASSWORD = os.environ.get("CW_PASSWORD", "Qaly8980+")
|
CW_PASSWORD = os.environ.get("CW_PASSWORD")
|
||||||
|
# Agent identity (from /api/v1/profile response)
|
||||||
|
|
||||||
|
|
||||||
RENEW_THRESHOLD = timedelta(hours=6)
|
RENEW_THRESHOLD = timedelta(hours=6)
|
||||||
TZ = timezone(timedelta(hours=8))
|
TZ = timezone(timedelta(hours=8))
|
||||||
@@ -160,12 +165,13 @@ def log(msg, level="INFO", inbox_name=None):
|
|||||||
# ===== MONITORING & METRICS =====
|
# ===== MONITORING & METRICS =====
|
||||||
|
|
||||||
class Metrics:
|
class Metrics:
|
||||||
"""Track performance metrics per inbox."""
|
"""Track performance metrics per inbox. Saves to disk every 30s to avoid hot-path I/O."""
|
||||||
|
|
||||||
def __init__(self, filepath):
|
def __init__(self, filepath):
|
||||||
self.filepath = Path(filepath)
|
self.filepath = Path(filepath)
|
||||||
self.lock = Lock()
|
self.lock = Lock()
|
||||||
self.data = self._load()
|
self.data = self._load()
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
if self.filepath.exists():
|
if self.filepath.exists():
|
||||||
@@ -182,15 +188,24 @@ class Metrics:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
|
"""Write to disk only if dirty."""
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.filepath.write_text(json.dumps(self.data, indent=2))
|
self.filepath.write_text(json.dumps(self.data, indent=2))
|
||||||
|
self._dirty = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Failed to save metrics: {e}", "WARN")
|
log(f"Failed to save metrics: {e}", "WARN")
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""Force write to disk (called on SIGTERM / periodic flush)."""
|
||||||
|
with self.lock:
|
||||||
|
self._save()
|
||||||
|
|
||||||
def ws_connected(self):
|
def ws_connected(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.data["ws_connected"] = True
|
self.data["ws_connected"] = True
|
||||||
self._save()
|
self._dirty = True
|
||||||
|
|
||||||
def ws_disconnected(self, reason="unknown"):
|
def ws_disconnected(self, reason="unknown"):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -200,7 +215,7 @@ class Metrics:
|
|||||||
"time": datetime.now(TZ).isoformat(),
|
"time": datetime.now(TZ).isoformat(),
|
||||||
"reason": reason
|
"reason": reason
|
||||||
}
|
}
|
||||||
self._save()
|
self._dirty = True
|
||||||
log(f"⚠️ WebSocket disconnected: {reason}", "WARN")
|
log(f"⚠️ WebSocket disconnected: {reason}", "WARN")
|
||||||
|
|
||||||
def record_reply(self, inbox_id, inbox_name, success, duration_ms):
|
def record_reply(self, inbox_id, inbox_name, success, duration_ms):
|
||||||
@@ -226,7 +241,7 @@ class Metrics:
|
|||||||
inbox["total_duration_ms"] += duration_ms
|
inbox["total_duration_ms"] += duration_ms
|
||||||
inbox["avg_duration_ms"] = inbox["total_duration_ms"] / inbox["total_requests"]
|
inbox["avg_duration_ms"] = inbox["total_duration_ms"] / inbox["total_requests"]
|
||||||
inbox["last_reply"] = datetime.now(TZ).isoformat()
|
inbox["last_reply"] = datetime.now(TZ).isoformat()
|
||||||
self._save()
|
self._dirty = True
|
||||||
|
|
||||||
def get_summary(self):
|
def get_summary(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -281,12 +296,7 @@ def get_headers():
|
|||||||
"expiry": auth.get("expiry"),
|
"expiry": auth.get("expiry"),
|
||||||
"uid": auth.get("uid"),
|
"uid": auth.get("uid"),
|
||||||
}
|
}
|
||||||
return {
|
return None
|
||||||
"access-token": "uueUhS5OBWOeabNdleUa8w",
|
|
||||||
"client": "4xu1KgEP3RzNoM86hAkeCg",
|
|
||||||
"expiry": "1785135457",
|
|
||||||
"uid": "qiuzhida@greatqiu.cn",
|
|
||||||
}
|
|
||||||
|
|
||||||
def renew_session():
|
def renew_session():
|
||||||
log("Renewing Chatwoot session...")
|
log("Renewing Chatwoot session...")
|
||||||
@@ -302,7 +312,14 @@ def renew_session():
|
|||||||
if not all([access_token, client, expiry, uid]):
|
if not all([access_token, client, expiry, uid]):
|
||||||
log(f"Missing headers: {dict(r.headers)}")
|
log(f"Missing headers: {dict(r.headers)}")
|
||||||
return None
|
return None
|
||||||
|
# Extract pubsub_token from response body (ActionCable auth)
|
||||||
|
pubsub_token = ""
|
||||||
|
try:
|
||||||
|
pubsub_token = r.json().get("data", {}).get("pubsub_token", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
data = {"access-token": access_token, "client": client, "expiry": expiry, "uid": uid,
|
data = {"access-token": access_token, "client": client, "expiry": expiry, "uid": uid,
|
||||||
|
"pubsub_token": pubsub_token,
|
||||||
"updated_at": datetime.now(TZ).isoformat()}
|
"updated_at": datetime.now(TZ).isoformat()}
|
||||||
save_auth(data)
|
save_auth(data)
|
||||||
exp_time = datetime.fromtimestamp(int(expiry))
|
exp_time = datetime.fromtimestamp(int(expiry))
|
||||||
@@ -333,8 +350,10 @@ def ensure_session():
|
|||||||
"expiry": new_auth["expiry"],
|
"expiry": new_auth["expiry"],
|
||||||
"uid": new_auth["uid"],
|
"uid": new_auth["uid"],
|
||||||
}
|
}
|
||||||
log("WARNING: Using fallback headers")
|
raise RuntimeError(
|
||||||
return get_headers()
|
"No Chatwoot session available. "
|
||||||
|
"Ensure 'chatwoot_auth.json' exists or CW_EMAIL/CW_PASSWORD env vars are set."
|
||||||
|
)
|
||||||
|
|
||||||
# ===== PROCESSED MESSAGE TRACKING =====
|
# ===== PROCESSED MESSAGE TRACKING =====
|
||||||
|
|
||||||
@@ -351,6 +370,16 @@ def save_processed(ids):
|
|||||||
|
|
||||||
processed_ids = load_processed()
|
processed_ids = load_processed()
|
||||||
processed_lock = Lock()
|
processed_lock = Lock()
|
||||||
|
MAX_PROCESSED_IDS = 10000
|
||||||
|
|
||||||
|
def prune_processed_ids():
|
||||||
|
"""Keep processed_ids from growing unbounded."""
|
||||||
|
global processed_ids
|
||||||
|
with processed_lock:
|
||||||
|
if len(processed_ids) > MAX_PROCESSED_IDS:
|
||||||
|
sorted_ids = sorted(processed_ids, reverse=True)
|
||||||
|
processed_ids = set(sorted_ids[:MAX_PROCESSED_IDS // 2])
|
||||||
|
save_processed(processed_ids)
|
||||||
|
|
||||||
def is_processed(msg_id):
|
def is_processed(msg_id):
|
||||||
with processed_lock:
|
with processed_lock:
|
||||||
@@ -366,9 +395,18 @@ def mark_processed(msg_id):
|
|||||||
# Track message IDs that OUR AI sent via the API.
|
# Track message IDs that OUR AI sent via the API.
|
||||||
# This lets us distinguish our own AI replies from human agent messages
|
# This lets us distinguish our own AI replies from human agent messages
|
||||||
# when receiving events via WebSocket (since both use sender_type="User").
|
# when receiving events via WebSocket (since both use sender_type="User").
|
||||||
|
MAX_AI_SENT_IDS = 10000
|
||||||
ai_sent_msg_ids = set()
|
ai_sent_msg_ids = set()
|
||||||
ai_sent_lock = Lock()
|
ai_sent_lock = Lock()
|
||||||
|
|
||||||
|
def prune_ai_sent_ids():
|
||||||
|
"""Keep ai_sent_msg_ids from growing unbounded."""
|
||||||
|
global ai_sent_msg_ids
|
||||||
|
with ai_sent_lock:
|
||||||
|
if len(ai_sent_msg_ids) > MAX_AI_SENT_IDS:
|
||||||
|
sorted_ids = sorted(ai_sent_msg_ids, reverse=True)
|
||||||
|
ai_sent_msg_ids = set(sorted_ids[:MAX_AI_SENT_IDS // 2])
|
||||||
|
|
||||||
# Track conversation IDs where we just sent a message (race condition safety net)
|
# Track conversation IDs where we just sent a message (race condition safety net)
|
||||||
# Entries are added BEFORE API call, removed AFTER tracking the real message ID.
|
# Entries are added BEFORE API call, removed AFTER tracking the real message ID.
|
||||||
_ai_pending_convs = set()
|
_ai_pending_convs = set()
|
||||||
@@ -433,6 +471,53 @@ def clean_expired_human_active():
|
|||||||
del human_active_convs[cid]
|
del human_active_convs[cid]
|
||||||
log(f"⏱️ Conv #{cid} human timeout (cleanup), AI resuming")
|
log(f"⏱️ Conv #{cid} human timeout (cleanup), AI resuming")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== STATE PERSISTENCE (survive restart) =====
|
||||||
|
|
||||||
|
def save_state():
|
||||||
|
"""Persist ai_sent_msg_ids, human_active_convs, _ai_pending_convs to JSON."""
|
||||||
|
try:
|
||||||
|
with ai_sent_lock, human_active_lock, _ai_pending_lock:
|
||||||
|
state = {
|
||||||
|
"ai_sent_msg_ids": list(ai_sent_msg_ids),
|
||||||
|
"human_active_convs": human_active_convs.copy(),
|
||||||
|
"ai_pending_convs": list(_ai_pending_convs),
|
||||||
|
"saved_at": time.time()
|
||||||
|
}
|
||||||
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"save_state error: {e}", "WARN")
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
"""Restore state from JSON. Safety-first: only restore if file is recent (< 1h old)."""
|
||||||
|
global ai_sent_msg_ids, human_active_convs, _ai_pending_convs
|
||||||
|
try:
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
log("No state file found, starting fresh")
|
||||||
|
return
|
||||||
|
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||||
|
saved_at = state.get("saved_at", 0)
|
||||||
|
age = time.time() - saved_at
|
||||||
|
|
||||||
|
if age > 3600:
|
||||||
|
log(f"State file too old ({age/60:.0f}min), ignoring — safety first", "WARN")
|
||||||
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
with ai_sent_lock:
|
||||||
|
ai_sent_msg_ids = set(state.get("ai_sent_msg_ids", []))
|
||||||
|
with human_active_lock:
|
||||||
|
human_active_convs = state.get("human_active_convs", {})
|
||||||
|
with _ai_pending_lock:
|
||||||
|
_ai_pending_convs = set(state.get("ai_pending_convs", []))
|
||||||
|
|
||||||
|
log(f"State restored: {len(ai_sent_msg_ids)} msg_ids, {len(human_active_convs)} active convs, {len(_ai_pending_convs)} pending")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"load_state error: {e} (starting fresh)", "WARN")
|
||||||
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# ===== AI HELPERS =====
|
# ===== AI HELPERS =====
|
||||||
|
|
||||||
import re as _re # used for session-id stripping
|
import re as _re # used for session-id stripping
|
||||||
@@ -499,8 +584,8 @@ def generate_ai_reply(customer_msg, sender_name, inbox_id):
|
|||||||
"""
|
"""
|
||||||
config = INBOX_CONFIG.get(inbox_id)
|
config = INBOX_CONFIG.get(inbox_id)
|
||||||
if not config:
|
if not config:
|
||||||
log(f"No config for inbox #{inbox_id}, falling back to sourcing-agent")
|
log(f"No config for inbox #{inbox_id}, skipping", "WARN")
|
||||||
config = INBOX_CONFIG[1]
|
return None
|
||||||
|
|
||||||
prompt = config["prompt_template"].format(
|
prompt = config["prompt_template"].format(
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
@@ -752,7 +837,6 @@ class WSAgent:
|
|||||||
self.headers = None
|
self.headers = None
|
||||||
self.running = Event()
|
self.running = Event()
|
||||||
self.running.set()
|
self.running.set()
|
||||||
self.reconnect_delay = 1
|
|
||||||
self.last_pong = time.time()
|
self.last_pong = time.time()
|
||||||
|
|
||||||
def on_open(self, ws):
|
def on_open(self, ws):
|
||||||
@@ -786,7 +870,6 @@ class WSAgent:
|
|||||||
|
|
||||||
if t == "confirm_subscription":
|
if t == "confirm_subscription":
|
||||||
log("✅ RoomChannel subscription confirmed")
|
log("✅ RoomChannel subscription confirmed")
|
||||||
self.reconnect_delay = 1
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if t == "reject_subscription":
|
if t == "reject_subscription":
|
||||||
@@ -879,15 +962,6 @@ class WSAgent:
|
|||||||
def on_close(self, ws, status, msg):
|
def on_close(self, ws, status, msg):
|
||||||
log(f"🔴 WebSocket closed (status={status}, msg={msg})", "WARN")
|
log(f"🔴 WebSocket closed (status={status}, msg={msg})", "WARN")
|
||||||
metrics.ws_disconnected(f"status={status}, msg={msg}")
|
metrics.ws_disconnected(f"status={status}, msg={msg}")
|
||||||
if self.running.is_set():
|
|
||||||
self._reconnect()
|
|
||||||
|
|
||||||
def _reconnect(self):
|
|
||||||
delay = min(self.reconnect_delay, 60)
|
|
||||||
log(f"Reconnecting in {delay}s...")
|
|
||||||
time.sleep(delay)
|
|
||||||
self.reconnect_delay = min(delay * 2, 60)
|
|
||||||
self.start()
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start WebSocket connection (blocking)."""
|
"""Start WebSocket connection (blocking)."""
|
||||||
@@ -920,18 +994,58 @@ class WSAgent:
|
|||||||
# ===== TIMEOUT CHECKER THREAD =====
|
# ===== TIMEOUT CHECKER THREAD =====
|
||||||
|
|
||||||
def timeout_checker_loop():
|
def timeout_checker_loop():
|
||||||
"""Background thread: clean expired handoffs + hot-reload config."""
|
"""Background thread: clean expired handoffs + hot-reload config + persist state."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(30) # Check every 30s
|
time.sleep(30) # Check every 30s
|
||||||
try:
|
try:
|
||||||
clean_expired_human_active()
|
clean_expired_human_active()
|
||||||
|
prune_ai_sent_ids()
|
||||||
|
prune_processed_ids()
|
||||||
_load_inboxes_config() # hot-reload if file changed
|
_load_inboxes_config() # hot-reload if file changed
|
||||||
|
save_state() # persist state every 30s
|
||||||
|
metrics.flush() # persist metrics every 30s
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Timeout checker error: {e}")
|
log(f"Timeout checker error: {e}")
|
||||||
|
|
||||||
|
PUBSUB_TOKEN = os.environ.get("CW_PUBSUB_TOKEN", "")
|
||||||
|
USER_ID = int(os.environ.get("CW_USER_ID", "1"))
|
||||||
|
if not PUBSUB_TOKEN:
|
||||||
|
# Fallback: read directly from auth file
|
||||||
|
_auth_path = Path(__file__).parent / "chatwoot_auth.json"
|
||||||
|
if _auth_path.exists():
|
||||||
|
try:
|
||||||
|
_auth_data = json.loads(_auth_path.read_text())
|
||||||
|
PUBSUB_TOKEN = _auth_data.get("pubsub_token", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not PUBSUB_TOKEN:
|
||||||
|
# Last resort: try renewing session to get pubsub_token from login response
|
||||||
|
log("PUBSUB_TOKEN not set, attempting session renewal to obtain it...", "WARN")
|
||||||
|
_new_auth = None
|
||||||
|
try:
|
||||||
|
_r = requests.post(
|
||||||
|
CW_AUTH_URL,
|
||||||
|
json={"email": CW_EMAIL, "password": CW_PASSWORD},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if _r.status_code == 200:
|
||||||
|
_body = _r.json()
|
||||||
|
PUBSUB_TOKEN = _body.get("data", {}).get("pubsub_token", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not PUBSUB_TOKEN:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Missing Chatwoot pubsub token. "
|
||||||
|
"Set CW_PUBSUB_TOKEN env var or include 'pubsub_token' in chatwoot_auth.json"
|
||||||
|
)
|
||||||
|
|
||||||
# ===== MAIN =====
|
# ===== MAIN =====
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
|
# Restore persisted state BEFORE handling any args (--health etc. need it)
|
||||||
|
load_state()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Chatwoot WebSocket AI Agent")
|
parser = argparse.ArgumentParser(description="Chatwoot WebSocket AI Agent")
|
||||||
parser.add_argument("--renew", action="store_true", help="Force renew session & exit")
|
parser.add_argument("--renew", action="store_true", help="Force renew session & exit")
|
||||||
parser.add_argument("--test-ws", action="store_true", help="Test WebSocket connection & exit")
|
parser.add_argument("--test-ws", action="store_true", help="Test WebSocket connection & exit")
|
||||||
@@ -995,7 +1109,7 @@ def main():
|
|||||||
"ws_last_disconnect": None,
|
"ws_last_disconnect": None,
|
||||||
"inboxes": {}
|
"inboxes": {}
|
||||||
}
|
}
|
||||||
metrics._save()
|
metrics.flush()
|
||||||
print("Metrics reset successfully")
|
print("Metrics reset successfully")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1084,12 +1198,26 @@ def main():
|
|||||||
|
|
||||||
# Start the main agent
|
# Start the main agent
|
||||||
agent = WSAgent()
|
agent = WSAgent()
|
||||||
|
|
||||||
|
def _shutdown(signum, frame):
|
||||||
|
log(f"Received signal {signum}, shutting down gracefully...")
|
||||||
|
agent.stop()
|
||||||
|
save_state()
|
||||||
|
metrics.flush()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent.start()
|
agent.start()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log("Shutting down...")
|
log("Shutting down...")
|
||||||
agent.stop()
|
agent.stop()
|
||||||
|
save_state()
|
||||||
|
metrics.flush()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Chatwoot WebSocket Agent Control Script
|
||||||
|
# Usage: ./chatwoot_ws_ctl.sh {start|stop|restart|status|logs}
|
||||||
|
|
||||||
|
SCRIPT_DIR="/app/working/workspaces/wordpress/skills/wordpress-cli"
|
||||||
|
PIDFILE="/var/run/chatwoot_ws_agent.pid"
|
||||||
|
LOGFILE="/var/log/chatwoot_ws_agent.log"
|
||||||
|
COMMAND="python3 $SCRIPT_DIR/chatwoot_ws_agent.py"
|
||||||
|
|
||||||
|
# Helper: check if PID is actually our agent (avoids PID reuse race)
|
||||||
|
_pid_is_ours() {
|
||||||
|
pid="$1"
|
||||||
|
[ -z "$pid" ] && return 1
|
||||||
|
# Check PID exists AND its cmdline matches
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
cmdline=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ')
|
||||||
|
case "$cmdline" in
|
||||||
|
*chatwoot_ws_agent.py*) return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
if [ -f "$PIDFILE" ] && _pid_is_ours $(cat "$PIDFILE"); then
|
||||||
|
echo "Agent already running (PID $(cat $PIDFILE))"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
python3 -c "
|
||||||
|
import subprocess, os
|
||||||
|
p = subprocess.Popen(['python3', '$SCRIPT_DIR/chatwoot_ws_agent.py'],
|
||||||
|
stdout=open('$LOGFILE','a'), stderr=subprocess.STDOUT,
|
||||||
|
cwd='$SCRIPT_DIR', preexec_fn=os.setsid)
|
||||||
|
print(p.pid)
|
||||||
|
" > "$PIDFILE"
|
||||||
|
echo "Agent started (PID $(cat $PIDFILE))"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
if [ -f "$PIDFILE" ]; then
|
||||||
|
PID=$(cat "$PIDFILE")
|
||||||
|
if _pid_is_ours "$PID"; then
|
||||||
|
kill "$PID" 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
kill -9 "$PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
rm -f "$PIDFILE"
|
||||||
|
echo "Agent stopped"
|
||||||
|
else
|
||||||
|
echo "No PID file found"
|
||||||
|
pkill -f "chatwoot_ws_agent.py" 2>/dev/null && echo "Killed all chatwoot_ws_agent processes" || echo "No processes found"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
$0 stop
|
||||||
|
sleep 1
|
||||||
|
$0 start
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
if [ -f "$PIDFILE" ] && _pid_is_ours $(cat "$PIDFILE"); then
|
||||||
|
echo "Agent running (PID $(cat $PIDFILE))"
|
||||||
|
else
|
||||||
|
if pgrep -f "chatwoot_ws_agent.py" >/dev/null 2>&1; then
|
||||||
|
echo "Agent running (no PID file)"
|
||||||
|
pgrep -f "chatwoot_ws_agent.py"
|
||||||
|
else
|
||||||
|
echo "Agent not running"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
tail -30 "$LOGFILE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status|logs}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"version": "1.1",
|
||||||
|
"updated_at": "2026-06-04T09:00:49Z",
|
||||||
|
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"name": "GreatQiu",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "sourcing-agent",
|
||||||
|
"system_prompt": "You are a professional China sourcing agent from GreatQiu (based in Shaoxing, Zhejiang, China). You help international clients with product sourcing, supplier verification, quality control, logistics, and supply chain management.\n\nIMPORTANT - Decide if you can fully handle this or need a human:\n- If the customer asks about specific PRICING, MOQ, PLACING ORDERS, CUSTOMIZATION, SHIPPING QUOTES, or COMPLEX TECHNICAL SPECS that require real-time data from suppliers → end your reply with [HANDOFF] on a new line.\n- If you can answer the question fully using your general knowledge (company info, services, processes, general timelines) → do NOT add [HANDOFF].",
|
||||||
|
"prompt_template": "A customer named '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer. Always sign with '- GreatQiu Team'.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (GreatQiu)",
|
||||||
|
"signature": "- GreatQiu Team",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"name": "HALO Blog",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "halo-blog-agent",
|
||||||
|
"system_prompt": "你是 HALO 博客(shopqiu.com)的技术顾问,精通安防、弱电、监控、综合布线、门禁考勤、网络工程等领域。用中文回复,语气专业但不死板。如果客户的问题需要人工判断(如具体报价、设备选型、项目评估),回复末尾加 [HANDOFF]。",
|
||||||
|
"prompt_template": "客户 '{sender_name}' 发来消息:\n\n{customer_msg}\n\n直接回复,简洁专业(2-4句话)。用中文。",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (HALO)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"name": "Amazon",
|
||||||
|
"type": "api",
|
||||||
|
"target_agent": "9hxc2Y",
|
||||||
|
"system_prompt": "You are a customer service agent for an Amazon seller. Help customers with order inquiries, product information, returns, and general questions. Be professional and concise.",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (Amazon)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"name": "测试店铺",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-16",
|
||||||
|
"system_prompt": "You are a customer service agent for 测试店铺 (test-shop.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (测试店铺)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"name": "OpenCode 测试店铺",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-19",
|
||||||
|
"system_prompt": "You are a customer service agent for OpenCode 测试店铺 (opencode-test.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (OpenCode 测试店铺)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"name": "120088193@qq.com",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-21",
|
||||||
|
"system_prompt": "You are a customer service agent for 120088193@qq.com (shopqiu.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (120088193@qq.com)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"23": {
|
||||||
|
"name": "验证测试店铺",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-23",
|
||||||
|
"system_prompt": "You are a customer service agent for 验证测试店铺 (test.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (验证测试店铺)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"24": {
|
||||||
|
"name": "测试公司12",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-24",
|
||||||
|
"system_prompt": "You are a customer service agent for 测试公司12 (test12.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (测试公司12)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"25": {
|
||||||
|
"name": "测试公司13",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-25",
|
||||||
|
"system_prompt": "You are a customer service agent for 测试公司13 (test13.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (测试公司13)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"26": {
|
||||||
|
"name": "最终测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-26",
|
||||||
|
"system_prompt": "You are a customer service agent for 最终测试 (final.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (最终测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"27": {
|
||||||
|
"name": "最终注册测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-27",
|
||||||
|
"system_prompt": "You are a customer service agent for 最终注册测试 (finalreg.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (最终注册测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"28": {
|
||||||
|
"name": "清理后测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-28",
|
||||||
|
"system_prompt": "You are a customer service agent for 清理后测试 (clean.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (清理后测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"29": {
|
||||||
|
"name": "supervisord测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-29",
|
||||||
|
"system_prompt": "You are a customer service agent for supervisord测试 (sup.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (supervisord测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"name": "autologin测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-30",
|
||||||
|
"system_prompt": "You are a customer service agent for autologin测试 (auto.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (autologin测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"name": "最终E2E测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-31",
|
||||||
|
"system_prompt": "You are a customer service agent for 最终E2E测试 (e2efinal.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (最终E2E测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"name": "E2E专业版公司",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-32",
|
||||||
|
"system_prompt": "You are a customer service agent for E2E专业版公司 (e2e-pro). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (E2E专业版公司)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"name": "E2E企业版公司",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-33",
|
||||||
|
"system_prompt": "You are a customer service agent for E2E企业版公司 (e2e-ent). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (E2E企业版公司)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"name": "FastAdmin专业版",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-34",
|
||||||
|
"system_prompt": "You are a customer service agent for FastAdmin专业版 (fastadmin-pro). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (FastAdmin专业版)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"name": "FastAdmin企业版",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-35",
|
||||||
|
"system_prompt": "You are a customer service agent for FastAdmin企业版 (fastadmin-ent). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (FastAdmin企业版)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"name": "DirectBasicTest",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-36",
|
||||||
|
"system_prompt": "You are a customer service agent for DirectBasicTest (direct-basic-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (DirectBasicTest)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"name": "DebugBasic",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-37",
|
||||||
|
"system_prompt": "You are a customer service agent for DebugBasic (debug-basic). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (DebugBasic)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"38": {
|
||||||
|
"name": "Retry测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-38",
|
||||||
|
"system_prompt": "You are a customer service agent for Retry测试 (retry-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (Retry测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"39": {
|
||||||
|
"name": "123",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-39",
|
||||||
|
"system_prompt": "You are a customer service agent for 123 (123). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (123)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"40": {
|
||||||
|
"name": "会员中心测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-40",
|
||||||
|
"system_prompt": "You are a customer service agent for 会员中心测试 (member-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (会员中心测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"41": {
|
||||||
|
"name": "快速测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-41",
|
||||||
|
"system_prompt": "You are a customer service agent for 快速测试 (fast-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (快速测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"42": {
|
||||||
|
"name": "速度测试",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-42",
|
||||||
|
"system_prompt": "You are a customer service agent for 速度测试 (speed-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (速度测试)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"43": {
|
||||||
|
"name": "SpeedTest",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-43",
|
||||||
|
"system_prompt": "You are a customer service agent for SpeedTest (speed-provision). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (SpeedTest)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"name": "快速测试2",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-44",
|
||||||
|
"system_prompt": "You are a customer service agent for 快速测试2 (fast2). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (快速测试2)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"45": {
|
||||||
|
"name": "新代码验证",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-45",
|
||||||
|
"system_prompt": "You are a customer service agent for 新代码验证 (newcode). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (新代码验证)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"46": {
|
||||||
|
"name": "去重A",
|
||||||
|
"type": "web_widget",
|
||||||
|
"target_agent": "chathub-46",
|
||||||
|
"system_prompt": "You are a customer service agent for 去重A (dedupA). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||||
|
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||||
|
"note_prefix": "🤖 AI 自动回复 (去重A)",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"version": "1.1",
|
|
||||||
"updated_at": "2026-06-02T12:00:00Z",
|
|
||||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable"
|
|
||||||
},
|
|
||||||
"1": {
|
|
||||||
"name": "GreatQiu",
|
|
||||||
"type": "web_widget",
|
|
||||||
"target_agent": "sourcing-agent",
|
|
||||||
"system_prompt": "You are a professional China sourcing agent from GreatQiu (based in Shaoxing, Zhejiang, China). You help international clients with product sourcing, supplier verification, quality control, logistics, and supply chain management.\n\nIMPORTANT - Decide if you can fully handle this or need a human:\n- If the customer asks about specific PRICING, MOQ, PLACING ORDERS, CUSTOMIZATION, SHIPPING QUOTES, or COMPLEX TECHNICAL SPECS that require real-time data from suppliers → end your reply with [HANDOFF] on a new line.\n- If you can answer the question fully using your general knowledge (company info, services, processes, general timelines) → do NOT add [HANDOFF].",
|
|
||||||
"prompt_template": "A customer named '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer. Always sign with '- GreatQiu Team'.",
|
|
||||||
"note_prefix": "🤖 AI 自动回复 (GreatQiu)",
|
|
||||||
"signature": "- GreatQiu Team",
|
|
||||||
"status": "active"
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"name": "HALO Blog",
|
|
||||||
"type": "web_widget",
|
|
||||||
"target_agent": "halo-blog-agent",
|
|
||||||
"system_prompt": "你是一名专业的安防弱电与IT基础设施技术顾问,来自Q师傅知识库。你帮助客户解答关于交换机配置、监控系统、网络布线、弱电工程、服务器运维等技术问题。\n\n重要 - 判断是否需要人工介入:\n- 如果客户询问具体的项目报价、施工方案定制、现场勘查需求、或需要实际采购产品 → 在回复末尾加一行 [HANDOFF]。\n- 如果是回答一般性的技术问题(设备参数、配置方法、故障排查思路等)→ 不加 [HANDOFF]。",
|
|
||||||
"prompt_template": "客户 '{sender_name}' 发送了以下消息:\n\n{customer_msg}\n\n请用中文直接回复(不要前缀,不要Markdown)。保持简洁(2-4句话)。回复语气专业友善,体现技术专家的可靠性。以'- Q师傅知识库'署名。",
|
|
||||||
"note_prefix": "🤖 AI 自动回复 (Q师傅知识库)",
|
|
||||||
"signature": "- Q师傅知识库",
|
|
||||||
"status": "active"
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"name": "Amazon",
|
|
||||||
"type": "api",
|
|
||||||
"target_agent": "9hxc2Y",
|
|
||||||
"system_prompt": "你是一名亚马逊平台业务助手,帮助处理亚马逊相关的咨询。客户可能询问产品信息、订单状态、物流问题等。\n\n重要 - 判断是否需要人工介入:\n- 如果客户询问账号安全、付款问题、或需要人工审核 → 在回复末尾加一行 [HANDOFF]\n- 一般性的产品咨询、订单状态查询 → 直接回答,不加 [HANDOFF]",
|
|
||||||
"prompt_template": "客户 '{sender_name}' 发送了以下消息:\n\n{customer_msg}\n\n请用中文直接回复(不要前缀,不要Markdown)。保持简洁(2-4句话)。语气专业。以'- Amazon 客服助手'署名。",
|
|
||||||
"note_prefix": "🤖 AI 自动回复 (Amazon)",
|
|
||||||
"signature": "- Amazon 客服助手",
|
|
||||||
"status": "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# Q师傅知识库 — 安防弱电与IT基础设施技术参考
|
|
||||||
|
|
||||||
> 本文档供 AI 客服参考,覆盖 shopqiu.com 的核心知识领域。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、视频监控系统(93篇)
|
|
||||||
|
|
||||||
### 海康威视
|
|
||||||
- **默认IP**: 192.0.0.64,激活需SADP工具
|
|
||||||
- **SADP工具**: 局域网广播239.255.255.250:37020,扫描设备IP/MAC/型号/固件版本
|
|
||||||
- **密码要求**: 8-16位,至少包含数字、大小写字母、特殊字符中两类
|
|
||||||
- **连续登录失败5次**自动锁定10分钟
|
|
||||||
- **Hik-Connect**: P2P远程访问,无需端口映射,固件需V5.0以上
|
|
||||||
- **萤石云(EZVIZ)**: 扫码添加设备,支持AP热点配网
|
|
||||||
- **ISAPI接口**: HTTP Digest鉴权,常用路径:/ISAPI/System/deviceInfo、/ISAPI/Streaming/channels
|
|
||||||
- **SDK**: HCNetSDK.dll,日志路径C:\Program Files\Hikvision\Log
|
|
||||||
|
|
||||||
### 大华
|
|
||||||
- **SDK**: dhnetsdk.dll,64位系统必须用x64 SDK
|
|
||||||
- **GB28181**: 固件5.0以上才支持
|
|
||||||
- **ISAPI**: 部分低端型号不提供
|
|
||||||
|
|
||||||
### 常见故障排查
|
|
||||||
- **无画面**: PoE断电→线缆损坏→IP冲突→摄像头未激活→NVR通道未添加
|
|
||||||
- **远程无法播放**: 检查Platform Access是否启用→DNS设8.8.8.8→重启顺序:调制解调器→NVR→中继设备
|
|
||||||
- **卡顿丢帧**: 码率过高→降低分辨率→检查硬盘IO→抓包定位丢包
|
|
||||||
|
|
||||||
### PoE供电
|
|
||||||
- IEEE 802.3af (15.4W) / 802.3at (30W)
|
|
||||||
- 标准距离100m,超长线导致电压降
|
|
||||||
- 需8芯直通线
|
|
||||||
|
|
||||||
### GB28181协议
|
|
||||||
- 仅支持H.264编码(不支持H.265),需开启双码流
|
|
||||||
- SIP端口5060/5061
|
|
||||||
- 常见错误:404(地址错误)、401(密码错)、503(资源不足)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、网络工程(86篇)
|
|
||||||
|
|
||||||
### 核心设备
|
|
||||||
- **交换机**: H3C、华为、锐捷,VLAN划分、端口隔离
|
|
||||||
- **路由器**: 策略路由、NAT、ACL
|
|
||||||
- **防火墙**: 状态检测、应用识别
|
|
||||||
|
|
||||||
### PoE交换机选型
|
|
||||||
- 整机供电功率 > 所有设备累计功耗
|
|
||||||
- 海康DS-3E0526P-S/L: 24口PoE,单口30W,整机370W
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
- **环路**: 启用STP/RSTP
|
|
||||||
- **IP冲突**: DHCP Snooping + IP Source Guard
|
|
||||||
- **带宽不足**: 链路聚合、QoS限速
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、门禁考勤系统(64篇)
|
|
||||||
|
|
||||||
### 组成
|
|
||||||
- 门禁控制器、读卡器(IC/ID卡、人脸)、电锁(电磁锁、电插锁、阴极锁)
|
|
||||||
- 出门按钮、门磁
|
|
||||||
|
|
||||||
### 人脸门禁
|
|
||||||
- 活体检测防照片攻击
|
|
||||||
- 识别距离0.3-1.5m
|
|
||||||
- 深度学习算法,误识率<0.01%
|
|
||||||
|
|
||||||
### 常见故障
|
|
||||||
- **不开门**: 检查供电→控制器→电锁接线→读卡器信号
|
|
||||||
- **刷卡无反应**: 卡片类型不匹配(IC/ID)→读卡器线缆→控制器在线状态
|
|
||||||
- **开门后不关门**: 门磁故障→延时设置→电锁回弹力不足
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、报警消防系统(26篇)
|
|
||||||
|
|
||||||
### 报警设备
|
|
||||||
- **红外对射**: 发射端+接收端,光束被遮断触发报警
|
|
||||||
- **烟感探测器**: 光电式/离子式,需定期清洁
|
|
||||||
- **报警主机**: 防区管理,支持电话/SMS/APP推送
|
|
||||||
|
|
||||||
### 消防联动
|
|
||||||
- 烟感→报警主机→消防泵/排烟/广播
|
|
||||||
- 需符合GB4717标准
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、停车管理系统(35篇)
|
|
||||||
|
|
||||||
### 道闸系统
|
|
||||||
- 栏杆机(直杆/曲杆/栅栏杆)
|
|
||||||
- 车牌识别摄像机(LPR)
|
|
||||||
- 收费系统(临时卡/月卡/ETC)
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
- **车牌识别率低**: 调整摄像机角度→补光灯→算法参数
|
|
||||||
- **道闸不抬杆**: 检查地感线圈→控制器→电机
|
|
||||||
- **跟车闯关**: 增加防砸功能(红外/压力波)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、对讲广播(31篇)
|
|
||||||
|
|
||||||
### 可视对讲
|
|
||||||
- 模拟对讲 → 数字IP对讲
|
|
||||||
- SIP协议、ONVIF协议
|
|
||||||
- 医护对讲:床头分机+走廊显示屏+护士站主机
|
|
||||||
|
|
||||||
### 广播系统
|
|
||||||
- 定压功放(70V/100V)+ 定阻功放
|
|
||||||
- IP网络广播:基于局域网,支持分区/定时/紧急广播
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、显示亮化(25篇)
|
|
||||||
|
|
||||||
### LED显示屏
|
|
||||||
- 室内P2.5/P3/P4,室外P5/P6/P8/P10
|
|
||||||
- 同步控制vs异步控制
|
|
||||||
- 亮度自动调节(光感探头)
|
|
||||||
|
|
||||||
### 拼接屏
|
|
||||||
- LCD拼接(3.5mm/1.8mm缝隙)
|
|
||||||
- DLP背投(无缝但体积大)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、存储与数据(34篇)
|
|
||||||
|
|
||||||
### NVR存储
|
|
||||||
- 硬盘容量计算:码率×时间×通道数
|
|
||||||
- RAID5/RAID6冗余
|
|
||||||
- 硬盘健康监控(SMART)
|
|
||||||
|
|
||||||
### NAS方案
|
|
||||||
- 群晖、威联通、海康存储
|
|
||||||
- ISCSI、NFS、SMB协议
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、无线通信(55篇)
|
|
||||||
|
|
||||||
### 无线覆盖
|
|
||||||
- AP选型:吸顶式、面板式、室外型
|
|
||||||
- 信道规划:避免同频干扰
|
|
||||||
- AC控制器集中管理
|
|
||||||
|
|
||||||
### 对讲系统
|
|
||||||
- 模拟对讲机 vs 数字对讲机(DMR/PDT)
|
|
||||||
- IP对讲:基于SIP协议
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、AI与智能化(35篇)
|
|
||||||
|
|
||||||
### 智能分析
|
|
||||||
- 人脸识别、行为分析、周界检测
|
|
||||||
- 热成像测温
|
|
||||||
- 车牌识别
|
|
||||||
|
|
||||||
### 系统集成
|
|
||||||
- GB28181国标对接
|
|
||||||
- SDK二次开发
|
|
||||||
- ISAPI接口调用
|
|
||||||
- ONVIF协议
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十一、运维笔记(39篇)
|
|
||||||
|
|
||||||
### 常用工具
|
|
||||||
- SADP(海康设备发现)
|
|
||||||
- HiTools Delivery(批量管理)
|
|
||||||
- WireShark(网络抓包)
|
|
||||||
- PuTTY/SecureCRT(串口/SSH)
|
|
||||||
|
|
||||||
### 调试技巧
|
|
||||||
- 先查物理层(线缆、供电)→ 再查网络层(IP、路由)→ 最后查应用层(配置、固件)
|
|
||||||
- 固件升级前务必备份配置
|
|
||||||
- UPS保护防止升级中断电
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十二、运维术语速查
|
|
||||||
|
|
||||||
| 缩写 | 全称 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| NVR | Network Video Recorder | 网络录像机 |
|
|
||||||
| DVR | Digital Video Recorder | 数字录像机 |
|
|
||||||
| IPC | IP Camera | 网络摄像机 |
|
|
||||||
| PoE | Power over Ethernet | 以太网供电 |
|
|
||||||
| VMS | Video Management System | 视频管理系统 |
|
|
||||||
| LPR | License Plate Recognition | 车牌识别 |
|
|
||||||
| RTSP | Real Time Streaming Protocol | 实时流协议 |
|
|
||||||
| ONVIF | Open Network Video Interface Forum | 开放网络视频接口论坛 |
|
|
||||||
| SIP | Session Initiation Protocol | 会话初始协议 |
|
|
||||||
| RAID | Redundant Array of Independent Disk | 磁盘冗余阵列 |
|
|
||||||
| AC | Access Controller | 无线控制器 |
|
|
||||||
| AP | Access Point | 无线接入点 |
|
|
||||||
| VLAN | Virtual LAN | 虚拟局域网 |
|
|
||||||
| STP | Spanning Tree Protocol | 生成树协议 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*最后更新: 2026-05-31*
|
|
||||||
*来源: shopqiu.com 文章 + 互联网技术资料*
|
|
||||||
-293
@@ -1,293 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Tenant Provisioning Script — Chatwoot AI Agent Platform
|
|
||||||
========================================================
|
|
||||||
Automatically creates a new tenant (inbox + AI agent) and registers it
|
|
||||||
in inboxes.json for the WS agent to pick up via hot-reload.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Website Widget (独立站嵌入)
|
|
||||||
python3 provision.py --name "MyShop" --type web_widget --domain myshop.com
|
|
||||||
|
|
||||||
# API Inbox (亚马逊/TikTok等)
|
|
||||||
python3 provision.py --name "AmazonStore" --type api --lang zh
|
|
||||||
|
|
||||||
# With custom prompt
|
|
||||||
python3 provision.py --name "TechSupport" --type web_widget --domain tech.com \
|
|
||||||
--lang en --system-prompt "You are a tech support agent..."
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- Inbox ID, identifier, embed code (for widget) or API credentials
|
|
||||||
- Writes to inboxes.json automatically
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os, sys, json, argparse, time, requests
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# ===== CONFIG =====
|
|
||||||
CW_BASE = os.environ.get("CW_BASE", "https://chatwoot.275763.xyz")
|
|
||||||
CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
|
|
||||||
CW_API_BASE = f"{CW_BASE}/api/v1/accounts/{CW_ACCOUNT_ID}"
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent
|
|
||||||
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
|
||||||
INBOX_CONFIG_FILE = SCRIPT_DIR / "inboxes.json"
|
|
||||||
|
|
||||||
# ===== AUTH =====
|
|
||||||
def get_headers():
|
|
||||||
"""Load session headers from auth file."""
|
|
||||||
if AUTH_FILE.exists():
|
|
||||||
try:
|
|
||||||
data = json.loads(AUTH_FILE.read_text())
|
|
||||||
return {
|
|
||||||
"access-token": data.get("access-token"),
|
|
||||||
"client": data.get("client"),
|
|
||||||
"expiry": data.get("expiry"),
|
|
||||||
"uid": data.get("uid"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WARNING: Failed to load auth: {e}")
|
|
||||||
print("ERROR: No valid auth. Run chatwoot_ws_agent.py --renew first.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# ===== CHATWOOT API =====
|
|
||||||
def create_inbox(name, inbox_type, domain=None, headers=None):
|
|
||||||
"""Create a Chatwoot inbox via API.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Display name for the inbox
|
|
||||||
inbox_type: 'web_widget' or 'api'
|
|
||||||
domain: Website URL (required for web_widget)
|
|
||||||
headers: Auth headers
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with inbox_id, identifier, etc.
|
|
||||||
"""
|
|
||||||
if inbox_type == "web_widget" and not domain:
|
|
||||||
print("ERROR: --domain required for web_widget type")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
payload = {"name": name}
|
|
||||||
|
|
||||||
if inbox_type == "web_widget":
|
|
||||||
# Ensure domain has protocol
|
|
||||||
if not domain.startswith("http"):
|
|
||||||
domain = f"https://{domain}"
|
|
||||||
payload["channel"] = {
|
|
||||||
"type": "web_widget",
|
|
||||||
"website_url": domain
|
|
||||||
}
|
|
||||||
elif inbox_type == "api":
|
|
||||||
payload["channel"] = {"type": "api"}
|
|
||||||
else:
|
|
||||||
print(f"ERROR: Unknown inbox type: {inbox_type}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
r = requests.post(f"{CW_API_BASE}/inboxes", json=payload, headers=headers, timeout=15)
|
|
||||||
if r.status_code not in (200, 201):
|
|
||||||
print(f"ERROR: Create inbox failed: {r.status_code} {r.text[:300]}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
data = r.json()
|
|
||||||
return {
|
|
||||||
"inbox_id": data.get("id"),
|
|
||||||
"name": data.get("name"),
|
|
||||||
"identifier": data.get("webhook_url", ""), # widget uses this
|
|
||||||
"website_token": data.get("website_token", ""),
|
|
||||||
"api_channel": data.get("channel", {}).get("type"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ===== QWENPAW AGENT =====
|
|
||||||
def create_qwenpaw_agent(agent_id, display_name, lang="zh"):
|
|
||||||
"""Create a QwenPaw agent workspace + config.
|
|
||||||
|
|
||||||
This creates the agent directory structure. The agent will be auto-detected
|
|
||||||
by QwenPaw on next restart or config reload.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the agent workspace
|
|
||||||
"""
|
|
||||||
# Determine workspace path
|
|
||||||
qwenpaw_base = Path(os.environ.get("QWENPAW_WORKING_DIR", "/app/working/workspaces"))
|
|
||||||
agent_dir = qwenpaw_base / agent_id
|
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create agent.json
|
|
||||||
agent_config = {
|
|
||||||
"id": agent_id,
|
|
||||||
"name": display_name,
|
|
||||||
"description": f"Auto-provisioned agent for {display_name}",
|
|
||||||
"workspace_dir": str(agent_dir),
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
(agent_dir / "agent.json").write_text(json.dumps(agent_config, indent=2))
|
|
||||||
|
|
||||||
# Create basic SOUL.md
|
|
||||||
if lang == "zh":
|
|
||||||
soul = f"""# {display_name} AI 客服
|
|
||||||
|
|
||||||
你是 {display_name} 的 AI 客服助手。
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
- 专业、友善地回答客户问题
|
|
||||||
- 无法处理时标记 [HANDOFF] 请求人工介入
|
|
||||||
- 保持简洁,2-4句话回复
|
|
||||||
|
|
||||||
## 风格
|
|
||||||
- 用中文回复
|
|
||||||
- 语气专业但不死板
|
|
||||||
- 直接回答,不绕弯子
|
|
||||||
"""
|
|
||||||
else:
|
|
||||||
soul = f"""# {display_name} AI Customer Service
|
|
||||||
|
|
||||||
You are the AI customer service agent for {display_name}.
|
|
||||||
|
|
||||||
## Responsibilities
|
|
||||||
- Answer customer questions professionally and friendly
|
|
||||||
- Mark [HANDOFF] when human intervention is needed
|
|
||||||
- Keep replies concise (2-4 sentences)
|
|
||||||
|
|
||||||
## Style
|
|
||||||
- Professional but approachable
|
|
||||||
- Direct answers, no fluff
|
|
||||||
"""
|
|
||||||
(agent_dir / "SOUL.md").write_text(soul)
|
|
||||||
|
|
||||||
# Create empty PROFILE.md
|
|
||||||
(agent_dir / "PROFILE.md").write_text(f"# {display_name} Agent Profile\n\nAuto-provisioned.\n")
|
|
||||||
|
|
||||||
print(f" Agent workspace: {agent_dir}")
|
|
||||||
return agent_dir
|
|
||||||
|
|
||||||
# ===== INBOXES.JSON =====
|
|
||||||
def update_inboxes_config(inbox_id, name, inbox_type, agent_id, lang="zh"):
|
|
||||||
"""Add new inbox to inboxes.json."""
|
|
||||||
# Load existing
|
|
||||||
config = {}
|
|
||||||
if INBOX_CONFIG_FILE.exists():
|
|
||||||
try:
|
|
||||||
config = json.loads(INBOX_CONFIG_FILE.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Default prompts by language
|
|
||||||
if lang == "zh":
|
|
||||||
system_prompt = f"你是 {name} 的 AI 客服助手。专业回复客户问题,需要人工时加 [HANDOFF]。"
|
|
||||||
prompt_template = "客户 '{sender_name}' 发来消息:\n\n{customer_msg}\n\n直接回复,简洁专业(2-4句话)。用中文。"
|
|
||||||
note_prefix = f"🤖 AI 自动回复 ({name})"
|
|
||||||
signature = f"- {name} 客服"
|
|
||||||
else:
|
|
||||||
system_prompt = f"You are the AI customer service agent for {name}. Answer professionally. Add [HANDOFF] when human intervention is needed."
|
|
||||||
prompt_template = "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer."
|
|
||||||
note_prefix = f"🤖 AI Auto-Reply ({name})"
|
|
||||||
signature = f"- {name} Team"
|
|
||||||
|
|
||||||
# Add/update entry
|
|
||||||
config[str(inbox_id)] = {
|
|
||||||
"name": name,
|
|
||||||
"type": inbox_type,
|
|
||||||
"target_agent": agent_id,
|
|
||||||
"system_prompt": system_prompt,
|
|
||||||
"prompt_template": prompt_template,
|
|
||||||
"note_prefix": note_prefix,
|
|
||||||
"signature": signature,
|
|
||||||
"status": "active",
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update meta
|
|
||||||
config["_meta"] = {
|
|
||||||
"version": "1.1",
|
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable",
|
|
||||||
}
|
|
||||||
|
|
||||||
INBOX_CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
|
||||||
print(f" Config written: {INBOX_CONFIG_FILE}")
|
|
||||||
|
|
||||||
# ===== MAIN =====
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Provision a new tenant inbox + AI agent")
|
|
||||||
parser.add_argument("--name", required=True, help="Tenant display name")
|
|
||||||
parser.add_argument("--type", required=True, choices=["web_widget", "api"],
|
|
||||||
help="Inbox type: web_widget (website) or api (platform)")
|
|
||||||
parser.add_argument("--domain", default=None, help="Website domain (required for web_widget)")
|
|
||||||
parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="Language (default: zh)")
|
|
||||||
parser.add_argument("--agent-id", default=None, help="Custom agent ID (default: auto-generated)")
|
|
||||||
parser.add_argument("--system-prompt", default=None, help="Custom system prompt (overrides default)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Generate agent ID
|
|
||||||
agent_id = args.agent_id or f"agent-{args.name.lower().replace(' ', '-')}"
|
|
||||||
|
|
||||||
print(f"\n{'='*50}")
|
|
||||||
print(f" Provisioning: {args.name}")
|
|
||||||
print(f" Type: {args.type}")
|
|
||||||
print(f" Agent: {agent_id}")
|
|
||||||
print(f"{'='*50}\n")
|
|
||||||
|
|
||||||
# Step 1: Create Chatwoot inbox
|
|
||||||
print("[1/3] Creating Chatwoot inbox...")
|
|
||||||
headers = get_headers()
|
|
||||||
inbox_info = create_inbox(args.name, args.type, args.domain, headers)
|
|
||||||
inbox_id = inbox_info["inbox_id"]
|
|
||||||
print(f" Inbox #{inbox_id}: {inbox_info['name']}")
|
|
||||||
|
|
||||||
# Step 2: Create QwenPaw agent
|
|
||||||
print("[2/3] Creating AI agent...")
|
|
||||||
agent_dir = create_qwenpaw_agent(agent_id, args.name, args.lang)
|
|
||||||
|
|
||||||
# Step 3: Update inboxes.json
|
|
||||||
print("[3/3] Updating routing config...")
|
|
||||||
update_inboxes_config(inbox_id, args.name, args.type, agent_id, args.lang)
|
|
||||||
|
|
||||||
# Override system prompt if provided
|
|
||||||
if args.system_prompt:
|
|
||||||
import inboxes_config_helper # noqa - if exists
|
|
||||||
# Manual override
|
|
||||||
config = json.loads(INBOX_CONFIG_FILE.read_text())
|
|
||||||
config[str(inbox_id)]["system_prompt"] = args.system_prompt
|
|
||||||
INBOX_CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
|
||||||
print(f" Custom system prompt applied")
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print(f"\n{'='*50}")
|
|
||||||
print(f" ✅ PROVISIONING COMPLETE")
|
|
||||||
print(f"{'='*50}")
|
|
||||||
print(f" Inbox ID: {inbox_id}")
|
|
||||||
print(f" Agent ID: {agent_id}")
|
|
||||||
print(f" Agent Dir: {agent_dir}")
|
|
||||||
|
|
||||||
if args.type == "web_widget":
|
|
||||||
token = inbox_info.get("website_token", "")
|
|
||||||
print(f"\n 📋 Widget Embed Code:")
|
|
||||||
print(f" <script>")
|
|
||||||
print(f" window.chatwootSettings = {{}};")
|
|
||||||
print(f" (function(d,t) {{")
|
|
||||||
print(f" var BASE_URL=\"{CW_BASE}\";")
|
|
||||||
print(f" var g=d.createElement(t),s=d.getElementsByTagName(t)[0];")
|
|
||||||
print(f" g.src=BASE_URL+'/packs/js/sdk.js';")
|
|
||||||
print(f" g.async=!0;")
|
|
||||||
print(f" s.parentNode.insertBefore(g,s);")
|
|
||||||
print(f" g.onload=function(){{")
|
|
||||||
print(f" window.chatwootSDK.run({{")
|
|
||||||
print(f" websiteToken: '{token}',")
|
|
||||||
print(f" baseUrl: BASE_URL")
|
|
||||||
print(f" }})")
|
|
||||||
print(f" }}")
|
|
||||||
print(f" }})(document,'script');")
|
|
||||||
print(f" </script>")
|
|
||||||
elif args.type == "api":
|
|
||||||
print(f"\n 📋 API Credentials:")
|
|
||||||
print(f" Inbox ID: {inbox_id}")
|
|
||||||
print(f" (Use Chatwoot API to create conversations and send messages)")
|
|
||||||
|
|
||||||
print(f"\n ⚡ WS Agent will auto-detect this inbox within 30s (hot-reload)")
|
|
||||||
print(f" ⚡ Restart QwenPaw to register the new agent: qwenpaw daemon restart")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""ChatHub Provision HTTP Service.
|
||||||
|
|
||||||
|
Called by FastAdmin PHP to provision a tenant:
|
||||||
|
→ creates Chatwoot team + inbox + agent account
|
||||||
|
→ writes inboxes.json (WS Agent hot-reloads within 30s)
|
||||||
|
→ creates QwenPaw agent workspace
|
||||||
|
Returns JSON for PHP to save to chathub_tenant table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import bottle
|
||||||
|
|
||||||
|
# ── logging ──────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("provision")
|
||||||
|
|
||||||
|
# ── paths (all inside QwenPaw container) ──────────────────────────
|
||||||
|
WORKSPACE_DIR = Path("/app/working/workspaces")
|
||||||
|
SCRIPT_DIR = WORKSPACE_DIR / "wordpress" / "skills" / "wordpress-cli"
|
||||||
|
INBOXES_PATH = SCRIPT_DIR / "inboxes.json"
|
||||||
|
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
||||||
|
|
||||||
|
# Chatwoot base config
|
||||||
|
CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
|
||||||
|
CW_INTERNAL = os.environ.get("CW_INTERNAL", "http://chatwoot-chatwoot-1:3000")
|
||||||
|
CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
|
||||||
|
CW_PLATFORM_TOKEN = os.environ.get("CW_PLATFORM_TOKEN", "")
|
||||||
|
# API key for provision/suspend/activate endpoints
|
||||||
|
CHATHUB_API_KEY = os.environ.get("CHATHUB_API_KEY", "chathub-default-key-change-me")
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_password(length: int = 14) -> str:
|
||||||
|
chars = string.ascii_letters + string.digits + '!@#$%^&*()_+-='
|
||||||
|
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def _relogin_chatwoot() -> dict:
|
||||||
|
email = os.environ.get("CW_ADMIN_EMAIL")
|
||||||
|
password = os.environ.get("CW_ADMIN_PASSWORD")
|
||||||
|
if not email:
|
||||||
|
raise RuntimeError("CW_ADMIN_EMAIL not set — cannot login")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("CW_ADMIN_PASSWORD not set — cannot login")
|
||||||
|
url = f"{CW_BASE}/auth/sign_in"
|
||||||
|
payload = json.dumps({"email": email, "password": password}).encode()
|
||||||
|
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
headers = {k.lower(): v for k, v in resp.headers.items()}
|
||||||
|
data = {
|
||||||
|
"access-token": headers.get("access-token", ""),
|
||||||
|
"client": headers.get("client", ""),
|
||||||
|
"expiry": headers.get("expiry", ""),
|
||||||
|
"uid": headers.get("uid", ""),
|
||||||
|
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
AUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
log.info("Chatwoot session refreshed for %s", data["uid"])
|
||||||
|
return {
|
||||||
|
"access-token": data["access-token"],
|
||||||
|
"client": data["client"],
|
||||||
|
"expiry": data["expiry"],
|
||||||
|
"uid": data["uid"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_headers() -> dict:
|
||||||
|
"""Load session from file, auto-renew if expired or missing."""
|
||||||
|
import time as _t
|
||||||
|
if AUTH_FILE.exists():
|
||||||
|
data = json.loads(AUTH_FILE.read_text())
|
||||||
|
expiry = int(data.get("expiry", 0))
|
||||||
|
if expiry - _t.time() < 3600:
|
||||||
|
log.info("Session < 1h (%ds left), renewing", expiry - int(_t.time()))
|
||||||
|
return _relogin_chatwoot()
|
||||||
|
if all([data.get("access-token"), data.get("client"), data.get("uid")]):
|
||||||
|
return {
|
||||||
|
"access-token": data["access-token"],
|
||||||
|
"client": data["client"],
|
||||||
|
"expiry": data["expiry"],
|
||||||
|
"uid": data["uid"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
return _relogin_chatwoot()
|
||||||
|
|
||||||
|
|
||||||
|
def _call_cw(method: str, path: str, body: Optional[dict], retries: int = 3) -> dict:
|
||||||
|
"""Call Chatwoot API with retry on 401 (auto-renew session)."""
|
||||||
|
url = f"{CW_BASE}{path}"
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
headers = _get_session_headers()
|
||||||
|
data = json.dumps(body).encode() if body else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401 and attempt < retries - 1:
|
||||||
|
log.warning(f"401 on {method} {path} (attempt {attempt+1}), re-login & retry")
|
||||||
|
AUTH_FILE.unlink(missing_ok=True)
|
||||||
|
continue
|
||||||
|
body_text = e.read().decode() if e.fp else ""
|
||||||
|
raise RuntimeError(f"CW API error {e.code} on {method} {path}: {body_text}")
|
||||||
|
raise RuntimeError(f"Exhausted {retries} retries on {method} {path}: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
def _call_internal(method: str, path: str,
|
||||||
|
body: Optional[dict],
|
||||||
|
extra_headers: Optional[dict] = None,
|
||||||
|
retries: int = 3) -> dict:
|
||||||
|
"""Call Chatwoot internal (platform) API with retry."""
|
||||||
|
url = f"{CW_INTERNAL}{path}"
|
||||||
|
for attempt in range(retries):
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
data = json.dumps(body).encode() if body else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if attempt < retries - 1:
|
||||||
|
log.warning(f"Internal API error {e.code} on {method} {path}, retry {attempt+1}")
|
||||||
|
continue
|
||||||
|
body_text = e.read().decode() if e.fp else ""
|
||||||
|
raise RuntimeError(f"Internal API error {e.code} on {method} {path}: {body_text}")
|
||||||
|
raise RuntimeError(f"Exhausted {retries} retries on internal {method} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_api_key():
|
||||||
|
"""Verify X-API-Key header matches CHATHUB_API_KEY."""
|
||||||
|
key = bottle.request.get_header("X-API-Key", "")
|
||||||
|
if key != CHATHUB_API_KEY:
|
||||||
|
bottle.response.status = 401
|
||||||
|
return json.dumps({"error": "Invalid or missing X-API-Key"})
|
||||||
|
|
||||||
|
|
||||||
|
def _create_agent(email: str, name: str = "") -> dict:
|
||||||
|
"""Create a Chatwoot user + agent. Returns {agent_cw_id, password}.
|
||||||
|
Rolls back (deletes user) if agent creation fails."""
|
||||||
|
password = _gen_password()
|
||||||
|
display_name = name or email.split("@")[0]
|
||||||
|
|
||||||
|
user = _call_internal(
|
||||||
|
"POST",
|
||||||
|
"/platform/api/v1/users",
|
||||||
|
{"name": display_name, "email": email, "password": password},
|
||||||
|
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
||||||
|
)
|
||||||
|
if not user.get("id"):
|
||||||
|
raise RuntimeError(f"Platform API user creation failed: {user}")
|
||||||
|
uid = user["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = _call_cw(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/agents",
|
||||||
|
{"email": email, "name": display_name, "role": "agent"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Rollback: delete the orphaned platform user
|
||||||
|
try:
|
||||||
|
_call_internal(
|
||||||
|
"DELETE",
|
||||||
|
f"/platform/api/v1/users/{uid}",
|
||||||
|
None,
|
||||||
|
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"Agent creation failed, rolled back user {uid}: {e}")
|
||||||
|
|
||||||
|
if not agent.get("id"):
|
||||||
|
raise RuntimeError(f"Agent creation failed: {agent}")
|
||||||
|
|
||||||
|
return {"agent_cw_id": agent["id"], "password": password}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_agent_to_team(agent_cw_id: int, team_id: int) -> None:
|
||||||
|
"""Assign agent to team."""
|
||||||
|
try:
|
||||||
|
_call_cw(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams/{team_id}/team_members",
|
||||||
|
{"user_ids": [agent_cw_id]},
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode() if e.fp else ""
|
||||||
|
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s %s", agent_cw_id, team_id, e.code, body)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s", agent_cw_id, team_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_inboxes() -> dict:
|
||||||
|
if INBOXES_PATH.exists():
|
||||||
|
return json.loads(INBOXES_PATH.read_text(encoding="utf-8"))
|
||||||
|
return {
|
||||||
|
"_meta": {
|
||||||
|
"version": "1.1",
|
||||||
|
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"description": "Chatwoot WS Agent inbox routing config \u2014 hot-reloadable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_inboxes(config: dict) -> None:
|
||||||
|
INBOXES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
INBOXES_PATH.write_text(
|
||||||
|
json.dumps(config, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_agent_workspace(agent_id: str, name: str) -> Path:
|
||||||
|
agent_dir = WORKSPACE_DIR / agent_id
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return agent_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _build_inbox_entry(name: str, domain: str, channel: str,
|
||||||
|
inbox_id: int, inbox_token: str, agent_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"type": channel,
|
||||||
|
"target_agent": agent_id,
|
||||||
|
"system_prompt": (
|
||||||
|
f"You are a customer service agent for {name} ({domain}). "
|
||||||
|
f"Answer questions professionally in the customer's language. "
|
||||||
|
f"If you cannot fully resolve the issue, end with [HANDOFF]."
|
||||||
|
),
|
||||||
|
"prompt_template": (
|
||||||
|
"Customer '{sender_name}' sent this message:\n\n"
|
||||||
|
"{customer_msg}\n\n"
|
||||||
|
"Write a direct reply (no preamble, no markdown). "
|
||||||
|
"Keep it concise (2-4 sentences). "
|
||||||
|
"Use the same language as the customer."
|
||||||
|
),
|
||||||
|
"note_prefix": f"\U0001f916 AI \u81ea\u52a8\u56de\u590d ({name})",
|
||||||
|
"signature": "",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Idempotency store ────────────────────────────────────────────
|
||||||
|
# Stores {key: response_dict} with 5-minute TTL.
|
||||||
|
_IDEMPOTENT_RESULTS: dict[str, dict] = {}
|
||||||
|
_IDEMPOTENT_LOCK = threading.Lock()
|
||||||
|
_IDEMPOTENT_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _check_idempotency(key: str) -> Optional[dict]:
|
||||||
|
"""Return cached result if key was already processed, else None."""
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
with _IDEMPOTENT_LOCK:
|
||||||
|
if key in _IDEMPOTENT_RESULTS:
|
||||||
|
log.info("Idempotent request %s, returning cached result", key)
|
||||||
|
cached = _IDEMPOTENT_RESULTS[key]
|
||||||
|
# Restore HTTP status from cached entry
|
||||||
|
status = cached.pop("_http_status", 200)
|
||||||
|
bottle.response.status = status
|
||||||
|
return cached
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_idempotency(key: str, response: dict, status: int = 200) -> None:
|
||||||
|
"""Store the response for an idempotency key (thread-safe, auto-expire).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Idempotency-Key header value.
|
||||||
|
response: The JSON response dict to cache.
|
||||||
|
status: HTTP status code to restore on cache hit.
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
with _IDEMPOTENT_LOCK:
|
||||||
|
if key not in _IDEMPOTENT_RESULTS:
|
||||||
|
entry = dict(response)
|
||||||
|
entry["_http_status"] = status
|
||||||
|
_IDEMPOTENT_RESULTS[key] = entry
|
||||||
|
# Schedule cleanup after TTL
|
||||||
|
threading.Timer(_IDEMPOTENT_TTL, lambda: _IDEMPOTENT_RESULTS.pop(key, None)).start()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provision endpoint ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bottle.post("/provision")
|
||||||
|
def provision():
|
||||||
|
result = None
|
||||||
|
idempotency_key = bottle.request.get_header("Idempotency-Key", "")
|
||||||
|
|
||||||
|
auth_err = _check_api_key()
|
||||||
|
if auth_err:
|
||||||
|
return auth_err
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = bottle.request.json
|
||||||
|
except Exception:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "invalid JSON body"}
|
||||||
|
|
||||||
|
name = (data or {}).get("name", "").strip()
|
||||||
|
domain = (data or {}).get("domain", "").strip()
|
||||||
|
email = (data or {}).get("email", "").strip()
|
||||||
|
channel = (data or {}).get("type", "web_widget")
|
||||||
|
agent_id = (data or {}).get("agent_id", "")
|
||||||
|
max_agents = int((data or {}).get("max_agents", 3))
|
||||||
|
|
||||||
|
# Input validation (stateless — always deterministic, no need to cache)
|
||||||
|
if not name:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "name is required"}
|
||||||
|
if len(name) > 100:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "name too long (max 100 chars)"}
|
||||||
|
if not domain:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "domain is required"}
|
||||||
|
if len(domain) > 255:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "domain too long (max 255 chars)"}
|
||||||
|
if not email or "@" not in email or len(email) > 255:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "valid email is required"}
|
||||||
|
if channel not in ("web_widget", "api"):
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "type must be 'web_widget' or 'api'"}
|
||||||
|
|
||||||
|
# Idempotency check (after validation, so only provisioning results get cached)
|
||||||
|
cached = _check_idempotency(idempotency_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# ── Provisioning (non-idempotent, stateful operations) ──────
|
||||||
|
try:
|
||||||
|
# 1. Create Chatwoot agent (Platform API + Agents API)
|
||||||
|
agent_info = _create_agent(email, name)
|
||||||
|
|
||||||
|
# 2. Create team
|
||||||
|
team = _call_cw(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams",
|
||||||
|
{
|
||||||
|
"name": f"{name} 客服团队",
|
||||||
|
"description": f"{name} 的专属客服团队(限制 {max_agents} 席)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
team_id = team.get("id")
|
||||||
|
|
||||||
|
# 3. Assign agent to team
|
||||||
|
_add_agent_to_team(agent_info["agent_cw_id"], team_id)
|
||||||
|
|
||||||
|
# 4. Create inbox
|
||||||
|
inbox_payload = {
|
||||||
|
"name": name,
|
||||||
|
"channel": {
|
||||||
|
"type": channel,
|
||||||
|
"website_url": domain if channel == "web_widget" else "",
|
||||||
|
"widget_color": "#6366f1",
|
||||||
|
"welcome_title": f"欢迎来到 {name}",
|
||||||
|
"welcome_tagline": f"您好!欢迎访问 {name},请问有什么可以帮您?",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
inbox = _call_cw(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes",
|
||||||
|
inbox_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
inbox_id = inbox.get("id")
|
||||||
|
inbox_token = inbox.get("access_token", "")
|
||||||
|
inbox_name = inbox.get("name", name)
|
||||||
|
website_token = inbox.get("website_token", "") or inbox.get("access_token", "")
|
||||||
|
embed_code = (
|
||||||
|
f"<script>\n"
|
||||||
|
f" (function(d,t) {{\n"
|
||||||
|
f" var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n"
|
||||||
|
f' g.src="{CW_BASE}/packs/js/sdk.js";\n'
|
||||||
|
f" g.defer=1;g.async=1;\n"
|
||||||
|
f" s.parentNode.insertBefore(g,s);\n"
|
||||||
|
f" g.onload=function(){{\n"
|
||||||
|
f" window.chatwootSettings={{\n"
|
||||||
|
f' position:"right",\n'
|
||||||
|
f' type:"standard",\n'
|
||||||
|
f' launcherTitle:"Chat"\n'
|
||||||
|
f" }};\n"
|
||||||
|
f" window.chatwootSDK.run({{\n"
|
||||||
|
f' websiteToken:"{website_token}",\n'
|
||||||
|
f' baseUrl:"{CW_BASE}"\n'
|
||||||
|
f" }});\n"
|
||||||
|
f" }};\n"
|
||||||
|
f" }})(document,\"script\");\n"
|
||||||
|
f"</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Create agent workspace
|
||||||
|
if not agent_id:
|
||||||
|
agent_id = f"chathub-{inbox_id}"
|
||||||
|
agent_dir = _ensure_agent_workspace(agent_id, name)
|
||||||
|
|
||||||
|
# 6. Update inboxes.json
|
||||||
|
config = _read_inboxes()
|
||||||
|
entry = _build_inbox_entry(name, domain, channel, inbox_id, inbox_token, agent_id)
|
||||||
|
config[str(inbox_id)] = entry
|
||||||
|
config.setdefault("_meta", {})["updated_at"] = (
|
||||||
|
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
)
|
||||||
|
_write_inboxes(config)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"inbox_id": inbox_id,
|
||||||
|
"inbox_token": inbox_token,
|
||||||
|
"inbox_name": inbox_name,
|
||||||
|
"team_id": team_id,
|
||||||
|
"team_name": f"{name} 客服团队",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"agent_cw_id": agent_info["agent_cw_id"],
|
||||||
|
"agent_cw_password": agent_info["password"],
|
||||||
|
"agent_dir": str(agent_dir),
|
||||||
|
"embed_code": embed_code,
|
||||||
|
"ws_agent_updated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
bottle.response.status = 502
|
||||||
|
result = {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
bottle.response.status = 502
|
||||||
|
result = {"error": f"Chatwoot network error: {e.reason}"}
|
||||||
|
except Exception as e:
|
||||||
|
bottle.response.status = 500
|
||||||
|
result = {"error": str(e)}
|
||||||
|
|
||||||
|
# Store idempotency result (both success and failure)
|
||||||
|
_store_idempotency(idempotency_key, result if result else {"error": "unknown"}, bottle.response.status_code)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@bottle.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suspend endpoint ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _disable_inbox(inbox_id: int) -> None:
|
||||||
|
"""Disable Chatwoot inbox: rename, clear website_url, disable auto-assignment."""
|
||||||
|
_call_cw(
|
||||||
|
"PUT",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||||
|
{
|
||||||
|
"enable_auto_assignment": False,
|
||||||
|
"name": f"[Suspended] Inbox #{inbox_id}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Also clear channel website_url if web_widget
|
||||||
|
try:
|
||||||
|
inbox = _call_cw("GET", f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}")
|
||||||
|
ch = inbox.get("channel", {})
|
||||||
|
if ch.get("type") == "Channel::WebWidget":
|
||||||
|
_call_cw(
|
||||||
|
"PUT",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||||
|
{
|
||||||
|
"channel": {
|
||||||
|
"website_url": "",
|
||||||
|
"welcome_title": "Service Suspended",
|
||||||
|
"welcome_tagline": "This chat service has been suspended.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Failed to clear inbox %d channel settings: %s", inbox_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_inbox_status(inbox_id: int, status: str) -> None:
|
||||||
|
"""Update inboxes.json entry status."""
|
||||||
|
config = _read_inboxes()
|
||||||
|
key = str(inbox_id)
|
||||||
|
if key in config:
|
||||||
|
config[key]["status"] = status
|
||||||
|
config.setdefault("_meta", {})["updated_at"] = (
|
||||||
|
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
)
|
||||||
|
_write_inboxes(config)
|
||||||
|
|
||||||
|
|
||||||
|
@bottle.post("/suspend")
|
||||||
|
def suspend():
|
||||||
|
"""Suspend a tenant: disable Chatwoot inbox + mark inboxes.json as suspended."""
|
||||||
|
auth_err = _check_api_key()
|
||||||
|
if auth_err:
|
||||||
|
return auth_err
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = bottle.request.json
|
||||||
|
except Exception:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "invalid JSON body"}
|
||||||
|
|
||||||
|
inbox_id = (data or {}).get("inbox_id")
|
||||||
|
if not inbox_id:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "inbox_id is required"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Disable Chatwoot inbox
|
||||||
|
_disable_inbox(int(inbox_id))
|
||||||
|
|
||||||
|
# 2. Mark inboxes.json as suspended
|
||||||
|
_update_inbox_status(int(inbox_id), "suspended")
|
||||||
|
|
||||||
|
return {"success": True, "message": f"Inbox {inbox_id} suspended"}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
bottle.response.status = 502
|
||||||
|
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||||
|
except Exception as e:
|
||||||
|
bottle.response.status = 500
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@bottle.post("/activate")
|
||||||
|
def activate():
|
||||||
|
"""Re-activate a suspended tenant: re-enable inbox + mark active."""
|
||||||
|
auth_err = _check_api_key()
|
||||||
|
if auth_err:
|
||||||
|
return auth_err
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = bottle.request.json
|
||||||
|
except Exception:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "invalid JSON body"}
|
||||||
|
|
||||||
|
inbox_id = (data or {}).get("inbox_id")
|
||||||
|
if not inbox_id:
|
||||||
|
bottle.response.status = 400
|
||||||
|
return {"error": "inbox_id is required"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_call_cw(
|
||||||
|
"PUT",
|
||||||
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||||
|
{"enable_auto_assignment": True},
|
||||||
|
)
|
||||||
|
_update_inbox_status(int(inbox_id), "active")
|
||||||
|
|
||||||
|
return {"success": True, "message": f"Inbox {inbox_id} activated"}
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
bottle.response.status = 502
|
||||||
|
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||||
|
except Exception as e:
|
||||||
|
bottle.response.status = 500
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5566
|
||||||
|
log.info("Starting ChatHub Provision Server on port %d (threaded)", port)
|
||||||
|
bottle.run(host="0.0.0.0", port=port, debug=False, server="wsgiref", num_threads=4)
|
||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
websocket-client>=1.6.0
|
requests>=2.31.0
|
||||||
requests>=2.28.0
|
websocket-client>=1.7.0
|
||||||
python-dotenv>=1.0.0
|
bottle>=0.13.0
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ⚠️ WARNING: This script is deprecated. WS Agent is now managed by supervisor.
|
||||||
|
# Use: supervisorctl start ws_agent
|
||||||
|
#
|
||||||
|
# If you need to manually start (e.g. for debugging):
|
||||||
|
cd /app/working/workspaces/wordpress/skills/wordpress-cli
|
||||||
|
exec nohup python3 -u chatwoot_ws_agent.py > /var/log/chatwoot_ws_agent.log 2>&1 &
|
||||||
Reference in New Issue
Block a user