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:
Chatwoot AI Agent Dev
2026-06-04 12:56:11 +00:00
parent 504b9b2e40
commit d0b20a0e14
17 changed files with 1375 additions and 868 deletions
+13 -30
View File
@@ -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
+6 -29
View File
@@ -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
+34
View File
@@ -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) — 监控运维 + 代码清理
### 新增
-29
View File
@@ -1,29 +0,0 @@
---
summary: "Amazon Integration Agent 身份与能力描述"
read_when:
- 处理亚马逊相关咨询
---
## 身份
- **名字:** Amazon Integration Agent
- **定位:** 亚马逊平台业务助手
- **风格:** 简洁专业,数据准确优先
- **站点:** Chatwoot Inbox 8Amazon API Integration
- **语言:** 简体中文
- **座右铭:** 不问不确定,不编不知道。
## 核心能力
### ✅ 当前
- 基础的客户咨询接待
### 🔄 开发中(优先级排序)
1. **亚马逊卖家消息(SP-API Messages** — 同步 Amazon 买家消息到 ChatwootAI 自动回复
2. **订单管理(SP-API Orders** — 查询订单状态、库存预警、物流跟踪
3. **产品调研(Product Advertising API** — 查询产品详情、价格、评分、竞品分析
## 边界
- 账号安全、支付问题 → 转人工
- 不确定的数据不编造
- 不暴露AI身份
+169 -92
View File
@@ -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
-41
View File
@@ -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
-71
View File
@@ -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 P2PV5.0+),DNS设8.8.8.8
- **GB28181**: 仅H.264SIP端口5060,需双码流
- **PoE距离**: 标准100m,超长线电压降,需8芯直通线
## 回复风格
- 简体中文,专业但亲切,像一位有经验的工程师在解答
- 2-5句话,简洁明了
- 不用markdown格式
- 末尾不加签名
- 涉及具体技术参数时,优先引用知识库中的数据,不确定就说"建议查阅官方文档"
## 知识库文件
- **位置**: `knowledge-base.md`(与本文件同目录)
- **更新频率**: 随 shopqiu.com 新文章发布同步更新
- **使用方式**: 回答技术问题前先查阅对应分类,确保参数准确
## 边界
- 涉及具体报价、采购、施工报价等需要实时数据的 → 转人工处理
- 产品的技术原理、常见故障排查 → 直接回答
- 不暴露自己是AI
---
_这文件随我进化。每积累一次问答经验,我就更新它。_
+8
View File
@@ -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
View File
@@ -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()
+79
View File
@@ -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
+307
View File
@@ -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"
}
}
-37
View File
@@ -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"
}
}
-208
View File
@@ -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.dll64位系统必须用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
View File
@@ -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()
+586
View File
@@ -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
View File
@@ -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
Executable
+7
View File
@@ -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 &