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