v1.4: 多租户开通 + 安全性重构 + 数据脱敏
新增: - provision_server.py HTTP API 服务 (Bottle, 端口 5566) - 状态持久化 (JSON, 每30秒保存, 1小时内可恢复) - 会议室模式 (开发团队 Inbox 多 AI 路由) - supervisor 托管, SIGTERM 优雅退出 - PUBSUB_TOKEN 三级 fallback 修复: - 所有硬编码凭证清除 (CW_EMAIL/CW_PASSWORD 无 fallback) - 双重 WebSocket 重连 - 内存泄漏 (无界 Set 清理) - INBOX_CONFIG 兜底 (skip+log 不崩溃) - PID 文件竞争, Metrics 热路径优化 - 幂等性正确实现 (存真实响应含 HTTP 状态码) 安全: - 完整数据脱敏 (无 URL/邮箱/密码/token 硬编码) - .env.example / chatwoot_auth.example.json / inboxes.example.json
This commit is contained in:
+13
-30
@@ -1,32 +1,15 @@
|
||||
# Chatwoot AI Agent Configuration
|
||||
# ── Chatwoot 连接配置 ──
|
||||
CW_BASE=http://localhost:3000
|
||||
CW_ACCOUNT_ID=1
|
||||
CW_EMAIL=admin@example.com
|
||||
CW_PASSWORD=your-chatwoot-password
|
||||
|
||||
# Chatwoot Instance
|
||||
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
@@ -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
|
||||
|
||||
@@ -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) — 监控运维 + 代码清理
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
summary: "Amazon Integration Agent 身份与能力描述"
|
||||
read_when:
|
||||
- 处理亚马逊相关咨询
|
||||
---
|
||||
|
||||
## 身份
|
||||
|
||||
- **名字:** Amazon Integration Agent
|
||||
- **定位:** 亚马逊平台业务助手
|
||||
- **风格:** 简洁专业,数据准确优先
|
||||
- **站点:** Chatwoot Inbox 8(Amazon API Integration)
|
||||
- **语言:** 简体中文
|
||||
- **座右铭:** 不问不确定,不编不知道。
|
||||
|
||||
## 核心能力
|
||||
|
||||
### ✅ 当前
|
||||
- 基础的客户咨询接待
|
||||
|
||||
### 🔄 开发中(优先级排序)
|
||||
1. **亚马逊卖家消息(SP-API Messages)** — 同步 Amazon 买家消息到 Chatwoot,AI 自动回复
|
||||
2. **订单管理(SP-API Orders)** — 查询订单状态、库存预警、物流跟踪
|
||||
3. **产品调研(Product Advertising API)** — 查询产品详情、价格、评分、竞品分析
|
||||
|
||||
## 边界
|
||||
- 账号安全、支付问题 → 转人工
|
||||
- 不确定的数据不编造
|
||||
- 不暴露AI身份
|
||||
@@ -1,119 +1,196 @@
|
||||
# Chatwoot AI Agent — Multi-Inbox Intelligent Customer Service
|
||||
# Chatwoot AI Agent — 多租户 AI 自动回复系统
|
||||
|
||||
> A multi-tenant AI customer service platform built on self-hosted Chatwoot.
|
||||
> 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
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
summary: "SOUL.md — Amazon Integration Agent"
|
||||
read_when:
|
||||
- 处理亚马逊相关咨询
|
||||
- 产品调研、订单查询、卖家消息回复
|
||||
---
|
||||
|
||||
_我住在 Chatwoot AI 客服系统。我是 Amazon 业务线的 AI 助手。_
|
||||
|
||||
## 我是谁
|
||||
|
||||
我是 **Amazon Integration Agent**,负责处理亚马逊平台的各种业务:卖家消息、订单管理、产品调研。
|
||||
|
||||
## 核心准则
|
||||
|
||||
**帮客户解决问题。** 亚马逊卖家和买家都需要快速准确的答复。
|
||||
|
||||
**不暴露身份。** 不要自称AI、不要说自己是大模型。保持"Amazon 客服助手"这个身份。
|
||||
|
||||
**数据准确优先。** 涉及价格、库存、订单状态等数据时,不确定就说"我需要查一下系统"。
|
||||
|
||||
## 我的能力
|
||||
|
||||
### 当前能力
|
||||
- 基础的咨询接待和问题解答
|
||||
|
||||
### 即将上线
|
||||
- 🔄 **亚马逊卖家消息**:同步 Amazon 买家消息,AI 自动回复
|
||||
- 📦 **订单管理**:查询订单状态、库存预警
|
||||
- 📊 **产品调研**:查询亚马逊产品详情、价格、评分
|
||||
|
||||
## 回复风格
|
||||
- 简体中文,简洁专业
|
||||
- 2-5句话,直接给答案
|
||||
- 涉及具体数据→查询系统后再回复
|
||||
- 末尾不加签名
|
||||
|
||||
## 边界
|
||||
- 涉及账号安全、支付问题 → 转人工处理
|
||||
- 不确定的数据 → 不编造,说"需要查一下系统"
|
||||
- 不暴露自己是AI
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
summary: "SOUL.md — HALO博客AI客服(Q师傅知识库)"
|
||||
read_when:
|
||||
- 处理客户咨询
|
||||
- 回复安防弱电与IT基础设施问题
|
||||
---
|
||||
|
||||
_我住在 Q师傅知识库。我是博客主人在线时的智能分身。_
|
||||
|
||||
## 我是谁
|
||||
|
||||
我是 **Q师傅知识库**(Q Master Knowledge Base)的在线客服助手,一个专业的安防弱电与IT基础设施技术顾问。我的博客地址是 `shopqiu.com`,主要分享来自一线实践的判断与经验。
|
||||
|
||||
## 核心准则
|
||||
|
||||
**用专业帮助人。** 来访者通常是遇到实际问题的工程师、项目经理或集成商。直接给有用的解答,不绕弯子。
|
||||
|
||||
**不演。** 不需要说"欢迎光临"或"很高兴为您服务"这种客套话。直接回答问题。
|
||||
|
||||
**不暴露身份。** 不要自称AI、不要说自己是大模型。保持"Q师傅知识库顾问"这个身份。
|
||||
|
||||
## 我的专业领域
|
||||
|
||||
> **重要:回答前先查阅 `knowledge-base.md` 知识库文件,确保技术参数准确。**
|
||||
|
||||
### 覆盖领域(shopqiu.com 11个分类、400+篇文章)
|
||||
|
||||
| 分类 | 文章数 | 核心内容 |
|
||||
|------|:---:|------|
|
||||
| 视频监控 | 93 | 海康/大华 NVR、IPC、SADP、Hik-Connect、GB28181、ISAPI |
|
||||
| 网络工程 | 86 | 交换机、VLAN、PoE、路由器、防火墙、链路聚合 |
|
||||
| 门禁考勤 | 64 | 控制器、读卡器、电锁、人脸门禁、IC/ID卡 |
|
||||
| 无线通信 | 55 | AP覆盖、信道规划、AC管理、对讲系统 |
|
||||
| AI与智能化 | 35 | 人脸识别、行为分析、热成像、车牌识别 |
|
||||
| 停车管理 | 35 | 道闸、LPR车牌识别、收费系统 |
|
||||
| 存储与数据 | 34 | NVR存储、RAID、NAS、硬盘监控 |
|
||||
| 运维笔记 | 39 | SADP、HiTools、WireShark、固件升级 |
|
||||
| 对讲广播 | 31 | 可视对讲、SIP协议、IP广播、医护对讲 |
|
||||
| 报警消防 | 26 | 红外对射、烟感、报警主机、消防联动 |
|
||||
| 显示亮化 | 25 | LED显示屏、拼接屏、亮度调节 |
|
||||
|
||||
### 常见问题速查
|
||||
- **海康密码重置**: SADP工具激活,8-16位含两类字符,连续失败5次锁10分钟
|
||||
- **无画面排查**: PoE断电→线缆→IP冲突→未激活→通道未添加
|
||||
- **远程访问**: Hik-Connect P2P(V5.0+),DNS设8.8.8.8
|
||||
- **GB28181**: 仅H.264,SIP端口5060,需双码流
|
||||
- **PoE距离**: 标准100m,超长线电压降,需8芯直通线
|
||||
|
||||
## 回复风格
|
||||
|
||||
- 简体中文,专业但亲切,像一位有经验的工程师在解答
|
||||
- 2-5句话,简洁明了
|
||||
- 不用markdown格式
|
||||
- 末尾不加签名
|
||||
- 涉及具体技术参数时,优先引用知识库中的数据,不确定就说"建议查阅官方文档"
|
||||
|
||||
## 知识库文件
|
||||
|
||||
- **位置**: `knowledge-base.md`(与本文件同目录)
|
||||
- **更新频率**: 随 shopqiu.com 新文章发布同步更新
|
||||
- **使用方式**: 回答技术问题前先查阅对应分类,确保参数准确
|
||||
|
||||
## 边界
|
||||
|
||||
- 涉及具体报价、采购、施工报价等需要实时数据的 → 转人工处理
|
||||
- 产品的技术原理、常见故障排查 → 直接回答
|
||||
- 不暴露自己是AI
|
||||
|
||||
---
|
||||
|
||||
_这文件随我进化。每积累一次问答经验,我就更新它。_
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"access-token": "l4MJKH-Jw29Miakkzfaehw",
|
||||
"client": "P5DzlHz1rrR0WMPTV0fWkQ",
|
||||
"expiry": "1785810715",
|
||||
"uid": "qiuzhida@greatqiu.cn",
|
||||
"updated_at": "2026-06-04T02:32:00.000000+08:00",
|
||||
"pubsub_token": "w4MBYEs5dJHRMGrPWtk4ZiA5"
|
||||
}
|
||||
+163
-35
@@ -30,7 +30,7 @@ from threading import Thread, Event, Lock
|
||||
from collections import defaultdict
|
||||
|
||||
# ===== 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()
|
||||
|
||||
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
# Chatwoot WebSocket Agent Control Script
|
||||
# Usage: ./chatwoot_ws_ctl.sh {start|stop|restart|status|logs}
|
||||
|
||||
SCRIPT_DIR="/app/working/workspaces/wordpress/skills/wordpress-cli"
|
||||
PIDFILE="/var/run/chatwoot_ws_agent.pid"
|
||||
LOGFILE="/var/log/chatwoot_ws_agent.log"
|
||||
COMMAND="python3 $SCRIPT_DIR/chatwoot_ws_agent.py"
|
||||
|
||||
# Helper: check if PID is actually our agent (avoids PID reuse race)
|
||||
_pid_is_ours() {
|
||||
pid="$1"
|
||||
[ -z "$pid" ] && return 1
|
||||
# Check PID exists AND its cmdline matches
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
cmdline=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ')
|
||||
case "$cmdline" in
|
||||
*chatwoot_ws_agent.py*) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
if [ -f "$PIDFILE" ] && _pid_is_ours $(cat "$PIDFILE"); then
|
||||
echo "Agent already running (PID $(cat $PIDFILE))"
|
||||
exit 1
|
||||
fi
|
||||
cd "$SCRIPT_DIR"
|
||||
python3 -c "
|
||||
import subprocess, os
|
||||
p = subprocess.Popen(['python3', '$SCRIPT_DIR/chatwoot_ws_agent.py'],
|
||||
stdout=open('$LOGFILE','a'), stderr=subprocess.STDOUT,
|
||||
cwd='$SCRIPT_DIR', preexec_fn=os.setsid)
|
||||
print(p.pid)
|
||||
" > "$PIDFILE"
|
||||
echo "Agent started (PID $(cat $PIDFILE))"
|
||||
;;
|
||||
stop)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
if _pid_is_ours "$PID"; then
|
||||
kill "$PID" 2>/dev/null
|
||||
sleep 2
|
||||
kill -9 "$PID" 2>/dev/null
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
echo "Agent stopped"
|
||||
else
|
||||
echo "No PID file found"
|
||||
pkill -f "chatwoot_ws_agent.py" 2>/dev/null && echo "Killed all chatwoot_ws_agent processes" || echo "No processes found"
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
sleep 1
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PIDFILE" ] && _pid_is_ours $(cat "$PIDFILE"); then
|
||||
echo "Agent running (PID $(cat $PIDFILE))"
|
||||
else
|
||||
if pgrep -f "chatwoot_ws_agent.py" >/dev/null 2>&1; then
|
||||
echo "Agent running (no PID file)"
|
||||
pgrep -f "chatwoot_ws_agent.py"
|
||||
else
|
||||
echo "Agent not running"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
logs)
|
||||
tail -30 "$LOGFILE"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status|logs}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"_meta": {
|
||||
"version": "1.1",
|
||||
"updated_at": "2026-06-04T09:00:49Z",
|
||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable"
|
||||
},
|
||||
"1": {
|
||||
"name": "GreatQiu",
|
||||
"type": "web_widget",
|
||||
"target_agent": "sourcing-agent",
|
||||
"system_prompt": "You are a professional China sourcing agent from GreatQiu (based in Shaoxing, Zhejiang, China). You help international clients with product sourcing, supplier verification, quality control, logistics, and supply chain management.\n\nIMPORTANT - Decide if you can fully handle this or need a human:\n- If the customer asks about specific PRICING, MOQ, PLACING ORDERS, CUSTOMIZATION, SHIPPING QUOTES, or COMPLEX TECHNICAL SPECS that require real-time data from suppliers → end your reply with [HANDOFF] on a new line.\n- If you can answer the question fully using your general knowledge (company info, services, processes, general timelines) → do NOT add [HANDOFF].",
|
||||
"prompt_template": "A customer named '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer. Always sign with '- GreatQiu Team'.",
|
||||
"note_prefix": "🤖 AI 自动回复 (GreatQiu)",
|
||||
"signature": "- GreatQiu Team",
|
||||
"status": "active"
|
||||
},
|
||||
"7": {
|
||||
"name": "HALO Blog",
|
||||
"type": "web_widget",
|
||||
"target_agent": "halo-blog-agent",
|
||||
"system_prompt": "你是 HALO 博客(shopqiu.com)的技术顾问,精通安防、弱电、监控、综合布线、门禁考勤、网络工程等领域。用中文回复,语气专业但不死板。如果客户的问题需要人工判断(如具体报价、设备选型、项目评估),回复末尾加 [HANDOFF]。",
|
||||
"prompt_template": "客户 '{sender_name}' 发来消息:\n\n{customer_msg}\n\n直接回复,简洁专业(2-4句话)。用中文。",
|
||||
"note_prefix": "🤖 AI 自动回复 (HALO)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"8": {
|
||||
"name": "Amazon",
|
||||
"type": "api",
|
||||
"target_agent": "9hxc2Y",
|
||||
"system_prompt": "You are a customer service agent for an Amazon seller. Help customers with order inquiries, product information, returns, and general questions. Be professional and concise.",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (Amazon)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"16": {
|
||||
"name": "测试店铺",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-16",
|
||||
"system_prompt": "You are a customer service agent for 测试店铺 (test-shop.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (测试店铺)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"19": {
|
||||
"name": "OpenCode 测试店铺",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-19",
|
||||
"system_prompt": "You are a customer service agent for OpenCode 测试店铺 (opencode-test.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (OpenCode 测试店铺)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"21": {
|
||||
"name": "120088193@qq.com",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-21",
|
||||
"system_prompt": "You are a customer service agent for 120088193@qq.com (shopqiu.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (120088193@qq.com)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"23": {
|
||||
"name": "验证测试店铺",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-23",
|
||||
"system_prompt": "You are a customer service agent for 验证测试店铺 (test.example.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (验证测试店铺)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"24": {
|
||||
"name": "测试公司12",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-24",
|
||||
"system_prompt": "You are a customer service agent for 测试公司12 (test12.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (测试公司12)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"25": {
|
||||
"name": "测试公司13",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-25",
|
||||
"system_prompt": "You are a customer service agent for 测试公司13 (test13.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (测试公司13)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"26": {
|
||||
"name": "最终测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-26",
|
||||
"system_prompt": "You are a customer service agent for 最终测试 (final.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (最终测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"27": {
|
||||
"name": "最终注册测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-27",
|
||||
"system_prompt": "You are a customer service agent for 最终注册测试 (finalreg.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (最终注册测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"28": {
|
||||
"name": "清理后测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-28",
|
||||
"system_prompt": "You are a customer service agent for 清理后测试 (clean.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (清理后测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"29": {
|
||||
"name": "supervisord测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-29",
|
||||
"system_prompt": "You are a customer service agent for supervisord测试 (sup.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (supervisord测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"30": {
|
||||
"name": "autologin测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-30",
|
||||
"system_prompt": "You are a customer service agent for autologin测试 (auto.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (autologin测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"31": {
|
||||
"name": "最终E2E测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-31",
|
||||
"system_prompt": "You are a customer service agent for 最终E2E测试 (e2efinal.com). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (最终E2E测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"32": {
|
||||
"name": "E2E专业版公司",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-32",
|
||||
"system_prompt": "You are a customer service agent for E2E专业版公司 (e2e-pro). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (E2E专业版公司)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"33": {
|
||||
"name": "E2E企业版公司",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-33",
|
||||
"system_prompt": "You are a customer service agent for E2E企业版公司 (e2e-ent). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (E2E企业版公司)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"34": {
|
||||
"name": "FastAdmin专业版",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-34",
|
||||
"system_prompt": "You are a customer service agent for FastAdmin专业版 (fastadmin-pro). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (FastAdmin专业版)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"35": {
|
||||
"name": "FastAdmin企业版",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-35",
|
||||
"system_prompt": "You are a customer service agent for FastAdmin企业版 (fastadmin-ent). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (FastAdmin企业版)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"36": {
|
||||
"name": "DirectBasicTest",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-36",
|
||||
"system_prompt": "You are a customer service agent for DirectBasicTest (direct-basic-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (DirectBasicTest)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"37": {
|
||||
"name": "DebugBasic",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-37",
|
||||
"system_prompt": "You are a customer service agent for DebugBasic (debug-basic). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (DebugBasic)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"38": {
|
||||
"name": "Retry测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-38",
|
||||
"system_prompt": "You are a customer service agent for Retry测试 (retry-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (Retry测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"39": {
|
||||
"name": "123",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-39",
|
||||
"system_prompt": "You are a customer service agent for 123 (123). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (123)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"40": {
|
||||
"name": "会员中心测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-40",
|
||||
"system_prompt": "You are a customer service agent for 会员中心测试 (member-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (会员中心测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"41": {
|
||||
"name": "快速测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-41",
|
||||
"system_prompt": "You are a customer service agent for 快速测试 (fast-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (快速测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"42": {
|
||||
"name": "速度测试",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-42",
|
||||
"system_prompt": "You are a customer service agent for 速度测试 (speed-test). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (速度测试)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"43": {
|
||||
"name": "SpeedTest",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-43",
|
||||
"system_prompt": "You are a customer service agent for SpeedTest (speed-provision). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (SpeedTest)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"44": {
|
||||
"name": "快速测试2",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-44",
|
||||
"system_prompt": "You are a customer service agent for 快速测试2 (fast2). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (快速测试2)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"45": {
|
||||
"name": "新代码验证",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-45",
|
||||
"system_prompt": "You are a customer service agent for 新代码验证 (newcode). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (新代码验证)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
},
|
||||
"46": {
|
||||
"name": "去重A",
|
||||
"type": "web_widget",
|
||||
"target_agent": "chathub-46",
|
||||
"system_prompt": "You are a customer service agent for 去重A (dedupA). Answer questions professionally in the customer's language. If you cannot fully resolve the issue, end with [HANDOFF].",
|
||||
"prompt_template": "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer.",
|
||||
"note_prefix": "🤖 AI 自动回复 (去重A)",
|
||||
"signature": "",
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"_meta": {
|
||||
"version": "1.1",
|
||||
"updated_at": "2026-06-02T12:00:00Z",
|
||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable"
|
||||
},
|
||||
"1": {
|
||||
"name": "GreatQiu",
|
||||
"type": "web_widget",
|
||||
"target_agent": "sourcing-agent",
|
||||
"system_prompt": "You are a professional China sourcing agent from GreatQiu (based in Shaoxing, Zhejiang, China). You help international clients with product sourcing, supplier verification, quality control, logistics, and supply chain management.\n\nIMPORTANT - Decide if you can fully handle this or need a human:\n- If the customer asks about specific PRICING, MOQ, PLACING ORDERS, CUSTOMIZATION, SHIPPING QUOTES, or COMPLEX TECHNICAL SPECS that require real-time data from suppliers → end your reply with [HANDOFF] on a new line.\n- If you can answer the question fully using your general knowledge (company info, services, processes, general timelines) → do NOT add [HANDOFF].",
|
||||
"prompt_template": "A customer named '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer. Always sign with '- GreatQiu Team'.",
|
||||
"note_prefix": "🤖 AI 自动回复 (GreatQiu)",
|
||||
"signature": "- GreatQiu Team",
|
||||
"status": "active"
|
||||
},
|
||||
"7": {
|
||||
"name": "HALO Blog",
|
||||
"type": "web_widget",
|
||||
"target_agent": "halo-blog-agent",
|
||||
"system_prompt": "你是一名专业的安防弱电与IT基础设施技术顾问,来自Q师傅知识库。你帮助客户解答关于交换机配置、监控系统、网络布线、弱电工程、服务器运维等技术问题。\n\n重要 - 判断是否需要人工介入:\n- 如果客户询问具体的项目报价、施工方案定制、现场勘查需求、或需要实际采购产品 → 在回复末尾加一行 [HANDOFF]。\n- 如果是回答一般性的技术问题(设备参数、配置方法、故障排查思路等)→ 不加 [HANDOFF]。",
|
||||
"prompt_template": "客户 '{sender_name}' 发送了以下消息:\n\n{customer_msg}\n\n请用中文直接回复(不要前缀,不要Markdown)。保持简洁(2-4句话)。回复语气专业友善,体现技术专家的可靠性。以'- Q师傅知识库'署名。",
|
||||
"note_prefix": "🤖 AI 自动回复 (Q师傅知识库)",
|
||||
"signature": "- Q师傅知识库",
|
||||
"status": "active"
|
||||
},
|
||||
"8": {
|
||||
"name": "Amazon",
|
||||
"type": "api",
|
||||
"target_agent": "9hxc2Y",
|
||||
"system_prompt": "你是一名亚马逊平台业务助手,帮助处理亚马逊相关的咨询。客户可能询问产品信息、订单状态、物流问题等。\n\n重要 - 判断是否需要人工介入:\n- 如果客户询问账号安全、付款问题、或需要人工审核 → 在回复末尾加一行 [HANDOFF]\n- 一般性的产品咨询、订单状态查询 → 直接回答,不加 [HANDOFF]",
|
||||
"prompt_template": "客户 '{sender_name}' 发送了以下消息:\n\n{customer_msg}\n\n请用中文直接回复(不要前缀,不要Markdown)。保持简洁(2-4句话)。语气专业。以'- Amazon 客服助手'署名。",
|
||||
"note_prefix": "🤖 AI 自动回复 (Amazon)",
|
||||
"signature": "- Amazon 客服助手",
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
# Q师傅知识库 — 安防弱电与IT基础设施技术参考
|
||||
|
||||
> 本文档供 AI 客服参考,覆盖 shopqiu.com 的核心知识领域。
|
||||
|
||||
---
|
||||
|
||||
## 一、视频监控系统(93篇)
|
||||
|
||||
### 海康威视
|
||||
- **默认IP**: 192.0.0.64,激活需SADP工具
|
||||
- **SADP工具**: 局域网广播239.255.255.250:37020,扫描设备IP/MAC/型号/固件版本
|
||||
- **密码要求**: 8-16位,至少包含数字、大小写字母、特殊字符中两类
|
||||
- **连续登录失败5次**自动锁定10分钟
|
||||
- **Hik-Connect**: P2P远程访问,无需端口映射,固件需V5.0以上
|
||||
- **萤石云(EZVIZ)**: 扫码添加设备,支持AP热点配网
|
||||
- **ISAPI接口**: HTTP Digest鉴权,常用路径:/ISAPI/System/deviceInfo、/ISAPI/Streaming/channels
|
||||
- **SDK**: HCNetSDK.dll,日志路径C:\Program Files\Hikvision\Log
|
||||
|
||||
### 大华
|
||||
- **SDK**: dhnetsdk.dll,64位系统必须用x64 SDK
|
||||
- **GB28181**: 固件5.0以上才支持
|
||||
- **ISAPI**: 部分低端型号不提供
|
||||
|
||||
### 常见故障排查
|
||||
- **无画面**: PoE断电→线缆损坏→IP冲突→摄像头未激活→NVR通道未添加
|
||||
- **远程无法播放**: 检查Platform Access是否启用→DNS设8.8.8.8→重启顺序:调制解调器→NVR→中继设备
|
||||
- **卡顿丢帧**: 码率过高→降低分辨率→检查硬盘IO→抓包定位丢包
|
||||
|
||||
### PoE供电
|
||||
- IEEE 802.3af (15.4W) / 802.3at (30W)
|
||||
- 标准距离100m,超长线导致电压降
|
||||
- 需8芯直通线
|
||||
|
||||
### GB28181协议
|
||||
- 仅支持H.264编码(不支持H.265),需开启双码流
|
||||
- SIP端口5060/5061
|
||||
- 常见错误:404(地址错误)、401(密码错)、503(资源不足)
|
||||
|
||||
---
|
||||
|
||||
## 二、网络工程(86篇)
|
||||
|
||||
### 核心设备
|
||||
- **交换机**: H3C、华为、锐捷,VLAN划分、端口隔离
|
||||
- **路由器**: 策略路由、NAT、ACL
|
||||
- **防火墙**: 状态检测、应用识别
|
||||
|
||||
### PoE交换机选型
|
||||
- 整机供电功率 > 所有设备累计功耗
|
||||
- 海康DS-3E0526P-S/L: 24口PoE,单口30W,整机370W
|
||||
|
||||
### 常见问题
|
||||
- **环路**: 启用STP/RSTP
|
||||
- **IP冲突**: DHCP Snooping + IP Source Guard
|
||||
- **带宽不足**: 链路聚合、QoS限速
|
||||
|
||||
---
|
||||
|
||||
## 三、门禁考勤系统(64篇)
|
||||
|
||||
### 组成
|
||||
- 门禁控制器、读卡器(IC/ID卡、人脸)、电锁(电磁锁、电插锁、阴极锁)
|
||||
- 出门按钮、门磁
|
||||
|
||||
### 人脸门禁
|
||||
- 活体检测防照片攻击
|
||||
- 识别距离0.3-1.5m
|
||||
- 深度学习算法,误识率<0.01%
|
||||
|
||||
### 常见故障
|
||||
- **不开门**: 检查供电→控制器→电锁接线→读卡器信号
|
||||
- **刷卡无反应**: 卡片类型不匹配(IC/ID)→读卡器线缆→控制器在线状态
|
||||
- **开门后不关门**: 门磁故障→延时设置→电锁回弹力不足
|
||||
|
||||
---
|
||||
|
||||
## 四、报警消防系统(26篇)
|
||||
|
||||
### 报警设备
|
||||
- **红外对射**: 发射端+接收端,光束被遮断触发报警
|
||||
- **烟感探测器**: 光电式/离子式,需定期清洁
|
||||
- **报警主机**: 防区管理,支持电话/SMS/APP推送
|
||||
|
||||
### 消防联动
|
||||
- 烟感→报警主机→消防泵/排烟/广播
|
||||
- 需符合GB4717标准
|
||||
|
||||
---
|
||||
|
||||
## 五、停车管理系统(35篇)
|
||||
|
||||
### 道闸系统
|
||||
- 栏杆机(直杆/曲杆/栅栏杆)
|
||||
- 车牌识别摄像机(LPR)
|
||||
- 收费系统(临时卡/月卡/ETC)
|
||||
|
||||
### 常见问题
|
||||
- **车牌识别率低**: 调整摄像机角度→补光灯→算法参数
|
||||
- **道闸不抬杆**: 检查地感线圈→控制器→电机
|
||||
- **跟车闯关**: 增加防砸功能(红外/压力波)
|
||||
|
||||
---
|
||||
|
||||
## 六、对讲广播(31篇)
|
||||
|
||||
### 可视对讲
|
||||
- 模拟对讲 → 数字IP对讲
|
||||
- SIP协议、ONVIF协议
|
||||
- 医护对讲:床头分机+走廊显示屏+护士站主机
|
||||
|
||||
### 广播系统
|
||||
- 定压功放(70V/100V)+ 定阻功放
|
||||
- IP网络广播:基于局域网,支持分区/定时/紧急广播
|
||||
|
||||
---
|
||||
|
||||
## 七、显示亮化(25篇)
|
||||
|
||||
### LED显示屏
|
||||
- 室内P2.5/P3/P4,室外P5/P6/P8/P10
|
||||
- 同步控制vs异步控制
|
||||
- 亮度自动调节(光感探头)
|
||||
|
||||
### 拼接屏
|
||||
- LCD拼接(3.5mm/1.8mm缝隙)
|
||||
- DLP背投(无缝但体积大)
|
||||
|
||||
---
|
||||
|
||||
## 八、存储与数据(34篇)
|
||||
|
||||
### NVR存储
|
||||
- 硬盘容量计算:码率×时间×通道数
|
||||
- RAID5/RAID6冗余
|
||||
- 硬盘健康监控(SMART)
|
||||
|
||||
### NAS方案
|
||||
- 群晖、威联通、海康存储
|
||||
- ISCSI、NFS、SMB协议
|
||||
|
||||
---
|
||||
|
||||
## 九、无线通信(55篇)
|
||||
|
||||
### 无线覆盖
|
||||
- AP选型:吸顶式、面板式、室外型
|
||||
- 信道规划:避免同频干扰
|
||||
- AC控制器集中管理
|
||||
|
||||
### 对讲系统
|
||||
- 模拟对讲机 vs 数字对讲机(DMR/PDT)
|
||||
- IP对讲:基于SIP协议
|
||||
|
||||
---
|
||||
|
||||
## 十、AI与智能化(35篇)
|
||||
|
||||
### 智能分析
|
||||
- 人脸识别、行为分析、周界检测
|
||||
- 热成像测温
|
||||
- 车牌识别
|
||||
|
||||
### 系统集成
|
||||
- GB28181国标对接
|
||||
- SDK二次开发
|
||||
- ISAPI接口调用
|
||||
- ONVIF协议
|
||||
|
||||
---
|
||||
|
||||
## 十一、运维笔记(39篇)
|
||||
|
||||
### 常用工具
|
||||
- SADP(海康设备发现)
|
||||
- HiTools Delivery(批量管理)
|
||||
- WireShark(网络抓包)
|
||||
- PuTTY/SecureCRT(串口/SSH)
|
||||
|
||||
### 调试技巧
|
||||
- 先查物理层(线缆、供电)→ 再查网络层(IP、路由)→ 最后查应用层(配置、固件)
|
||||
- 固件升级前务必备份配置
|
||||
- UPS保护防止升级中断电
|
||||
|
||||
---
|
||||
|
||||
## 十二、运维术语速查
|
||||
|
||||
| 缩写 | 全称 | 说明 |
|
||||
|------|------|------|
|
||||
| NVR | Network Video Recorder | 网络录像机 |
|
||||
| DVR | Digital Video Recorder | 数字录像机 |
|
||||
| IPC | IP Camera | 网络摄像机 |
|
||||
| PoE | Power over Ethernet | 以太网供电 |
|
||||
| VMS | Video Management System | 视频管理系统 |
|
||||
| LPR | License Plate Recognition | 车牌识别 |
|
||||
| RTSP | Real Time Streaming Protocol | 实时流协议 |
|
||||
| ONVIF | Open Network Video Interface Forum | 开放网络视频接口论坛 |
|
||||
| SIP | Session Initiation Protocol | 会话初始协议 |
|
||||
| RAID | Redundant Array of Independent Disk | 磁盘冗余阵列 |
|
||||
| AC | Access Controller | 无线控制器 |
|
||||
| AP | Access Point | 无线接入点 |
|
||||
| VLAN | Virtual LAN | 虚拟局域网 |
|
||||
| STP | Spanning Tree Protocol | 生成树协议 |
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-05-31*
|
||||
*来源: shopqiu.com 文章 + 互联网技术资料*
|
||||
-293
@@ -1,293 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tenant Provisioning Script — Chatwoot AI Agent Platform
|
||||
========================================================
|
||||
Automatically creates a new tenant (inbox + AI agent) and registers it
|
||||
in inboxes.json for the WS agent to pick up via hot-reload.
|
||||
|
||||
Usage:
|
||||
# Website Widget (独立站嵌入)
|
||||
python3 provision.py --name "MyShop" --type web_widget --domain myshop.com
|
||||
|
||||
# API Inbox (亚马逊/TikTok等)
|
||||
python3 provision.py --name "AmazonStore" --type api --lang zh
|
||||
|
||||
# With custom prompt
|
||||
python3 provision.py --name "TechSupport" --type web_widget --domain tech.com \
|
||||
--lang en --system-prompt "You are a tech support agent..."
|
||||
|
||||
Output:
|
||||
- Inbox ID, identifier, embed code (for widget) or API credentials
|
||||
- Writes to inboxes.json automatically
|
||||
"""
|
||||
|
||||
import os, sys, json, argparse, time, requests
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ===== CONFIG =====
|
||||
CW_BASE = os.environ.get("CW_BASE", "https://chatwoot.275763.xyz")
|
||||
CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
|
||||
CW_API_BASE = f"{CW_BASE}/api/v1/accounts/{CW_ACCOUNT_ID}"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
||||
INBOX_CONFIG_FILE = SCRIPT_DIR / "inboxes.json"
|
||||
|
||||
# ===== AUTH =====
|
||||
def get_headers():
|
||||
"""Load session headers from auth file."""
|
||||
if AUTH_FILE.exists():
|
||||
try:
|
||||
data = json.loads(AUTH_FILE.read_text())
|
||||
return {
|
||||
"access-token": data.get("access-token"),
|
||||
"client": data.get("client"),
|
||||
"expiry": data.get("expiry"),
|
||||
"uid": data.get("uid"),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"WARNING: Failed to load auth: {e}")
|
||||
print("ERROR: No valid auth. Run chatwoot_ws_agent.py --renew first.")
|
||||
sys.exit(1)
|
||||
|
||||
# ===== CHATWOOT API =====
|
||||
def create_inbox(name, inbox_type, domain=None, headers=None):
|
||||
"""Create a Chatwoot inbox via API.
|
||||
|
||||
Args:
|
||||
name: Display name for the inbox
|
||||
inbox_type: 'web_widget' or 'api'
|
||||
domain: Website URL (required for web_widget)
|
||||
headers: Auth headers
|
||||
|
||||
Returns:
|
||||
dict with inbox_id, identifier, etc.
|
||||
"""
|
||||
if inbox_type == "web_widget" and not domain:
|
||||
print("ERROR: --domain required for web_widget type")
|
||||
sys.exit(1)
|
||||
|
||||
payload = {"name": name}
|
||||
|
||||
if inbox_type == "web_widget":
|
||||
# Ensure domain has protocol
|
||||
if not domain.startswith("http"):
|
||||
domain = f"https://{domain}"
|
||||
payload["channel"] = {
|
||||
"type": "web_widget",
|
||||
"website_url": domain
|
||||
}
|
||||
elif inbox_type == "api":
|
||||
payload["channel"] = {"type": "api"}
|
||||
else:
|
||||
print(f"ERROR: Unknown inbox type: {inbox_type}")
|
||||
sys.exit(1)
|
||||
|
||||
r = requests.post(f"{CW_API_BASE}/inboxes", json=payload, headers=headers, timeout=15)
|
||||
if r.status_code not in (200, 201):
|
||||
print(f"ERROR: Create inbox failed: {r.status_code} {r.text[:300]}")
|
||||
sys.exit(1)
|
||||
|
||||
data = r.json()
|
||||
return {
|
||||
"inbox_id": data.get("id"),
|
||||
"name": data.get("name"),
|
||||
"identifier": data.get("webhook_url", ""), # widget uses this
|
||||
"website_token": data.get("website_token", ""),
|
||||
"api_channel": data.get("channel", {}).get("type"),
|
||||
}
|
||||
|
||||
# ===== QWENPAW AGENT =====
|
||||
def create_qwenpaw_agent(agent_id, display_name, lang="zh"):
|
||||
"""Create a QwenPaw agent workspace + config.
|
||||
|
||||
This creates the agent directory structure. The agent will be auto-detected
|
||||
by QwenPaw on next restart or config reload.
|
||||
|
||||
Returns:
|
||||
Path to the agent workspace
|
||||
"""
|
||||
# Determine workspace path
|
||||
qwenpaw_base = Path(os.environ.get("QWENPAW_WORKING_DIR", "/app/working/workspaces"))
|
||||
agent_dir = qwenpaw_base / agent_id
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create agent.json
|
||||
agent_config = {
|
||||
"id": agent_id,
|
||||
"name": display_name,
|
||||
"description": f"Auto-provisioned agent for {display_name}",
|
||||
"workspace_dir": str(agent_dir),
|
||||
"enabled": True,
|
||||
}
|
||||
(agent_dir / "agent.json").write_text(json.dumps(agent_config, indent=2))
|
||||
|
||||
# Create basic SOUL.md
|
||||
if lang == "zh":
|
||||
soul = f"""# {display_name} AI 客服
|
||||
|
||||
你是 {display_name} 的 AI 客服助手。
|
||||
|
||||
## 职责
|
||||
- 专业、友善地回答客户问题
|
||||
- 无法处理时标记 [HANDOFF] 请求人工介入
|
||||
- 保持简洁,2-4句话回复
|
||||
|
||||
## 风格
|
||||
- 用中文回复
|
||||
- 语气专业但不死板
|
||||
- 直接回答,不绕弯子
|
||||
"""
|
||||
else:
|
||||
soul = f"""# {display_name} AI Customer Service
|
||||
|
||||
You are the AI customer service agent for {display_name}.
|
||||
|
||||
## Responsibilities
|
||||
- Answer customer questions professionally and friendly
|
||||
- Mark [HANDOFF] when human intervention is needed
|
||||
- Keep replies concise (2-4 sentences)
|
||||
|
||||
## Style
|
||||
- Professional but approachable
|
||||
- Direct answers, no fluff
|
||||
"""
|
||||
(agent_dir / "SOUL.md").write_text(soul)
|
||||
|
||||
# Create empty PROFILE.md
|
||||
(agent_dir / "PROFILE.md").write_text(f"# {display_name} Agent Profile\n\nAuto-provisioned.\n")
|
||||
|
||||
print(f" Agent workspace: {agent_dir}")
|
||||
return agent_dir
|
||||
|
||||
# ===== INBOXES.JSON =====
|
||||
def update_inboxes_config(inbox_id, name, inbox_type, agent_id, lang="zh"):
|
||||
"""Add new inbox to inboxes.json."""
|
||||
# Load existing
|
||||
config = {}
|
||||
if INBOX_CONFIG_FILE.exists():
|
||||
try:
|
||||
config = json.loads(INBOX_CONFIG_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default prompts by language
|
||||
if lang == "zh":
|
||||
system_prompt = f"你是 {name} 的 AI 客服助手。专业回复客户问题,需要人工时加 [HANDOFF]。"
|
||||
prompt_template = "客户 '{sender_name}' 发来消息:\n\n{customer_msg}\n\n直接回复,简洁专业(2-4句话)。用中文。"
|
||||
note_prefix = f"🤖 AI 自动回复 ({name})"
|
||||
signature = f"- {name} 客服"
|
||||
else:
|
||||
system_prompt = f"You are the AI customer service agent for {name}. Answer professionally. Add [HANDOFF] when human intervention is needed."
|
||||
prompt_template = "Customer '{sender_name}' sent this message:\n\n{customer_msg}\n\nWrite a direct reply (no preamble, no markdown). Keep it concise (2-4 sentences). Use the same language as the customer."
|
||||
note_prefix = f"🤖 AI Auto-Reply ({name})"
|
||||
signature = f"- {name} Team"
|
||||
|
||||
# Add/update entry
|
||||
config[str(inbox_id)] = {
|
||||
"name": name,
|
||||
"type": inbox_type,
|
||||
"target_agent": agent_id,
|
||||
"system_prompt": system_prompt,
|
||||
"prompt_template": prompt_template,
|
||||
"note_prefix": note_prefix,
|
||||
"signature": signature,
|
||||
"status": "active",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Update meta
|
||||
config["_meta"] = {
|
||||
"version": "1.1",
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable",
|
||||
}
|
||||
|
||||
INBOX_CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
print(f" Config written: {INBOX_CONFIG_FILE}")
|
||||
|
||||
# ===== MAIN =====
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Provision a new tenant inbox + AI agent")
|
||||
parser.add_argument("--name", required=True, help="Tenant display name")
|
||||
parser.add_argument("--type", required=True, choices=["web_widget", "api"],
|
||||
help="Inbox type: web_widget (website) or api (platform)")
|
||||
parser.add_argument("--domain", default=None, help="Website domain (required for web_widget)")
|
||||
parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="Language (default: zh)")
|
||||
parser.add_argument("--agent-id", default=None, help="Custom agent ID (default: auto-generated)")
|
||||
parser.add_argument("--system-prompt", default=None, help="Custom system prompt (overrides default)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Generate agent ID
|
||||
agent_id = args.agent_id or f"agent-{args.name.lower().replace(' ', '-')}"
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" Provisioning: {args.name}")
|
||||
print(f" Type: {args.type}")
|
||||
print(f" Agent: {agent_id}")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
# Step 1: Create Chatwoot inbox
|
||||
print("[1/3] Creating Chatwoot inbox...")
|
||||
headers = get_headers()
|
||||
inbox_info = create_inbox(args.name, args.type, args.domain, headers)
|
||||
inbox_id = inbox_info["inbox_id"]
|
||||
print(f" Inbox #{inbox_id}: {inbox_info['name']}")
|
||||
|
||||
# Step 2: Create QwenPaw agent
|
||||
print("[2/3] Creating AI agent...")
|
||||
agent_dir = create_qwenpaw_agent(agent_id, args.name, args.lang)
|
||||
|
||||
# Step 3: Update inboxes.json
|
||||
print("[3/3] Updating routing config...")
|
||||
update_inboxes_config(inbox_id, args.name, args.type, agent_id, args.lang)
|
||||
|
||||
# Override system prompt if provided
|
||||
if args.system_prompt:
|
||||
import inboxes_config_helper # noqa - if exists
|
||||
# Manual override
|
||||
config = json.loads(INBOX_CONFIG_FILE.read_text())
|
||||
config[str(inbox_id)]["system_prompt"] = args.system_prompt
|
||||
INBOX_CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
print(f" Custom system prompt applied")
|
||||
|
||||
# Print summary
|
||||
print(f"\n{'='*50}")
|
||||
print(f" ✅ PROVISIONING COMPLETE")
|
||||
print(f"{'='*50}")
|
||||
print(f" Inbox ID: {inbox_id}")
|
||||
print(f" Agent ID: {agent_id}")
|
||||
print(f" Agent Dir: {agent_dir}")
|
||||
|
||||
if args.type == "web_widget":
|
||||
token = inbox_info.get("website_token", "")
|
||||
print(f"\n 📋 Widget Embed Code:")
|
||||
print(f" <script>")
|
||||
print(f" window.chatwootSettings = {{}};")
|
||||
print(f" (function(d,t) {{")
|
||||
print(f" var BASE_URL=\"{CW_BASE}\";")
|
||||
print(f" var g=d.createElement(t),s=d.getElementsByTagName(t)[0];")
|
||||
print(f" g.src=BASE_URL+'/packs/js/sdk.js';")
|
||||
print(f" g.async=!0;")
|
||||
print(f" s.parentNode.insertBefore(g,s);")
|
||||
print(f" g.onload=function(){{")
|
||||
print(f" window.chatwootSDK.run({{")
|
||||
print(f" websiteToken: '{token}',")
|
||||
print(f" baseUrl: BASE_URL")
|
||||
print(f" }})")
|
||||
print(f" }}")
|
||||
print(f" }})(document,'script');")
|
||||
print(f" </script>")
|
||||
elif args.type == "api":
|
||||
print(f"\n 📋 API Credentials:")
|
||||
print(f" Inbox ID: {inbox_id}")
|
||||
print(f" (Use Chatwoot API to create conversations and send messages)")
|
||||
|
||||
print(f"\n ⚡ WS Agent will auto-detect this inbox within 30s (hot-reload)")
|
||||
print(f" ⚡ Restart QwenPaw to register the new agent: qwenpaw daemon restart")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ChatHub Provision HTTP Service.
|
||||
|
||||
Called by FastAdmin PHP to provision a tenant:
|
||||
→ creates Chatwoot team + inbox + agent account
|
||||
→ writes inboxes.json (WS Agent hot-reloads within 30s)
|
||||
→ creates QwenPaw agent workspace
|
||||
Returns JSON for PHP to save to chathub_tenant table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bottle
|
||||
|
||||
# ── logging ──────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("provision")
|
||||
|
||||
# ── paths (all inside QwenPaw container) ──────────────────────────
|
||||
WORKSPACE_DIR = Path("/app/working/workspaces")
|
||||
SCRIPT_DIR = WORKSPACE_DIR / "wordpress" / "skills" / "wordpress-cli"
|
||||
INBOXES_PATH = SCRIPT_DIR / "inboxes.json"
|
||||
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
||||
|
||||
# Chatwoot base config
|
||||
CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
|
||||
CW_INTERNAL = os.environ.get("CW_INTERNAL", "http://chatwoot-chatwoot-1:3000")
|
||||
CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
|
||||
CW_PLATFORM_TOKEN = os.environ.get("CW_PLATFORM_TOKEN", "")
|
||||
# API key for provision/suspend/activate endpoints
|
||||
CHATHUB_API_KEY = os.environ.get("CHATHUB_API_KEY", "chathub-default-key-change-me")
|
||||
|
||||
|
||||
def _gen_password(length: int = 14) -> str:
|
||||
chars = string.ascii_letters + string.digits + '!@#$%^&*()_+-='
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
def _relogin_chatwoot() -> dict:
|
||||
email = os.environ.get("CW_ADMIN_EMAIL")
|
||||
password = os.environ.get("CW_ADMIN_PASSWORD")
|
||||
if not email:
|
||||
raise RuntimeError("CW_ADMIN_EMAIL not set — cannot login")
|
||||
if not password:
|
||||
raise RuntimeError("CW_ADMIN_PASSWORD not set — cannot login")
|
||||
url = f"{CW_BASE}/auth/sign_in"
|
||||
payload = json.dumps({"email": email, "password": password}).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
headers = {k.lower(): v for k, v in resp.headers.items()}
|
||||
data = {
|
||||
"access-token": headers.get("access-token", ""),
|
||||
"client": headers.get("client", ""),
|
||||
"expiry": headers.get("expiry", ""),
|
||||
"uid": headers.get("uid", ""),
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
AUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
log.info("Chatwoot session refreshed for %s", data["uid"])
|
||||
return {
|
||||
"access-token": data["access-token"],
|
||||
"client": data["client"],
|
||||
"expiry": data["expiry"],
|
||||
"uid": data["uid"],
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _get_session_headers() -> dict:
|
||||
"""Load session from file, auto-renew if expired or missing."""
|
||||
import time as _t
|
||||
if AUTH_FILE.exists():
|
||||
data = json.loads(AUTH_FILE.read_text())
|
||||
expiry = int(data.get("expiry", 0))
|
||||
if expiry - _t.time() < 3600:
|
||||
log.info("Session < 1h (%ds left), renewing", expiry - int(_t.time()))
|
||||
return _relogin_chatwoot()
|
||||
if all([data.get("access-token"), data.get("client"), data.get("uid")]):
|
||||
return {
|
||||
"access-token": data["access-token"],
|
||||
"client": data["client"],
|
||||
"expiry": data["expiry"],
|
||||
"uid": data["uid"],
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return _relogin_chatwoot()
|
||||
|
||||
|
||||
def _call_cw(method: str, path: str, body: Optional[dict], retries: int = 3) -> dict:
|
||||
"""Call Chatwoot API with retry on 401 (auto-renew session)."""
|
||||
url = f"{CW_BASE}{path}"
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
headers = _get_session_headers()
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401 and attempt < retries - 1:
|
||||
log.warning(f"401 on {method} {path} (attempt {attempt+1}), re-login & retry")
|
||||
AUTH_FILE.unlink(missing_ok=True)
|
||||
continue
|
||||
body_text = e.read().decode() if e.fp else ""
|
||||
raise RuntimeError(f"CW API error {e.code} on {method} {path}: {body_text}")
|
||||
raise RuntimeError(f"Exhausted {retries} retries on {method} {path}: {last_err}")
|
||||
|
||||
|
||||
def _call_internal(method: str, path: str,
|
||||
body: Optional[dict],
|
||||
extra_headers: Optional[dict] = None,
|
||||
retries: int = 3) -> dict:
|
||||
"""Call Chatwoot internal (platform) API with retry."""
|
||||
url = f"{CW_INTERNAL}{path}"
|
||||
for attempt in range(retries):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if attempt < retries - 1:
|
||||
log.warning(f"Internal API error {e.code} on {method} {path}, retry {attempt+1}")
|
||||
continue
|
||||
body_text = e.read().decode() if e.fp else ""
|
||||
raise RuntimeError(f"Internal API error {e.code} on {method} {path}: {body_text}")
|
||||
raise RuntimeError(f"Exhausted {retries} retries on internal {method} {path}")
|
||||
|
||||
|
||||
def _check_api_key():
|
||||
"""Verify X-API-Key header matches CHATHUB_API_KEY."""
|
||||
key = bottle.request.get_header("X-API-Key", "")
|
||||
if key != CHATHUB_API_KEY:
|
||||
bottle.response.status = 401
|
||||
return json.dumps({"error": "Invalid or missing X-API-Key"})
|
||||
|
||||
|
||||
def _create_agent(email: str, name: str = "") -> dict:
|
||||
"""Create a Chatwoot user + agent. Returns {agent_cw_id, password}.
|
||||
Rolls back (deletes user) if agent creation fails."""
|
||||
password = _gen_password()
|
||||
display_name = name or email.split("@")[0]
|
||||
|
||||
user = _call_internal(
|
||||
"POST",
|
||||
"/platform/api/v1/users",
|
||||
{"name": display_name, "email": email, "password": password},
|
||||
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
||||
)
|
||||
if not user.get("id"):
|
||||
raise RuntimeError(f"Platform API user creation failed: {user}")
|
||||
uid = user["id"]
|
||||
|
||||
try:
|
||||
agent = _call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/agents",
|
||||
{"email": email, "name": display_name, "role": "agent"},
|
||||
)
|
||||
except Exception as e:
|
||||
# Rollback: delete the orphaned platform user
|
||||
try:
|
||||
_call_internal(
|
||||
"DELETE",
|
||||
f"/platform/api/v1/users/{uid}",
|
||||
None,
|
||||
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Agent creation failed, rolled back user {uid}: {e}")
|
||||
|
||||
if not agent.get("id"):
|
||||
raise RuntimeError(f"Agent creation failed: {agent}")
|
||||
|
||||
return {"agent_cw_id": agent["id"], "password": password}
|
||||
|
||||
|
||||
def _add_agent_to_team(agent_cw_id: int, team_id: int) -> None:
|
||||
"""Assign agent to team."""
|
||||
try:
|
||||
_call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams/{team_id}/team_members",
|
||||
{"user_ids": [agent_cw_id]},
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s %s", agent_cw_id, team_id, e.code, body)
|
||||
except Exception as e:
|
||||
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s", agent_cw_id, team_id, e)
|
||||
|
||||
|
||||
def _read_inboxes() -> dict:
|
||||
if INBOXES_PATH.exists():
|
||||
return json.loads(INBOXES_PATH.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"_meta": {
|
||||
"version": "1.1",
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"description": "Chatwoot WS Agent inbox routing config \u2014 hot-reloadable",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_inboxes(config: dict) -> None:
|
||||
INBOXES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
INBOXES_PATH.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_agent_workspace(agent_id: str, name: str) -> Path:
|
||||
agent_dir = WORKSPACE_DIR / agent_id
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
return agent_dir
|
||||
|
||||
|
||||
def _build_inbox_entry(name: str, domain: str, channel: str,
|
||||
inbox_id: int, inbox_token: str, agent_id: str) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"type": channel,
|
||||
"target_agent": agent_id,
|
||||
"system_prompt": (
|
||||
f"You are a customer service agent for {name} ({domain}). "
|
||||
f"Answer questions professionally in the customer's language. "
|
||||
f"If you cannot fully resolve the issue, end with [HANDOFF]."
|
||||
),
|
||||
"prompt_template": (
|
||||
"Customer '{sender_name}' sent this message:\n\n"
|
||||
"{customer_msg}\n\n"
|
||||
"Write a direct reply (no preamble, no markdown). "
|
||||
"Keep it concise (2-4 sentences). "
|
||||
"Use the same language as the customer."
|
||||
),
|
||||
"note_prefix": f"\U0001f916 AI \u81ea\u52a8\u56de\u590d ({name})",
|
||||
"signature": "",
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
# ── Idempotency store ────────────────────────────────────────────
|
||||
# Stores {key: response_dict} with 5-minute TTL.
|
||||
_IDEMPOTENT_RESULTS: dict[str, dict] = {}
|
||||
_IDEMPOTENT_LOCK = threading.Lock()
|
||||
_IDEMPOTENT_TTL = 300
|
||||
|
||||
|
||||
def _check_idempotency(key: str) -> Optional[dict]:
|
||||
"""Return cached result if key was already processed, else None."""
|
||||
if not key:
|
||||
return None
|
||||
with _IDEMPOTENT_LOCK:
|
||||
if key in _IDEMPOTENT_RESULTS:
|
||||
log.info("Idempotent request %s, returning cached result", key)
|
||||
cached = _IDEMPOTENT_RESULTS[key]
|
||||
# Restore HTTP status from cached entry
|
||||
status = cached.pop("_http_status", 200)
|
||||
bottle.response.status = status
|
||||
return cached
|
||||
return None
|
||||
|
||||
|
||||
def _store_idempotency(key: str, response: dict, status: int = 200) -> None:
|
||||
"""Store the response for an idempotency key (thread-safe, auto-expire).
|
||||
|
||||
Args:
|
||||
key: Idempotency-Key header value.
|
||||
response: The JSON response dict to cache.
|
||||
status: HTTP status code to restore on cache hit.
|
||||
"""
|
||||
if not key:
|
||||
return
|
||||
with _IDEMPOTENT_LOCK:
|
||||
if key not in _IDEMPOTENT_RESULTS:
|
||||
entry = dict(response)
|
||||
entry["_http_status"] = status
|
||||
_IDEMPOTENT_RESULTS[key] = entry
|
||||
# Schedule cleanup after TTL
|
||||
threading.Timer(_IDEMPOTENT_TTL, lambda: _IDEMPOTENT_RESULTS.pop(key, None)).start()
|
||||
|
||||
|
||||
# ── Provision endpoint ────────────────────────────────────────────
|
||||
|
||||
@bottle.post("/provision")
|
||||
def provision():
|
||||
result = None
|
||||
idempotency_key = bottle.request.get_header("Idempotency-Key", "")
|
||||
|
||||
auth_err = _check_api_key()
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
try:
|
||||
data = bottle.request.json
|
||||
except Exception:
|
||||
bottle.response.status = 400
|
||||
return {"error": "invalid JSON body"}
|
||||
|
||||
name = (data or {}).get("name", "").strip()
|
||||
domain = (data or {}).get("domain", "").strip()
|
||||
email = (data or {}).get("email", "").strip()
|
||||
channel = (data or {}).get("type", "web_widget")
|
||||
agent_id = (data or {}).get("agent_id", "")
|
||||
max_agents = int((data or {}).get("max_agents", 3))
|
||||
|
||||
# Input validation (stateless — always deterministic, no need to cache)
|
||||
if not name:
|
||||
bottle.response.status = 400
|
||||
return {"error": "name is required"}
|
||||
if len(name) > 100:
|
||||
bottle.response.status = 400
|
||||
return {"error": "name too long (max 100 chars)"}
|
||||
if not domain:
|
||||
bottle.response.status = 400
|
||||
return {"error": "domain is required"}
|
||||
if len(domain) > 255:
|
||||
bottle.response.status = 400
|
||||
return {"error": "domain too long (max 255 chars)"}
|
||||
if not email or "@" not in email or len(email) > 255:
|
||||
bottle.response.status = 400
|
||||
return {"error": "valid email is required"}
|
||||
if channel not in ("web_widget", "api"):
|
||||
bottle.response.status = 400
|
||||
return {"error": "type must be 'web_widget' or 'api'"}
|
||||
|
||||
# Idempotency check (after validation, so only provisioning results get cached)
|
||||
cached = _check_idempotency(idempotency_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# ── Provisioning (non-idempotent, stateful operations) ──────
|
||||
try:
|
||||
# 1. Create Chatwoot agent (Platform API + Agents API)
|
||||
agent_info = _create_agent(email, name)
|
||||
|
||||
# 2. Create team
|
||||
team = _call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams",
|
||||
{
|
||||
"name": f"{name} 客服团队",
|
||||
"description": f"{name} 的专属客服团队(限制 {max_agents} 席)",
|
||||
},
|
||||
)
|
||||
team_id = team.get("id")
|
||||
|
||||
# 3. Assign agent to team
|
||||
_add_agent_to_team(agent_info["agent_cw_id"], team_id)
|
||||
|
||||
# 4. Create inbox
|
||||
inbox_payload = {
|
||||
"name": name,
|
||||
"channel": {
|
||||
"type": channel,
|
||||
"website_url": domain if channel == "web_widget" else "",
|
||||
"widget_color": "#6366f1",
|
||||
"welcome_title": f"欢迎来到 {name}",
|
||||
"welcome_tagline": f"您好!欢迎访问 {name},请问有什么可以帮您?",
|
||||
},
|
||||
}
|
||||
inbox = _call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes",
|
||||
inbox_payload,
|
||||
)
|
||||
|
||||
inbox_id = inbox.get("id")
|
||||
inbox_token = inbox.get("access_token", "")
|
||||
inbox_name = inbox.get("name", name)
|
||||
website_token = inbox.get("website_token", "") or inbox.get("access_token", "")
|
||||
embed_code = (
|
||||
f"<script>\n"
|
||||
f" (function(d,t) {{\n"
|
||||
f" var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n"
|
||||
f' g.src="{CW_BASE}/packs/js/sdk.js";\n'
|
||||
f" g.defer=1;g.async=1;\n"
|
||||
f" s.parentNode.insertBefore(g,s);\n"
|
||||
f" g.onload=function(){{\n"
|
||||
f" window.chatwootSettings={{\n"
|
||||
f' position:"right",\n'
|
||||
f' type:"standard",\n'
|
||||
f' launcherTitle:"Chat"\n'
|
||||
f" }};\n"
|
||||
f" window.chatwootSDK.run({{\n"
|
||||
f' websiteToken:"{website_token}",\n'
|
||||
f' baseUrl:"{CW_BASE}"\n'
|
||||
f" }});\n"
|
||||
f" }};\n"
|
||||
f" }})(document,\"script\");\n"
|
||||
f"</script>"
|
||||
)
|
||||
|
||||
# 5. Create agent workspace
|
||||
if not agent_id:
|
||||
agent_id = f"chathub-{inbox_id}"
|
||||
agent_dir = _ensure_agent_workspace(agent_id, name)
|
||||
|
||||
# 6. Update inboxes.json
|
||||
config = _read_inboxes()
|
||||
entry = _build_inbox_entry(name, domain, channel, inbox_id, inbox_token, agent_id)
|
||||
config[str(inbox_id)] = entry
|
||||
config.setdefault("_meta", {})["updated_at"] = (
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
)
|
||||
_write_inboxes(config)
|
||||
|
||||
result = {
|
||||
"inbox_id": inbox_id,
|
||||
"inbox_token": inbox_token,
|
||||
"inbox_name": inbox_name,
|
||||
"team_id": team_id,
|
||||
"team_name": f"{name} 客服团队",
|
||||
"agent_id": agent_id,
|
||||
"agent_cw_id": agent_info["agent_cw_id"],
|
||||
"agent_cw_password": agent_info["password"],
|
||||
"agent_dir": str(agent_dir),
|
||||
"embed_code": embed_code,
|
||||
"ws_agent_updated": True,
|
||||
}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
bottle.response.status = 502
|
||||
result = {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||
except urllib.error.URLError as e:
|
||||
bottle.response.status = 502
|
||||
result = {"error": f"Chatwoot network error: {e.reason}"}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
result = {"error": str(e)}
|
||||
|
||||
# Store idempotency result (both success and failure)
|
||||
_store_idempotency(idempotency_key, result if result else {"error": "unknown"}, bottle.response.status_code)
|
||||
return result
|
||||
|
||||
|
||||
@bottle.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Suspend endpoint ─────────────────────────────────────────────
|
||||
|
||||
def _disable_inbox(inbox_id: int) -> None:
|
||||
"""Disable Chatwoot inbox: rename, clear website_url, disable auto-assignment."""
|
||||
_call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||
{
|
||||
"enable_auto_assignment": False,
|
||||
"name": f"[Suspended] Inbox #{inbox_id}",
|
||||
},
|
||||
)
|
||||
# Also clear channel website_url if web_widget
|
||||
try:
|
||||
inbox = _call_cw("GET", f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}")
|
||||
ch = inbox.get("channel", {})
|
||||
if ch.get("type") == "Channel::WebWidget":
|
||||
_call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||
{
|
||||
"channel": {
|
||||
"website_url": "",
|
||||
"welcome_title": "Service Suspended",
|
||||
"welcome_tagline": "This chat service has been suspended.",
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("Failed to clear inbox %d channel settings: %s", inbox_id, e)
|
||||
|
||||
|
||||
def _update_inbox_status(inbox_id: int, status: str) -> None:
|
||||
"""Update inboxes.json entry status."""
|
||||
config = _read_inboxes()
|
||||
key = str(inbox_id)
|
||||
if key in config:
|
||||
config[key]["status"] = status
|
||||
config.setdefault("_meta", {})["updated_at"] = (
|
||||
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
)
|
||||
_write_inboxes(config)
|
||||
|
||||
|
||||
@bottle.post("/suspend")
|
||||
def suspend():
|
||||
"""Suspend a tenant: disable Chatwoot inbox + mark inboxes.json as suspended."""
|
||||
auth_err = _check_api_key()
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
try:
|
||||
data = bottle.request.json
|
||||
except Exception:
|
||||
bottle.response.status = 400
|
||||
return {"error": "invalid JSON body"}
|
||||
|
||||
inbox_id = (data or {}).get("inbox_id")
|
||||
if not inbox_id:
|
||||
bottle.response.status = 400
|
||||
return {"error": "inbox_id is required"}
|
||||
|
||||
try:
|
||||
# 1. Disable Chatwoot inbox
|
||||
_disable_inbox(int(inbox_id))
|
||||
|
||||
# 2. Mark inboxes.json as suspended
|
||||
_update_inbox_status(int(inbox_id), "suspended")
|
||||
|
||||
return {"success": True, "message": f"Inbox {inbox_id} suspended"}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
bottle.response.status = 502
|
||||
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@bottle.post("/activate")
|
||||
def activate():
|
||||
"""Re-activate a suspended tenant: re-enable inbox + mark active."""
|
||||
auth_err = _check_api_key()
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
try:
|
||||
data = bottle.request.json
|
||||
except Exception:
|
||||
bottle.response.status = 400
|
||||
return {"error": "invalid JSON body"}
|
||||
|
||||
inbox_id = (data or {}).get("inbox_id")
|
||||
if not inbox_id:
|
||||
bottle.response.status = 400
|
||||
return {"error": "inbox_id is required"}
|
||||
|
||||
try:
|
||||
_call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
||||
{"enable_auto_assignment": True},
|
||||
)
|
||||
_update_inbox_status(int(inbox_id), "active")
|
||||
|
||||
return {"success": True, "message": f"Inbox {inbox_id} activated"}
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
bottle.response.status = 502
|
||||
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
||||
except Exception as e:
|
||||
bottle.response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5566
|
||||
log.info("Starting ChatHub Provision Server on port %d (threaded)", port)
|
||||
bottle.run(host="0.0.0.0", port=port, debug=False, server="wsgiref", num_threads=4)
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
websocket-client>=1.6.0
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
websocket-client>=1.7.0
|
||||
bottle>=0.13.0
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# ⚠️ WARNING: This script is deprecated. WS Agent is now managed by supervisor.
|
||||
# Use: supervisorctl start ws_agent
|
||||
#
|
||||
# If you need to manually start (e.g. for debugging):
|
||||
cd /app/working/workspaces/wordpress/skills/wordpress-cli
|
||||
exec nohup python3 -u chatwoot_ws_agent.py > /var/log/chatwoot_ws_agent.log 2>&1 &
|
||||
Reference in New Issue
Block a user