diff --git a/.env.example b/.env.example
index 9df972f..ceedce3 100644
--- a/.env.example
+++ b/.env.example
@@ -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
-CHATWOOT_BASE_URL=https://your-chatwoot-domain.com
-CHATWOOT_WS_URL=wss://your-chatwoot-domain.com/cable
+# ── WS Agent ──
+CW_PUBSUB_TOKEN=
+CW_USER_ID=1
-# API Credentials (create via Chatwoot Profile > Access Token)
-ACCESS_TOKEN=your_access_token
-USER_UID=user@email.com
-USER_TOKEN=your_user_token
-USER_CLIENT=client_name
-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
+# ── Provision Server ──
+CW_ADMIN_EMAIL=admin@example.com
+CW_ADMIN_PASSWORD=your-chatwoot-password
+CW_PLATFORM_TOKEN=your-platform-api-token
+CHATHUB_API_KEY=change-me-to-a-random-string
diff --git a/.gitignore b/.gitignore
index 49c3b4c..a999b37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,31 +1,8 @@
-# Environment
-.env
-*.env.local
-
-# Python
+chatwoot_auth.json
+inboxes.json
+.chatwoot_ws_state.json
+.chatwoot_ws_processed.json
+.chatwoot_ws_metrics.json
__pycache__/
*.pyc
-*.pyo
-.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
+.env
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb18b1f..9ddf9c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,39 @@
# 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) — 监控运维 + 代码清理
### 新增
diff --git a/PROFILE-amazon-agent.md b/PROFILE-amazon-agent.md
deleted file mode 100644
index bfa71a0..0000000
--- a/PROFILE-amazon-agent.md
+++ /dev/null
@@ -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身份
diff --git a/README.md b/README.md
index 65810e7..a1c23c9 100644
--- a/README.md
+++ b/README.md
@@ -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.
-> Single WebSocket agent routes conversations to different AI agents per inbox — with seamless AI ↔ Human handoff.
+基于 Chatwoot ActionCable WebSocket 的实时 AI 客服系统,支持多租户、人工/AI 无缝切换、自动开通。
-## Architecture
+## 架构概览
```
-Customer (Web Widget / API)
- │
- ▼
-Chatwoot Self-Hosted (wss://chatwoot.275763.xyz/cable)
- │
- ▼
- WS Agent (chatwoot_ws_agent.py)
- │ reads inboxes.json → routes by inbox_id
- │
- ├── Inbox 1 → sourcing-agent (EN, 采购代理)
- ├── Inbox 7 → halo-blog-agent (中文, 安防弱电顾问)
- └── Inbox 8 → amazon-agent (EN, Amazon 客服)
+┌─────────────────────────────────────────────────────┐
+│ QwenPaw Agent │
+│ ┌─────────────────────┐ ┌──────────────────────┐ │
+│ │ WS Agent │ │ Provision Server │ │
+│ │ (WebSocket 长连接) │ │ (HTTP API :5566) │ │
+│ │ • 接收实时消息 │ │ • 自动开通租户 │ │
+│ │ • AI 自动回复 │ │ • 创建 Inbox/Team │ │
+│ │ • 人工/AI 切换 │ │ • 创建 AI Agent │ │
+│ │ • 多 Inbox 路由 │ │ • 写入路由配置 │ │
+│ └─────────┬─────────────┘ └──────────┬───────────┘ │
+│ │ │ │
+└────────────┼───────────────────────────┼───────────────┘
+ │ WebSocket (wss) │ HTTP API
+ ▼ ▼
+ ┌────────────────┐ ┌──────────────────┐
+ │ Chatwoot │ │ FastAdmin │
+ │ (自托管客服系统)│◄───────│ (PHP 管理后台) │
+ └────────────────┘ └──────────────────┘
```
-## Features
+## 组件说明
-| Feature | Description |
-|:--------|:-----------|
-| **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 |
+### 1. WS Agent (`chatwoot_ws_agent.py`)
-## Routing Matrix
+WebSocket 长连接实时 AI 客服,**1147 行**。
-| Inbox | Site | Agent | Lang | Role |
-|:-----:|:-----|:------|:----:|:-----|
-| 1 | greatqiu.cn | sourcing-agent | EN | Global Sourcing Advisor |
-| 7 | shopqiu.com | halo-blog-agent | 中文 | 安防弱电技术顾问 |
-| 8 | Amazon (API) | amazon-agent | EN | Amazon Customer Service |
+**核心技术:**
+- **ActionCable WebSocket** — 通过 Chatwoot RoomChannel 实时接收消息事件,零轮询
+- **多 Inbox 路由** — 根据 `inbox_id` 分发到不同 AI Agent,支持 30+ 租户并发
+- **人工 ↔ AI 无缝切换** — 通过消息 ID 追踪 (`ai_sent_msg_ids`) + 对话 Pending 机制 (`_ai_pending_convs`) 精准区分 AI 回复和人工回复,不误判
+- **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
-git clone https://github.com/hanmolabiqiu/chatwoot-ai-agent.git
-cd chatwoot-ai-agent
+# 1. 安装依赖
pip install -r requirements.txt
-cp .env.example .env
-# Edit .env with your Chatwoot API credentials
-python3 chatwoot_ws_agent.py
+
+# 2. 配置环境变量(详见 .env.example)
+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
-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)
-python3 chatwoot_ws_agent.py --ws-status # WebSocket connection status
-python3 chatwoot_ws_agent.py --list-inboxes # List configured inboxes
-python3 chatwoot_ws_agent.py --inbox-stats # Formatted inbox statistics table
-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
-```
+### WS Agent 必需
+| 变量 | 说明 |
+|------|------|
+| `CW_BASE` | Chatwoot 服务器地址 |
+| `CW_EMAIL` | 管理员账号邮箱 |
+| `CW_PASSWORD` | 管理员密码 |
+| `CW_PUBSUB_TOKEN` | Chatwoot ActionCable pubsub token(首次运行自动获取) |
-### 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_ws_agent.py # Core engine — WS agent with multi-inbox routing
-├── inboxes.json # Hot-reloadable inbox routing config
-├── provision.py # Auto-provision new tenants
-├── CHANGELOG.md # Version history (v1.0 → v1.3)
-├── knowledge-base.md # Knowledge base for halo-blog-agent
-├── SOUL-halo-blog-agent.md # AI personality — 安防弱电 (Chinese)
-├── SOUL-amazon-agent.md # AI personality — Amazon CS (English)
-├── PROFILE-amazon-agent.md # Amazon agent profile
-├── .env.example # Environment config template
-├── requirements.txt # Python dependencies
-├── README.md # This file
+├── chatwoot_ws_agent.py # WebSocket AI Agent(核心,1147 行)
+├── provision_server.py # HTTP 开通服务(555 行)
+├── chatwoot_ws_ctl.sh # 进程管理脚本
+├── start_agent.sh # 启动脚本(旧,推荐用 supervisor)
+├── .env.example # 环境变量模板
+├── requirements.txt # Python 依赖
+├── chatwoot_auth.example.json # Session 认证文件模板
+├── inboxes.example.json # 路由配置模板
└── .gitignore
```
-## Version History
+## 版本历史
-| Ver | Date | Highlights |
-|:----|:-----|:-----------|
-| v1.3 | 2026-06-03 | Metrics monitoring, health check CLI, default config fallback, inbox-stats cleanup |
-| v1.2 | 2026-06-02 | Hot-reload `inboxes.json`, `provision.py` auto-provision |
-| v1.1 | 2026-06-02 | Amazon API inbox, API inbox human detection fix |
-| v1.0 | 2026-06-01 | Initial: dual inbox routing, AI↔Human handoff, knowledge base |
+| 版本 | 说明 |
+|------|------|
+| v1.0 | 初始 WebSocket 版本,支持基本 AI 回复 |
+| v1.1 | Amazon API 集成,人工/AI 切换修复 |
+| v1.2 | 热加载配置架构 |
+| v1.3 | 代码清理优化,Metrics 监控 |
+| v1.4 | 多租户架构,Provision Server,状态持久化,安全性修复 |
-## Roadmap
+## 许可证
-- [x] Multi-inbox routing (GreatQiu + HALO + Amazon)
-- [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
+MIT License
diff --git a/SOUL-amazon-agent.md b/SOUL-amazon-agent.md
deleted file mode 100644
index dd50ee3..0000000
--- a/SOUL-amazon-agent.md
+++ /dev/null
@@ -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
diff --git a/SOUL-halo-blog-agent.md b/SOUL-halo-blog-agent.md
deleted file mode 100644
index 5f2042a..0000000
--- a/SOUL-halo-blog-agent.md
+++ /dev/null
@@ -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
-
----
-
-_这文件随我进化。每积累一次问答经验,我就更新它。_
diff --git a/chatwoot_auth.example.json b/chatwoot_auth.example.json
new file mode 100644
index 0000000..07185f4
--- /dev/null
+++ b/chatwoot_auth.example.json
@@ -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"
+}
\ No newline at end of file
diff --git a/chatwoot_ws_agent.py b/chatwoot_ws_agent.py
index 18b9a30..101b446 100644
--- a/chatwoot_ws_agent.py
+++ b/chatwoot_ws_agent.py
@@ -30,7 +30,7 @@ from threading import Thread, Event, Lock
from collections import defaultdict
# ===== 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_ACCOUNT_ID = 1
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"
PROCESSED_FILE = SCRIPT_DIR / ".chatwoot_ws_processed.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_CONFIG_FILE = Path(os.environ.get(
@@ -74,12 +75,18 @@ DEFAULT_INBOX_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"]
+ template_placeholders = ["{sender_name}", "{customer_msg}"]
if not isinstance(config, dict):
return False
for key in required_keys:
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 True
@@ -134,13 +141,11 @@ def _load_inboxes_config():
if not INBOX_CONFIG:
INBOX_CONFIG = DEFAULT_INBOX_CONFIG.copy()
-# Agent identity (from /api/v1/profile response)
-PUBSUB_TOKEN = "JQ3wQYDy6LUMwvHouKKV2scr"
-USER_ID = 1
-
# Login credentials for auto-renewal
-CW_EMAIL = os.environ.get("CW_EMAIL", "qiuzhida@greatqiu.cn")
-CW_PASSWORD = os.environ.get("CW_PASSWORD", "Qaly8980+")
+CW_EMAIL = os.environ.get("CW_EMAIL")
+CW_PASSWORD = os.environ.get("CW_PASSWORD")
+# Agent identity (from /api/v1/profile response)
+
RENEW_THRESHOLD = timedelta(hours=6)
TZ = timezone(timedelta(hours=8))
@@ -160,12 +165,13 @@ def log(msg, level="INFO", inbox_name=None):
# ===== MONITORING & 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):
self.filepath = Path(filepath)
self.lock = Lock()
self.data = self._load()
+ self._dirty = False
def _load(self):
if self.filepath.exists():
@@ -182,15 +188,24 @@ class Metrics:
}
def _save(self):
+ """Write to disk only if dirty."""
+ if not self._dirty:
+ return
try:
self.filepath.write_text(json.dumps(self.data, indent=2))
+ self._dirty = False
except Exception as e:
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):
with self.lock:
self.data["ws_connected"] = True
- self._save()
+ self._dirty = True
def ws_disconnected(self, reason="unknown"):
with self.lock:
@@ -200,7 +215,7 @@ class Metrics:
"time": datetime.now(TZ).isoformat(),
"reason": reason
}
- self._save()
+ self._dirty = True
log(f"⚠️ WebSocket disconnected: {reason}", "WARN")
def record_reply(self, inbox_id, inbox_name, success, duration_ms):
@@ -226,7 +241,7 @@ class Metrics:
inbox["total_duration_ms"] += duration_ms
inbox["avg_duration_ms"] = inbox["total_duration_ms"] / inbox["total_requests"]
inbox["last_reply"] = datetime.now(TZ).isoformat()
- self._save()
+ self._dirty = True
def get_summary(self):
with self.lock:
@@ -281,12 +296,7 @@ def get_headers():
"expiry": auth.get("expiry"),
"uid": auth.get("uid"),
}
- return {
- "access-token": "uueUhS5OBWOeabNdleUa8w",
- "client": "4xu1KgEP3RzNoM86hAkeCg",
- "expiry": "1785135457",
- "uid": "qiuzhida@greatqiu.cn",
- }
+ return None
def renew_session():
log("Renewing Chatwoot session...")
@@ -302,7 +312,14 @@ def renew_session():
if not all([access_token, client, expiry, uid]):
log(f"Missing headers: {dict(r.headers)}")
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,
+ "pubsub_token": pubsub_token,
"updated_at": datetime.now(TZ).isoformat()}
save_auth(data)
exp_time = datetime.fromtimestamp(int(expiry))
@@ -333,8 +350,10 @@ def ensure_session():
"expiry": new_auth["expiry"],
"uid": new_auth["uid"],
}
- log("WARNING: Using fallback headers")
- return get_headers()
+ raise RuntimeError(
+ "No Chatwoot session available. "
+ "Ensure 'chatwoot_auth.json' exists or CW_EMAIL/CW_PASSWORD env vars are set."
+ )
# ===== PROCESSED MESSAGE TRACKING =====
@@ -351,6 +370,16 @@ def save_processed(ids):
processed_ids = load_processed()
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):
with processed_lock:
@@ -366,9 +395,18 @@ def mark_processed(msg_id):
# Track message IDs that OUR AI sent via the API.
# This lets us distinguish our own AI replies from human agent messages
# when receiving events via WebSocket (since both use sender_type="User").
+MAX_AI_SENT_IDS = 10000
ai_sent_msg_ids = set()
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)
# Entries are added BEFORE API call, removed AFTER tracking the real message ID.
_ai_pending_convs = set()
@@ -433,6 +471,53 @@ def clean_expired_human_active():
del human_active_convs[cid]
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 =====
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)
if not config:
- log(f"No config for inbox #{inbox_id}, falling back to sourcing-agent")
- config = INBOX_CONFIG[1]
+ log(f"No config for inbox #{inbox_id}, skipping", "WARN")
+ return None
prompt = config["prompt_template"].format(
sender_name=sender_name,
@@ -752,7 +837,6 @@ class WSAgent:
self.headers = None
self.running = Event()
self.running.set()
- self.reconnect_delay = 1
self.last_pong = time.time()
def on_open(self, ws):
@@ -786,7 +870,6 @@ class WSAgent:
if t == "confirm_subscription":
log("✅ RoomChannel subscription confirmed")
- self.reconnect_delay = 1
return
if t == "reject_subscription":
@@ -879,15 +962,6 @@ class WSAgent:
def on_close(self, ws, status, msg):
log(f"🔴 WebSocket closed (status={status}, msg={msg})", "WARN")
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):
"""Start WebSocket connection (blocking)."""
@@ -920,18 +994,58 @@ class WSAgent:
# ===== TIMEOUT CHECKER THREAD =====
def timeout_checker_loop():
- """Background thread: clean expired handoffs + hot-reload config."""
+ """Background thread: clean expired handoffs + hot-reload config + persist state."""
while True:
time.sleep(30) # Check every 30s
try:
clean_expired_human_active()
+ prune_ai_sent_ids()
+ prune_processed_ids()
_load_inboxes_config() # hot-reload if file changed
+ save_state() # persist state every 30s
+ metrics.flush() # persist metrics every 30s
except Exception as 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 =====
def main():
+
+ # Restore persisted state BEFORE handling any args (--health etc. need it)
+ load_state()
+
parser = argparse.ArgumentParser(description="Chatwoot WebSocket AI Agent")
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")
@@ -995,7 +1109,7 @@ def main():
"ws_last_disconnect": None,
"inboxes": {}
}
- metrics._save()
+ metrics.flush()
print("Metrics reset successfully")
return
@@ -1084,12 +1198,26 @@ def main():
# Start the main agent
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:
agent.start()
except KeyboardInterrupt:
log("Shutting down...")
agent.stop()
+ save_state()
+ metrics.flush()
if __name__ == "__main__":
main()
+
diff --git a/chatwoot_ws_ctl.sh b/chatwoot_ws_ctl.sh
new file mode 100755
index 0000000..8cd1db9
--- /dev/null
+++ b/chatwoot_ws_ctl.sh
@@ -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
diff --git a/inboxes.example.json b/inboxes.example.json
new file mode 100644
index 0000000..8cec206
--- /dev/null
+++ b/inboxes.example.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/inboxes.json b/inboxes.json
deleted file mode 100644
index a013589..0000000
--- a/inboxes.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/knowledge-base.md b/knowledge-base.md
deleted file mode 100644
index 0f4593c..0000000
--- a/knowledge-base.md
+++ /dev/null
@@ -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 文章 + 互联网技术资料*
diff --git a/provision.py b/provision.py
deleted file mode 100644
index 9b0796e..0000000
--- a/provision.py
+++ /dev/null
@@ -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" ")
- 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()
diff --git a/provision_server.py b/provision_server.py
new file mode 100644
index 0000000..db52e02
--- /dev/null
+++ b/provision_server.py
@@ -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""
+ )
+
+ # 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)
diff --git a/requirements.txt b/requirements.txt
index 2357fd1..237dba7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
-websocket-client>=1.6.0
-requests>=2.28.0
-python-dotenv>=1.0.0
+requests>=2.31.0
+websocket-client>=1.7.0
+bottle>=0.13.0
diff --git a/start_agent.sh b/start_agent.sh
new file mode 100755
index 0000000..12d8542
--- /dev/null
+++ b/start_agent.sh
@@ -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 &