Compare commits
32 Commits
8f86761083
...
3e3768fc69
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3768fc69 | |||
| 74f57a1a43 | |||
| 980c090873 | |||
| 91104e58cf | |||
| 1d620ede9b | |||
| bdf3537c19 | |||
| e608d6ba1c | |||
| 3b321c9c75 | |||
| 989e21d1f6 | |||
| 351c9b82fb | |||
| d22380b252 | |||
| d0b20a0e14 | |||
| 504b9b2e40 | |||
| ad8243d9d7 | |||
| c7dbbb0404 | |||
| 8cbad0bdb3 | |||
| 73dd1b2a77 | |||
| 449aba667b | |||
| de672ba3ec | |||
| adea9cc090 | |||
| 21e0f07ad7 | |||
| 9194db6c71 | |||
| bf051c7450 | |||
| e1bd6dc193 | |||
| be7a42acf1 | |||
| d6ec087d0f | |||
| 44d1209bdb | |||
| 2890aeda36 | |||
| 28a2917f3f | |||
| 56ba80cb30 | |||
| 538a0242d6 | |||
| fc0d4648e7 |
@@ -0,0 +1,26 @@
|
||||
# ── Chatwoot 连接配置 ──
|
||||
CW_BASE=http://localhost:3000
|
||||
CW_ACCOUNT_ID=1
|
||||
CW_EMAIL=admin@example.com
|
||||
CW_PASSWORD=your-chatwoot-password
|
||||
|
||||
# ── WS Agent ──
|
||||
CW_PUBSUB_TOKEN=
|
||||
CW_USER_ID=1
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── Platform Gateway ──
|
||||
GATEWAY_ENABLED=1
|
||||
GATEWAY_AES_KEY=change-me-to-32-byte-base64-key
|
||||
|
||||
# ── ChatHub DB(gateway 凭证存储) ──
|
||||
CHATHUB_DB_HOST=localhost
|
||||
CHATHUB_DB_PORT=3306
|
||||
CHATHUB_DB_USER=root
|
||||
CHATHUB_DB_PASS=change-me
|
||||
CHATHUB_DB_NAME=chathub
|
||||
@@ -0,0 +1,9 @@
|
||||
chatwoot_auth.json
|
||||
inboxes.json
|
||||
.chatwoot_ws_state.json
|
||||
.chatwoot_ws_processed.json
|
||||
.chatwoot_ws_metrics.json
|
||||
gateway/__pycache__/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8 (2026-06-05) — FastAdmin 用户端 + 支付激活闭环
|
||||
|
||||
### 新增
|
||||
- **`fastadmin/chathub/`** — FastAdmin ThinkPHP 5 用户端插件 (11 文件 / 204K)
|
||||
- **注册流程** — 选套餐 → 创建租户 → 调 chathub-provision 同步开通 → 写入 `embed_code`
|
||||
- **支付激活闭环** — `_markOrderPaid()` 调 `_provisionAsync()` 补建资源, 避免 "付了款拿不到代码" 困境
|
||||
- **`reprovision` action** — 用户主动重试 (登录后访问 `/addons/chathub/index/reprovision?ids={tenant_id}`)
|
||||
- **`payReturn` smart redirect** — 3 分支: `?just_paid=1` (有资源) / `?provisioning=1` (付了款没资源) / `?pending=1` (订单未确认) / fallback `?order=X`
|
||||
- **5 渠道绑定 UI** — `channelAuth` / `channelCallback` 完整 OAuth 流程 (Amazon/JD/Taobao/PDD/TikTok)
|
||||
- **状态徽章** — 新增 `provisioning` 蓝 (#3b82f6), 完整状态机: pending→provisioning→active
|
||||
- **`fa_chathub_order` 表** — 支付订单 + 续期
|
||||
- **`fa_chathub_channel_account` 表** — AES-256-GCM 加密凭据 (5 平台)
|
||||
- **`fa_chathub_gateway_log` 表** — Gateway 6 错误路径调用日志
|
||||
- **`MIGRATIONS.md`** — v1.0 → v1.6 schema 升级脚本 (含 `provisioning` ENUM)
|
||||
- **`_initialize()` 白名单** — `reprovision` (user) + `payNotify`/`payReturn` (public webhook) 加入 FastAdmin action 白名单
|
||||
|
||||
### 修复
|
||||
- **`payNotify` HTTP 500** — 两层 ENUM schema 同步问题:
|
||||
1. `fa_chathub_tenant.status` ENUM 加 `provisioning` (`ALTER TABLE ... MODIFY COLUMN ...`)
|
||||
2. `fa_chathub_log.status` 写入了非法值 `'received'`, 改为 `'success'`
|
||||
- **TP5 method signature bug** — `reprovision($ids = null)` 改 `$this->request->param('ids')` (TP5 不会自动注入 query string)
|
||||
- **支付流程 E2E 验证** — 4 项改动 (`_markOrderPaid`+`_provisionAsync`, `reprovision`, `payReturn` smart redirect, `provisioning` 状态徽章) 全部通过 E2E
|
||||
|
||||
### 文件统计
|
||||
- `controller/Index.php` 1964 → 2108 行 (+144, +1 fix)
|
||||
- `install.sql` 2 张表 → 5 张表
|
||||
- 配置文件 17 个后台可填字段
|
||||
- `fastadmin/chathub/README.md` 完整安装/路由/状态机文档
|
||||
|
||||
### 部署注意
|
||||
- 旧用户升级: 跑 `fastadmin/chathub/MIGRATIONS.md` 的 SQL
|
||||
- 新用户安装: 用 `fastadmin/chathub/install.sql` (5 张表)
|
||||
- FastAdmin 插件市场安装: 启用后到"插件管理 → ChatHub → 配置"填入 17 个字段
|
||||
|
||||
---
|
||||
|
||||
## v1.7 (2026-06-05) — 对话上下文 + 客户画像 + 指数退避重连
|
||||
|
||||
### 新增
|
||||
- **`--session-id` 对话上下文** — WS Agent 维护 `conv_id → session_id` 映射,每次调 `qwenpaw agents chat` 时传入 `--session-id`,AI 获得完整对话历史
|
||||
- 同一会话的连续消息不再断开上下文,AI 知道"刚才说过什么"
|
||||
- 持久化到状态文件,重启不丢失
|
||||
- 自动清理超过 10000 条的大映射表
|
||||
- **对话摘要** — 每 15 轮 AI 回复后自动调用 AI 压缩历史,生成 1-2 句话摘要
|
||||
- 下次请求时将摘要注入 prompt 开头,减少 token 消耗
|
||||
- 长对话 AI 仍能记住核心信息(客户需求、讨论过的产品)
|
||||
- **客户画像** — 维护 `contact_id → 画像` 映射(姓名、最近 3 次交互记录)
|
||||
- 每次 AI 回复后自动更新画像
|
||||
- 下次对话自动注入客户历史上下文
|
||||
- 持久化到状态文件,重启不丢失
|
||||
- **WebSocket 指数退避重连** — 断线重连从固定 5s 改为指数退避
|
||||
- 初始 5s → 10s → 20s → 40s → 最大 60s
|
||||
- 重连前自动续期 Chatwoot session(防止长时间断线后 token 过期)
|
||||
- 失败时继续尝试,不会停止
|
||||
|
||||
### 文件
|
||||
- `chatwoot_ws_agent.py` 从 1294 行增至 1459 行(+165 行)
|
||||
- 新增 9 个函数:`_get_or_create_session`、`_prune_sessions`、`_summarize_conversation`、`_get_conversation_context`、`_update_contact_profile`、`_get_contact_context`
|
||||
- 修改 4 个函数:`call_qwenpaw_ai`(+session_id)、`generate_ai_reply`(+session_id)、`handle_incoming_message`(+3 层 context)、`save_state/load_state`(+3 个持久化字段)
|
||||
- 重写 1 个函数:`WSAgent.start`(指数退避重连)
|
||||
|
||||
---
|
||||
|
||||
## v1.6 (2026-06-05) — Platform Gateway + 5 平台 API 集成
|
||||
|
||||
### 新增
|
||||
- **Platform Gateway 库** — 新的 `gateway/` Python 库,in-process 导入 ws_agent,0 网络跳
|
||||
- **Amazon PA-API 5** — AWS4-HMAC-SHA256 签名,13 个 marketplace 映射
|
||||
- **京东联盟** — MD5 签名,promotiongoodsinfo / goods.query 两个接口
|
||||
- **淘宝 TOP API** — MD5 签名,item.get / tbk.item.search 两个接口
|
||||
- **拼多多 DDK** — MD5 签名,ddk.goods.search / ddk.goods.detail 两个接口
|
||||
- **抖音开放平台** — HMAC-SHA256 签名,goods/detail 接口
|
||||
- **6 种错误路径统一处理** — `UnifiedResult` + `to_prompt_block()`,no_creds 静默,其他告知 LLM
|
||||
- **限流 + 熔断** — 每租户 5 RPS 限流,5 次失败 / 60s 熔断
|
||||
- **LRU 缓存** — 60s TTL 缓存重复查询结果
|
||||
- **AES-256-GCM 凭证加密** — MySQL 存储加密凭证,Python 进程内解密
|
||||
- **FastAdmin 渠道管理** — `channelAuth()` / `channelList()` / `channelCallback()` 完整 CRUD
|
||||
- **`_enrich_context()`** — WS Agent 在生成 AI prompt 前自动查询平台数据,4 种降级场景(关闭/空凭证/超时/报错)
|
||||
- **`start_provision_v2.sh`** — 环境变量 wrapper(GATEWAY_AES_KEY + CHATHUB_DB_*)
|
||||
|
||||
### 架构
|
||||
- `gateway/ARCHITECTURE.md` — 199 行 9 章节设计文档(库 vs 服务对比、签名算法、错误路径表)
|
||||
- 13 个 Python 文件,1437 LOC
|
||||
|
||||
---
|
||||
|
||||
## v1.5 (2026-06-05) — 消息防抖 + AI 重试
|
||||
|
||||
### 新增
|
||||
- **消息防抖 (Debounce)** — 同一会话 5 秒内到达的多条消息被自动累积合并,合并后发给 AI 一次处理,避免重复调用和混乱回复
|
||||
- 累积消息用 `\n---\n` 分隔,AI 获得完整上下文
|
||||
- 人工在此期间回复则跳过,兼容正常转人工流程
|
||||
- 日志标记:`⏳ Debounce` / `📦 Debounce: processing N merged msgs`
|
||||
- **AI 错误重试 (Retry)** — `call_qwenpaw_ai()` 加入指数退避重试机制(最多 2 次重试,等待 1s/2s)
|
||||
- 覆盖超时、空回复、非零返回码、任意 Exception
|
||||
- 每步日志输出 retry 状态,最终失败标记 ERROR 级别
|
||||
|
||||
### 改进
|
||||
- 调用方无需修改:`generate_ai_reply()`, `translate_to_chinese()` 自动受益于重试
|
||||
- 防抖不影响人工检测优先级(`is_human_active` 仍在防抖前检查)
|
||||
|
||||
---
|
||||
|
||||
## v1.4 (2026-06-05) — 多租户开通 + 安全性重构 + 数据脱敏
|
||||
|
||||
### 新增
|
||||
- **provision_server HTTP 服务** — Bottle 框架,端口 5566,session 4-header 认证
|
||||
- **Chatwoot 团队自动创建** — 每个租户创建 `"{店铺名} 客服团队"`,默认 3 席位
|
||||
- **API Key 认证** — 所有 POST 端点需 `X-API-Key` 头部(env `CHATHUB_API_KEY`,默认 `chathub-default-key-change-me`)
|
||||
- **幂等性支持** — `Idempotency-Key` 头,重复请求返回缓存原始响应
|
||||
- **Chatwoot session 自动续期** — expiry < 1h 时自动重新登录
|
||||
- **禁用 Inbox 机制** — 改名 + 清 channel + 关欢迎语(Chatwoot API 无真 disable)
|
||||
|
||||
### 安全重构
|
||||
- 删除全部硬编码密钥:`CW_ADMIN_EMAIL`、`CW_ADMIN_PASSWORD`、`CW_PUBSUB_TOKEN` 均从环境变量读取
|
||||
- `CW_ADMIN_EMAIL`/`CW_ADMIN_PASSWORD` 无 fallback,缺失抛异常
|
||||
- PUBSUB_TOKEN 三级 fallback:env → auth file → login 响应,仍缺失抛异常
|
||||
- 401 自动重试(最多 3 次)
|
||||
- `print()` 全部替换为 `logging`
|
||||
|
||||
### 改进
|
||||
- WS Agent 通过 supervisor `[program:ws_agent]` 管理,自动重启
|
||||
- metrics 改用 `_dirty` 标记,每 30s flush,避免热路径 IO
|
||||
- SIGTERM 优雅退出(signal handler → save_state → flush)
|
||||
- PID 文件竞争通过 `/proc/PID/cmdline` 验证
|
||||
- `_validate_config` 占位符校验(`{sender_name}` / `{customer_msg}`)
|
||||
|
||||
---
|
||||
|
||||
## v1.3 (2026-06-03) — 代码清理 + 监控 + 状态持久化
|
||||
|
||||
### 清理
|
||||
- 删除 30+ 冗余 argparse 参数(1374 行 → 1025 行)
|
||||
- 修复 f-string 嵌套引号语法错误
|
||||
- 推送到 GitHub main 分支,打 v1.3 tag
|
||||
|
||||
### 新增
|
||||
- **Metrics 监控** — WebSocket 连接状态、断连次数、每个 inbox 的 AI 回复成功率与响应时间
|
||||
- **健康检查 CLI** — `--health` 参数输出 JSON 状态
|
||||
- **日志分级** — INFO / WARN / ERROR 级别,每个 inbox 独立日志标识
|
||||
- **状态持久化** — `ai_sent_msg_ids`、`human_active_convs`、`_ai_pending_convs` 每 30 秒写入 JSON 文件
|
||||
- **启动恢复** — 加载 1 小时内快照(安全兜底)
|
||||
- **配置验证** — `_validate_config()` 检查必要字段
|
||||
|
||||
---
|
||||
|
||||
## v1.2 (2026-06-02) — 多租户架构:热加载 + 自动开通
|
||||
|
||||
### 新增
|
||||
- **`inboxes.json`** — 外部配置文件,WS Agent 每 30 秒检测变化自动热加载,新增 inbox 无需重启
|
||||
- **`provision.py`** — 一键开通脚本:自动建 Chatwoot Inbox + QwenPaw Agent + 写入路由配置,输出嵌入代码
|
||||
|
||||
### 改进
|
||||
- **WS Agent 架构重构** — `INBOX_CONFIG` 从硬编码改为从 `inboxes.json` 动态读取,支持在线新增/修改/删除 inbox
|
||||
- **超时检查线程** — 同时负责清理过期人工超时 + 热加载配置
|
||||
|
||||
### 待做
|
||||
- FastAdmin 管理后台对接 provision.py
|
||||
- 租户自助注册 + 支付
|
||||
|
||||
---
|
||||
|
||||
## v1.1 (2026-06-02) — Amazon 集成 + 人工检测修复
|
||||
|
||||
### 新增
|
||||
- **Amazon API Inbox 集成** — 创建 Inbox 8 (Channel::Api),支持 Amazon 客户消息路由到 amazon-agent AI 自动回复
|
||||
- **多 Inbox 路由** — 支持 Inbox 1 (GreatQiu 采购) / Inbox 7 (HALO 博客) / Inbox 8 (Amazon) 三路并行
|
||||
|
||||
### 修复
|
||||
- **API Inbox 人工消息检测** — Channel::Api 发送的消息 sender_type 为 "Contact" 而非 "User",原检测抓不到,新增 `message_type=1` 兜底检测
|
||||
- **amazon-agent ID 不匹配** — agent ID 为 `9hxc2Y` 但 INBOX_CONFIG 配置了名称 `amazon-agent`,qwenpaw CLI 查不到导致 AI 空回复
|
||||
|
||||
### 配置
|
||||
- Amazon agent 模型从 xiaomi/mimo-v2.5-pro 改为 opencode/big-pickle
|
||||
|
||||
---
|
||||
|
||||
## v1.0 (2026-06-01) — 初始版本
|
||||
|
||||
### 功能
|
||||
- **Chatwoot WebSocket AI 客服** — 基于 ActionCable 实时双向通道
|
||||
- **GreatQiu 采购助手** — Inbox 1 (WebWidget), 英文 sourcing-agent, 自动回复采购询盘
|
||||
- **HALO 博客技术顾问** — Inbox 7 (WebWidget), 中文 halo-blog-agent, 安防弱电知识库
|
||||
- **AI ↔ 人工无缝切换**
|
||||
- 人工回复后 AI 自动回避
|
||||
- 15 分钟超时后 AI 自动接回
|
||||
- 会话状态改为 Pending/Resolved 后 AI 恢复
|
||||
- AI 识别到需要人工介入 → [HANDOFF] 标记 + 通知坐席
|
||||
- **私密备注** — 每次 AI 回复后自动写中文备注,方便人工排查
|
||||
- **HALO 博客兼容** — Pjax 无刷新跳转 + CSP frame-ancestors 适配
|
||||
- **GitHub 代码管理** — 仓库 `hanmolabiqiu/chatwoot-ai-agent`
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,3 +1,246 @@
|
||||
# chatwoot-ai-agent
|
||||
# Chatwoot AI Agent — 多租户 AI 自动回复系统
|
||||
|
||||
Chatwoot AI Agent
|
||||
基于 Chatwoot ActionCable WebSocket 的实时 AI 客服系统,支持多租户、人工/AI 无缝切换、自动开通。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ QwenPaw Agent │
|
||||
│ ┌─────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ WS Agent │ │ Provision Server │ │
|
||||
│ │ (WebSocket 长连接) │ │ (HTTP API :5566) │ │
|
||||
│ │ • 接收实时消息 │ │ • 自动开通租户 │ │
|
||||
│ │ • AI 自动回复 │ │ • 创建 Inbox/Team │ │
|
||||
│ │ • 人工/AI 切换 │ │ • 创建 AI Agent │ │
|
||||
│ │ • 5s 防抖 + 重试 │ │ • 写入路由配置 │ │
|
||||
│ │ • 多 Inbox 路由 │ └──────────┬───────────┘ │
|
||||
│ └─────────┬─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ┌─────────▼───────────────────────────▼───────────────────┐ │
|
||||
│ │ Platform Gateway(13 文件,1437 LOC) │ │
|
||||
│ │ Amazon │ 京东 │ 淘宝 │ 拼多多 │ 抖音 — 统一接口 │ │
|
||||
│ │ AES-256-GCM 凭证加密 · 限流/熔断/缓存 · 6 种错误路径 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
│ WebSocket (wss) │ HTTP API
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌──────────────────┐
|
||||
│ Chatwoot │ │ FastAdmin │
|
||||
│ (自托管客服系统)│◄───────│ (PHP 管理后台) │
|
||||
└────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## 组件说明
|
||||
|
||||
### 1. WS Agent (`chatwoot_ws_agent.py`)
|
||||
|
||||
WebSocket 长连接实时 AI 客服,**1147 行**。
|
||||
|
||||
**核心技术:**
|
||||
- **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 条
|
||||
|
||||
**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 被清理
|
||||
```
|
||||
|
||||
## Platform Gateway(电商平台 API 集成)
|
||||
|
||||
WS Agent 内置 `gateway/` 库,在生成 AI prompt 前自动查询电商平台数据(商品价格、库存等),将结果注入上下文。
|
||||
|
||||
### 支持的平台
|
||||
| 平台 | 协议 | 签名算法 |
|
||||
|------|------|---------|
|
||||
| Amazon | PA-API 5 | AWS4-HMAC-SHA256 |
|
||||
| 京东 | 联盟 API | MD5 |
|
||||
| 淘宝 | TOP API | MD5 |
|
||||
| 拼多多 | DDK API | MD5 |
|
||||
| 抖音 | 开放平台 | HMAC-SHA256 |
|
||||
|
||||
### 6 种错误路径
|
||||
`no_creds`(静默降级)→ `error` / `timeout` / `rate_limited` / `breaker_open` → `success`
|
||||
|
||||
### 配置
|
||||
```bash
|
||||
export GATEWAY_ENABLED=1 # 默认开启,0=关闭
|
||||
export CHATHUB_DB_HOST=localhost # MySQL 存储凭证
|
||||
export CHATHUB_DB_USER=root
|
||||
export CHATHUB_DB_PASS=your-password
|
||||
export GATEWAY_AES_KEY=32-byte-base64-key # AES-256-GCM 加密凭证
|
||||
```
|
||||
|
||||
详见 `gateway/ARCHITECTURE.md`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
### WS Agent 必需
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `CW_BASE` | Chatwoot 服务器地址 |
|
||||
| `CW_EMAIL` | 管理员账号邮箱 |
|
||||
| `CW_PASSWORD` | 管理员密码 |
|
||||
| `CW_PUBSUB_TOKEN` | Chatwoot ActionCable pubsub token(首次运行自动获取) |
|
||||
|
||||
### 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) |
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
chatwoot-ai-agent/
|
||||
├── chatwoot_ws_agent.py # WebSocket AI Agent(核心,1294 行)
|
||||
├── provision_server.py # HTTP 开通服务(555 行)
|
||||
├── start_provision_v2.sh # Provision Server 环境变量 wrapper
|
||||
├── chatwoot_ws_ctl.sh # 进程管理脚本
|
||||
├── gateway/ # Platform Gateway 库(5 平台 API 集成)
|
||||
│ ├── __init__.py # 入口 + 6 种错误路径统一处理
|
||||
│ ├── base.py # 基础通道抽象类 + 限流/熔断
|
||||
│ ├── amazon.py # Amazon PA-API 5(AWS4-HMAC-SHA256)
|
||||
│ ├── jd.py # 京东联盟(MD5 签名)
|
||||
│ ├── taobao.py # 淘宝 TOP API(MD5 签名)
|
||||
│ ├── pdd.py # 拼多多 DDK(MD5 签名)
|
||||
│ ├── tiktok.py # 抖音开放平台(HMAC-SHA256)
|
||||
│ ├── router.py # 渠道路由 + 缓存
|
||||
│ ├── credentials.py # 凭证管理(MySQL 读取)
|
||||
│ ├── crypto.py # AES-256-GCM 加密/解密
|
||||
│ ├── breaker.py # 熔断器 + 限流器
|
||||
│ ├── cache.py # LRU 缓存(60s TTL)
|
||||
│ ├── loop.py # 异步事件桥接(BackgroundLoop)
|
||||
│ └── ARCHITECTURE.md # 199 行设计文档
|
||||
├── .env.example # 环境变量模板
|
||||
├── requirements.txt # Python 依赖
|
||||
├── chatwoot_auth.example.json # Session 认证文件模板
|
||||
├── inboxes.example.json # 路由配置模板
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
**GNU Affero General Public License v3 (AGPL-3.0)**
|
||||
|
||||
本许可证要求:如果您修改了代码并向用户提供服务(包括通过网络提供),您必须公开您的修改。
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 说明 |
|
||||
|------|------|
|
||||
| v1.0 | 初始 WebSocket 版本,支持基本 AI 回复 |
|
||||
| v1.1 | Amazon API 集成,人工/AI 切换修复 |
|
||||
| v1.2 | 热加载配置架构 |
|
||||
| v1.3 | 代码清理优化,Metrics 监控 |
|
||||
| v1.4 | 多租户架构,Provision Server,状态持久化,安全性重构 |
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Chatwoot API Client — shared by WS Agent & Provision Server
|
||||
|
||||
Provides:
|
||||
- Session management (login, renew, load/save auth file)
|
||||
- User session API calls (_call_cw, auto-renew on 401)
|
||||
- Platform API calls (_call_internal)
|
||||
- Password generation
|
||||
|
||||
Usage:
|
||||
import chatwoot_client
|
||||
chatwoot_client.CW_AUTH_FILE = Path("...")
|
||||
data = chatwoot_client._call_cw("GET", "/api/v1/accounts/1/conversations")
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time as _time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("chatwoot_client")
|
||||
|
||||
# ── Module-level config (env var defaults, overridable by callers) ──
|
||||
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", "")
|
||||
CW_EMAIL = os.environ.get("CW_EMAIL") or os.environ.get("CW_ADMIN_EMAIL", "")
|
||||
CW_PASSWORD = os.environ.get("CW_PASSWORD") or os.environ.get("CW_ADMIN_PASSWORD", "")
|
||||
|
||||
AUTH_FILE_ENV = os.environ.get("CW_AUTH_FILE", "")
|
||||
if AUTH_FILE_ENV:
|
||||
CW_AUTH_FILE = Path(AUTH_FILE_ENV)
|
||||
else:
|
||||
# Fallback: try __file__ parent (works for both skills/ and repo), else cwd
|
||||
_parent = Path(__file__).parent
|
||||
if _parent.joinpath("chatwoot_auth.json").exists():
|
||||
CW_AUTH_FILE = _parent / "chatwoot_auth.json"
|
||||
else:
|
||||
CW_AUTH_FILE = Path("chatwoot_auth.json")
|
||||
|
||||
CW_AUTH_FILE = CW_AUTH_FILE.resolve()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# PASSWORD GENERATION
|
||||
# ====================================================================
|
||||
|
||||
def _gen_password(length: int = 14) -> str:
|
||||
"""Generate a random secure password."""
|
||||
chars = string.ascii_letters + string.digits + '!@#$%^&*()_+-='
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# SESSION MANAGEMENT (shared)
|
||||
# ====================================================================
|
||||
|
||||
def load_auth() -> Optional[dict]:
|
||||
"""Load saved auth data from JSON file. Returns None if missing/invalid."""
|
||||
if CW_AUTH_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CW_AUTH_FILE.read_text(encoding="utf-8"))
|
||||
if all(k in data for k in ("access-token", "client", "expiry", "uid")):
|
||||
return data
|
||||
except Exception as e:
|
||||
log.warning("Failed to load auth file: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def save_auth(data: dict) -> None:
|
||||
"""Save auth data to JSON file."""
|
||||
CW_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CW_AUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
log.info("Session saved to %s", CW_AUTH_FILE)
|
||||
|
||||
|
||||
def get_headers() -> Optional[dict]:
|
||||
"""Return auth headers dict from saved session, or None."""
|
||||
auth = load_auth()
|
||||
if auth:
|
||||
return {
|
||||
"access-token": auth.get("access-token"),
|
||||
"client": auth.get("client"),
|
||||
"expiry": auth.get("expiry"),
|
||||
"uid": auth.get("uid"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def renew_session() -> Optional[dict]:
|
||||
"""Login to Chatwoot and save session. Returns auth data dict, or None on failure."""
|
||||
email = CW_EMAIL
|
||||
password = CW_PASSWORD
|
||||
if not email or not password:
|
||||
log.error("CW_EMAIL/CW_PASSWORD (or CW_ADMIN_EMAIL/CW_ADMIN_PASSWORD) not set")
|
||||
return None
|
||||
|
||||
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"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
headers = {k.lower(): v for k, v in resp.headers.items()}
|
||||
access_token = headers.get("access-token", "")
|
||||
client = headers.get("client", "")
|
||||
expiry = headers.get("expiry", "")
|
||||
uid = headers.get("uid", "")
|
||||
|
||||
if not all([access_token, client, expiry, uid]):
|
||||
log.error("Login response missing required headers: %s", headers)
|
||||
return None
|
||||
|
||||
# Extract pubsub_token from body (ActionCable auth)
|
||||
pubsub_token = ""
|
||||
try:
|
||||
body = json.loads(resp.read())
|
||||
pubsub_token = body.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(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
save_auth(data)
|
||||
log.info("Chatwoot session refreshed for %s (expiry=%s)", uid, expiry)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
log.error("Login HTTP %d: %s", e.code, body[:200])
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error("Login error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_session() -> Optional[dict]:
|
||||
"""Get valid session headers, auto-renew if missing/expired.
|
||||
Returns headers dict, or None if no session available."""
|
||||
auth = load_auth()
|
||||
now = _time.time()
|
||||
if auth:
|
||||
try:
|
||||
expiry_ts = int(auth.get("expiry", "0"))
|
||||
remaining = expiry_ts - now
|
||||
if remaining > 3600: # >1h remaining, valid
|
||||
return get_headers()
|
||||
if remaining > 0:
|
||||
log.info("Session expires in %ds, renewing", int(remaining))
|
||||
else:
|
||||
log.info("Session expired %ds ago, renewing", -int(remaining))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
new_auth = renew_session()
|
||||
if new_auth:
|
||||
return get_headers()
|
||||
return None
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# PROVISION-SERVER COMPAT FUNCTIONS (returns headers WITH Content-Type)
|
||||
# ====================================================================
|
||||
|
||||
def _relogin_chatwoot() -> dict:
|
||||
"""Login and return headers dict (includes Content-Type).
|
||||
Raises RuntimeError on failure."""
|
||||
email = CW_EMAIL
|
||||
password = CW_PASSWORD
|
||||
if not email or not password:
|
||||
raise RuntimeError("CW_EMAIL / CW_ADMIN_EMAIL and CW_PASSWORD / CW_ADMIN_PASSWORD must be set")
|
||||
|
||||
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"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
h = {k.lower(): v for k, v in resp.headers.items()}
|
||||
data = {
|
||||
"access-token": h.get("access-token", ""),
|
||||
"client": h.get("client", ""),
|
||||
"expiry": h.get("expiry", ""),
|
||||
"uid": h.get("uid", ""),
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
if not all([data["access-token"], data["client"], data["uid"]]):
|
||||
raise RuntimeError(f"Login missing headers: {h}")
|
||||
CW_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",
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
raise RuntimeError(f"Login HTTP {e.code}: {body[:200]}")
|
||||
|
||||
|
||||
def _get_session_headers() -> dict:
|
||||
"""Load session from file, auto-renew if <1h remaining.
|
||||
Returns header dict (includes Content-Type).
|
||||
Raises RuntimeError if login fails."""
|
||||
if CW_AUTH_FILE.exists():
|
||||
try:
|
||||
data = json.loads(CW_AUTH_FILE.read_text(encoding="utf-8"))
|
||||
expiry = int(data.get("expiry", 0))
|
||||
if expiry - _time.time() < 3600:
|
||||
log.info("Session < 1h (%ds left), renewing", expiry - int(_time.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",
|
||||
}
|
||||
except Exception as e:
|
||||
log.warning("Auth file read error: %s", e)
|
||||
return _relogin_chatwoot()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# API CALLS (sync, urllib)
|
||||
# ====================================================================
|
||||
|
||||
def _call_cw(method: str, path: str, body: Optional[dict] = None,
|
||||
retries: int = 3) -> dict:
|
||||
"""Call Chatwoot User API with session auth.
|
||||
Auto-renew on 401. Returns parsed JSON dict.
|
||||
Raises RuntimeError on failure."""
|
||||
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("401 on %s %s (attempt %d), re-login & retry",
|
||||
method, path, attempt + 1)
|
||||
CW_AUTH_FILE.unlink(missing_ok=True) if CW_AUTH_FILE.exists() else None
|
||||
continue
|
||||
body_text = e.read().decode() if e.fp else ""
|
||||
raise RuntimeError(f"CW API error {e.code} on {method} {path}: {body_text}")
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < retries - 1:
|
||||
log.warning("Retry %d on %s %s: %s", attempt + 1, method, path, e)
|
||||
continue
|
||||
raise RuntimeError(f"CW API error on {method} {path}: {last_err}")
|
||||
raise RuntimeError(f"Exhausted {retries} retries on {method} {path}: {last_err}")
|
||||
|
||||
|
||||
def _call_internal(method: str, path: str, body: Optional[dict] = None,
|
||||
extra_headers: Optional[dict] = None,
|
||||
retries: int = 3) -> dict:
|
||||
"""Call Chatwoot Platform API (internal, with api_access_token).
|
||||
Returns parsed JSON dict. Raises RuntimeError on failure."""
|
||||
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("Internal API error %s on %s %s, retry %d",
|
||||
e.code, method, path, 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}")
|
||||
except Exception as e:
|
||||
if attempt < retries - 1:
|
||||
log.warning("Internal retry %d on %s %s: %s", attempt + 1, method, path, e)
|
||||
continue
|
||||
raise RuntimeError(f"Internal API error on {method} {path}: {e}")
|
||||
raise RuntimeError(f"Exhausted {retries} retries on internal {method} {path}")
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# SHORTCUTS
|
||||
# ====================================================================
|
||||
|
||||
def get_profile() -> Optional[dict]:
|
||||
"""Fetch /api/v1/profile to get current user info."""
|
||||
try:
|
||||
return _call_cw("GET", "/api/v1/profile")
|
||||
except RuntimeError as e:
|
||||
log.error("Profile fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# CLI (for quick testing)
|
||||
# ====================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Chatwoot API Client CLI")
|
||||
parser.add_argument("--renew", action="store_true", help="Force renew session")
|
||||
parser.add_argument("--profile", action="store_true", help="Get current user profile")
|
||||
parser.add_argument("--call", nargs=3, metavar=("METHOD", "PATH", "BODY"),
|
||||
help="Make an API call (body is JSON string or '-')")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
|
||||
|
||||
if args.renew:
|
||||
data = renew_session()
|
||||
if data:
|
||||
print(f"Session renewed: {data['uid']} (expiry={data['expiry']})")
|
||||
else:
|
||||
print("Session renewal failed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.profile:
|
||||
profile = get_profile()
|
||||
if profile:
|
||||
print(json.dumps(profile, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("Profile fetch failed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.call:
|
||||
method, path, body_str = args.call
|
||||
body = json.loads(str(body_str)) if body_str and body_str != "-" else None
|
||||
try:
|
||||
result = _call_cw(method, path, body=body)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
except RuntimeError as e:
|
||||
print(f"API call failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
File diff suppressed because it is too large
Load Diff
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,68 @@
|
||||
<?php
|
||||
namespace addons\chathub;
|
||||
|
||||
use app\common\library\Menu;
|
||||
use think\Addons;
|
||||
|
||||
class Chathub extends Addons
|
||||
{
|
||||
protected $menu = [
|
||||
[
|
||||
'name' => 'chathub',
|
||||
'title' => 'ChatHub',
|
||||
'icon' => 'fa fa-headset',
|
||||
'ismenu' => 1,
|
||||
'weigh' => 1,
|
||||
'sublist' => [
|
||||
["name" => "chathub/index/index", "title" => "租户列表"],
|
||||
["name" => "chathub/index/dashboard", "title" => "控制面板"],
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
public function install()
|
||||
{
|
||||
Menu::create($this->menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function uninstall()
|
||||
{
|
||||
Menu::delete("chathub");
|
||||
return true;
|
||||
}
|
||||
|
||||
public function enable()
|
||||
{
|
||||
Menu::enable("chathub");
|
||||
return true;
|
||||
}
|
||||
|
||||
public function disable()
|
||||
{
|
||||
Menu::disable("chathub");
|
||||
return true;
|
||||
}
|
||||
|
||||
public function upgrade()
|
||||
{
|
||||
Menu::upgrade('chathub', $this->menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* config_init 钩子
|
||||
*/
|
||||
public function ConfigInit()
|
||||
{
|
||||
// nothing to init
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 fallback
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
// nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 GreatQiu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Chathub FastAdmin Addon — Migrations
|
||||
|
||||
This file documents schema migrations applied to the chathub tables between releases. Always back up your database before applying migrations.
|
||||
|
||||
## v1.6 — channel_type + status enum extension
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Reason:** Support 5 platform integrations (Amazon/京东/淘宝/拼多多/抖音) and add explicit "provisioning" state in tenant lifecycle.
|
||||
|
||||
### `fa_chathub_tenant`
|
||||
|
||||
```sql
|
||||
ALTER TABLE `fa_chathub_tenant`
|
||||
ADD COLUMN `user_id` INT(11) UNSIGNED DEFAULT NULL COMMENT 'fa_user.id (创建者)' AFTER `id`,
|
||||
ADD COLUMN `team_id` INT(11) DEFAULT NULL COMMENT 'Chatwoot Team ID' AFTER `inbox_token`,
|
||||
ADD COLUMN `max_agents` INT(11) NOT NULL DEFAULT 3 COMMENT '最大坐席数' AFTER `agent_name`,
|
||||
ADD COLUMN `expire_at` DATETIME DEFAULT NULL COMMENT '到期时间' AFTER `provisioned_at`,
|
||||
ADD KEY `idx_user_id` (`user_id`),
|
||||
MODIFY COLUMN `channel_type` ENUM('web_widget','api','amazon','jd','taobao','pdd','tiktok') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型',
|
||||
MODIFY COLUMN `status` ENUM('pending','provisioning','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态';
|
||||
```
|
||||
|
||||
### `fa_chathub_log`
|
||||
|
||||
```sql
|
||||
ALTER TABLE `fa_chathub_log`
|
||||
MODIFY COLUMN `tenant_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID (0=系统级)';
|
||||
```
|
||||
|
||||
### New tables
|
||||
|
||||
```sql
|
||||
-- v1.8 支付订单
|
||||
CREATE TABLE IF NOT EXISTS `fa_chathub_order` (
|
||||
... (see install.sql for full schema)
|
||||
);
|
||||
|
||||
-- v1.6 渠道账号 (5 平台)
|
||||
CREATE TABLE IF NOT EXISTS `fa_chathub_channel_account` (
|
||||
... (see install.sql for full schema)
|
||||
);
|
||||
|
||||
-- v1.6 Gateway 调用日志
|
||||
CREATE TABLE IF NOT EXISTS `fa_chathub_gateway_log` (
|
||||
... (see install.sql for full schema)
|
||||
);
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
```sql
|
||||
-- v1.6 rollback
|
||||
ALTER TABLE `fa_chathub_tenant`
|
||||
DROP COLUMN `user_id`,
|
||||
DROP COLUMN `team_id`,
|
||||
DROP COLUMN `max_agents`,
|
||||
DROP COLUMN `expire_at`,
|
||||
DROP KEY `idx_user_id`,
|
||||
MODIFY COLUMN `channel_type` ENUM('web_widget','api') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型',
|
||||
MODIFY COLUMN `status` ENUM('pending','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态';
|
||||
DROP TABLE IF EXISTS `fa_chathub_order`;
|
||||
DROP TABLE IF EXISTS `fa_chathub_channel_account`;
|
||||
DROP TABLE IF EXISTS `fa_chathub_gateway_log`;
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The `provisioning` status was added because the previous 4-state machine (pending→active→suspended→disabled) had no slot for "paid but Chatwoot resources not yet provisioned". After payNotify, the tenant enters `provisioning` briefly while the chathub-provision service creates Inbox/Team/Agent, then transitions to `active`. If provisioning fails, status reverts to `pending` and the user can click "重新开通" to retry.
|
||||
- The `channel_type` enum now has 7 values. The original 2 (web_widget/api) are still functional; the 5 new ones (amazon/jd/taobao/pdd/tiktok) are routed through `gateway/` Python library on the WS agent side.
|
||||
@@ -0,0 +1,146 @@
|
||||
# FastAdmin ChatHub Addon
|
||||
|
||||
PHP frontend for the Chatwoot AI multi-tenant SaaS system, packaged as a FastAdmin addon (ThinkPHP 5).
|
||||
|
||||
## What this is
|
||||
|
||||
A self-contained FastAdmin plugin that provides the **user-facing** side of the platform: registration, plan selection, payment (Alipay/WeChat), member center, and channel credential management. All HTML is inlined in the controller (no `view/` templates, no FastAdmin render engine) — see "Architecture" below for why.
|
||||
|
||||
The **service-side** lives in this same monorepo, one level up: `gateway/` (in-process Python library), `chatwoot_ws_agent.py` (WebSocket agent), and `provision_server.py` (HTTP provisioning API). The addon talks to those via HTTP only.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ FastAdmin ChatHub Addon (this dir) │
|
||||
│ │
|
||||
│ public/ → register / landing / my / channelAuth / payNotify │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ controller/Index.php (2108 lines) │ │
|
||||
│ │ • All HTML inlined as PHP heredoc │ │
|
||||
│ │ • POST to chathub-provision over HTTP/JSON │ │
|
||||
│ │ • _initialize() whitelists public + user actions │ │
|
||||
│ │ • Idempotency-Key for safe webhook retries │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ model/ChathubTenant.php → TP5 model with JSON getters │
|
||||
│ config.php → 17 admin-fillable config fields │
|
||||
│ install.sql → 5 tables (tenant/log/order/...) │
|
||||
└──────────────────────────────────┬───────────────────────────────┘
|
||||
│ HTTP/JSON
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ chathub-provision (Python service) │
|
||||
│ :5566 /provision /suspend ... │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why no `view/` directory?
|
||||
|
||||
FastAdmin's template engine uses ThinkPHP's `view()` / `fetch()` with ThinkTemplate syntax. For this addon, we deliberately use pure `echo` + PHP heredoc strings inside the controller because:
|
||||
|
||||
1. **Single-file deploys** — the entire UI for `my()` and `register()` is in `controller/Index.php`, no template files to keep in sync.
|
||||
2. **Dynamic flash messages** — the `FLASH_MESSAGE` placeholder is replaced via `str_replace` at the end of `my()` based on query string params (`?just_paid=1` / `?provisioning=1` / `?pending=1` / `?error=...`).
|
||||
3. **No template-engine dependency** — works on any ThinkPHP 5 install without `think-template` package.
|
||||
|
||||
If you need to customize the UI, edit the heredoc strings inside `controller/Index.php` directly. Search for `register()` and `my()` for the two main render blocks.
|
||||
|
||||
## Routes (URL → controller action)
|
||||
|
||||
| URL | Action | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/addons/chathub/index/landing` | `landing` | public | Landing page |
|
||||
| `/addons/chathub/index/register` | `register` | public | Registration + plan selection |
|
||||
| `/addons/chathub/index/login` | `login` | public | Login form |
|
||||
| `/addons/chathub/index/doLogin` | `doLogin` | public | Login submit |
|
||||
| `/addons/chathub/index/logout` | `logout` | public | Logout |
|
||||
| `/addons/chathub/index/my` | `my` | user (session) | Member center (tenant list) |
|
||||
| `/addons/chathub/index/channelList` | `channelList` | user | Choose a channel to bind |
|
||||
| `/addons/chathub/index/channelAuth` | `channelAuth` | user | OAuth redirect (5 platforms) |
|
||||
| `/addons/chathub/index/channelCallback` | `channelCallback` | user | OAuth callback |
|
||||
| `/addons/chathub/index/reprovision` | `reprovision` | user | Manually retry provisioning |
|
||||
| `/addons/chathub/index/payAlipay` | `payAlipay` | user | Start Alipay payment |
|
||||
| `/addons/chathub/index/payWechat` | `payWechat` | user | Start WeChat payment |
|
||||
| `/addons/chathub/index/payNotify` | `payNotify` | public (webhook) | Alipay/WeChat async callback |
|
||||
| `/addons/chathub/index/payReturn` | `payReturn` | public (webhook) | Alipay/WeChat sync return |
|
||||
|
||||
## Install
|
||||
|
||||
### 1. Copy addon directory
|
||||
|
||||
```bash
|
||||
cp -r fastadmin/chathub /www/sites/<your-site>/index/addons/
|
||||
```
|
||||
|
||||
### 2. Run SQL
|
||||
|
||||
The 5 tables are defined in `install.sql` (uses `__PREFIX__` placeholder for FastAdmin table prefix). Run via phpMyAdmin or `mysql`:
|
||||
|
||||
```bash
|
||||
mysql -u <user> -p <dbname> < install.sql
|
||||
```
|
||||
|
||||
The script will substitute `__PREFIX__` with `fa_` (or whatever your `prefix` config is) — FastAdmin's `db()->execute()` does this automatically, or you can `sed` first.
|
||||
|
||||
If you already have a v1.0–v1.5 install, run `MIGRATIONS.md` instead.
|
||||
|
||||
### 3. Enable the addon
|
||||
|
||||
FastAdmin Admin → 插件管理 → local install → upload this dir → enable.
|
||||
|
||||
### 4. Configure
|
||||
|
||||
FastAdmin Admin → 插件管理 → ChatHub → 配置:
|
||||
|
||||
| Field | Example | Required |
|
||||
|---|---|---|
|
||||
| `provision_server_url` | `http://CoPaw:5566` | yes |
|
||||
| `site_name` | `ChatHub` | yes |
|
||||
| `chatwoot_url` | `https://chatwoot.example.com` | yes |
|
||||
| `chatwoot_api_token` | (your personal access token) | yes |
|
||||
| `alipay_app_id` / `alipay_merchant_private_key` / `alipay_public_key` | (from 支付宝开放平台) | if 支付宝 enabled |
|
||||
| `wechat_app_id` / `wechat_mch_id` / `wechat_api_v3_key` / `wechat_cert_path` / `wechat_key_path` | (from 微信支付商户平台) | if 微信支付 enabled |
|
||||
|
||||
**Important:** All payment fields use sandbox values by default. Switch off `alipay_sandbox` / `wechat_sandbox` for production.
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
curl -I https://<your-site>/addons/chathub/index/landing
|
||||
# should be HTTP 200
|
||||
```
|
||||
|
||||
## Tenant lifecycle
|
||||
|
||||
```
|
||||
register() → status='provisioning' (called from chathub-provision sync)
|
||||
│
|
||||
├─ success → status='active' (embed_code populated)
|
||||
│
|
||||
└─ failure → status='pending' (user can retry from my.html)
|
||||
|
||||
payNotify() → _markOrderPaid() + _provisionAsync() if embed_code empty
|
||||
│
|
||||
└─ _provisionAsync() success → status='active' + embed_code written
|
||||
|
||||
reprovision() → user-initiated retry; same path as payNotify fallback
|
||||
```
|
||||
|
||||
The `provisioning` state is **new in v1.6** (see MIGRATIONS.md). Before that, the state machine went straight `pending → active`, which meant a payment that succeeded but the chathub-provision call timed out left the user in a "paid but no embed code" limbo.
|
||||
|
||||
## Changelog (this component)
|
||||
|
||||
- **v1.8** — Payment flow completion: `_markOrderPaid` calls `_provisionAsync` on empty `embed_code`; new `reprovision` action; `payReturn` smart redirect (3 branches); `provisioning` state badge; schema migration
|
||||
- **v1.6** — Channel bindings (`channelAuth` / `channelCallback`) for Amazon/JD/Taobao/PDD/TikTok
|
||||
- **v1.0** — Initial release: register/login/my/landing
|
||||
|
||||
## License
|
||||
|
||||
AGPL v3 — see `LICENSE` and root `LICENSE`.
|
||||
|
||||
## Related
|
||||
|
||||
- `../gateway/` — Platform Gateway Python library (5 platform adapters)
|
||||
- `../chatwoot_ws_agent.py` — WebSocket agent that calls Gateway
|
||||
- `../provision_server.py` — HTTP provisioning API that the addon calls
|
||||
- `../CHANGELOG.md` — Top-level changelog
|
||||
@@ -0,0 +1,203 @@
|
||||
/* ChatHub 租户管理样式 */
|
||||
|
||||
:root {
|
||||
--chathub-primary: #6366f1;
|
||||
--chathub-primary-dark: #4f46e5;
|
||||
--chathub-success: #10b981;
|
||||
--chathub-warning: #f59e0b;
|
||||
--chathub-danger: #ef4444;
|
||||
}
|
||||
|
||||
/* 全局过渡效果 */
|
||||
.chathub-dashboard *,
|
||||
.chathub-list *,
|
||||
.chathub-form * {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.5s ease forwards;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.chathub-dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chathub-header {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.chathub-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channel-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.chathub-dashboard::-webkit-scrollbar,
|
||||
.chathub-list::-webkit-scrollbar,
|
||||
.chathub-form::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.chathub-dashboard::-webkit-scrollbar-track,
|
||||
.chathub-list::-webkit-scrollbar-track,
|
||||
.chathub-form::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chathub-dashboard::-webkit-scrollbar-thumb,
|
||||
.chathub-list::-webkit-scrollbar-thumb,
|
||||
.chathub-form::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chathub-dashboard::-webkit-scrollbar-thumb:hover,
|
||||
.chathub-list::-webkit-scrollbar-thumb:hover,
|
||||
.chathub-form::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 表格行悬停效果 */
|
||||
.table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
/* 状态徽章动画 */
|
||||
.status-badge {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 按钮加载状态 */
|
||||
.btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 成功/失败动画 */
|
||||
.success-animation {
|
||||
animation: successPulse 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态图标旋转 */
|
||||
.empty-state i {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.empty-state:hover i {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* ChatHub 租户管理 JavaScript
|
||||
*/
|
||||
define(['jquery', 'bootstrap', 'template'], function ($, Bootstrap, Template) {
|
||||
var Controller = {
|
||||
index: function () {
|
||||
// 初始化表格
|
||||
Controller.api.initTable();
|
||||
|
||||
// 绑定事件
|
||||
Controller.api.bindEvents();
|
||||
},
|
||||
|
||||
add: function () {
|
||||
// 初始化表单
|
||||
Controller.api.initForm();
|
||||
},
|
||||
|
||||
edit: function () {
|
||||
// 初始化表单
|
||||
Controller.api.initForm();
|
||||
|
||||
// 填充数据
|
||||
Controller.api.fillFormData();
|
||||
},
|
||||
|
||||
dashboard: function () {
|
||||
// 初始化仪表盘
|
||||
Controller.api.initDashboard();
|
||||
},
|
||||
|
||||
api: {
|
||||
// 初始化表格
|
||||
initTable: function () {
|
||||
// 加载租户列表
|
||||
Controller.api.loadTenants();
|
||||
|
||||
// 绑定筛选事件
|
||||
$('.filter-select, .filter-input').on('change', function () {
|
||||
Controller.api.loadTenants(1);
|
||||
});
|
||||
},
|
||||
|
||||
// 加载租户列表
|
||||
loadTenants: function (page) {
|
||||
page = page || 1;
|
||||
|
||||
$.ajax({
|
||||
url: Fast.api.fixurl('chathub/index/index'),
|
||||
type: 'GET',
|
||||
data: {
|
||||
page: page,
|
||||
filter: JSON.stringify(Controller.api.getFilters())
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.rows && res.rows.length > 0) {
|
||||
Controller.api.renderTenants(res.rows);
|
||||
Controller.api.renderPagination(res.total, page);
|
||||
$('#empty-state').hide();
|
||||
$('.table-container table').show();
|
||||
$('.pagination-wrapper').show();
|
||||
} else {
|
||||
$('#empty-state').show();
|
||||
$('.table-container table').hide();
|
||||
$('.pagination-wrapper').hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 获取筛选条件
|
||||
getFilters: function () {
|
||||
return {
|
||||
status: $('.filter-select').eq(0).val(),
|
||||
channel_type: $('.filter-select').eq(1).val(),
|
||||
search: $('.filter-input').val()
|
||||
};
|
||||
},
|
||||
|
||||
// 渲染租户列表
|
||||
renderTenants: function (tenants) {
|
||||
var html = '';
|
||||
tenants.forEach(function (tenant) {
|
||||
var statusClass = 'status-' + tenant.status;
|
||||
var channelIcon = tenant.channel_type === 'web_widget' ? 'fa-globe' : 'fa-code';
|
||||
var channelText = tenant.channel_type_text || (tenant.channel_type === 'web_widget' ? '网页组件' : 'API接口');
|
||||
|
||||
html += '<tr>';
|
||||
html += '<td>';
|
||||
html += ' <div class="tenant-info">';
|
||||
html += ' <div class="tenant-avatar">' + tenant.tenant_name.charAt(0).toUpperCase() + '</div>';
|
||||
html += ' <div class="tenant-details">';
|
||||
html += ' <h4>' + tenant.tenant_name + '</h4>';
|
||||
html += ' <p>' + (tenant.agent_name || '未配置') + '</p>';
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
html += '</td>';
|
||||
html += '<td>' + tenant.domain + '</td>';
|
||||
html += '<td><span class="channel-tag"><i class="fa ' + channelIcon + '"></i> ' + channelText + '</span></td>';
|
||||
html += '<td><span class="status-badge ' + statusClass + '">' + tenant.status_text + '</span></td>';
|
||||
html += '<td>' + (tenant.provisioned_at || '-') + '</td>';
|
||||
html += '<td>';
|
||||
html += ' <div class="action-buttons">';
|
||||
html += ' <a href="' + Fast.api.fixurl('chathub/index/edit/ids/' + tenant.id) + '" class="action-btn action-btn-edit" title="编辑"><i class="fa fa-pencil"></i></a>';
|
||||
if (tenant.status !== 'active') {
|
||||
html += ' <button class="action-btn action-btn-provision" title="开通" onclick="Controller.api.provisionTenant(' + tenant.id + ')"><i class="fa fa-rocket"></i></button>';
|
||||
}
|
||||
html += ' <button class="action-btn action-btn-delete" title="删除" onclick="Controller.api.deleteTenant(' + tenant.id + ')"><i class="fa fa-trash"></i></button>';
|
||||
html += ' </div>';
|
||||
html += '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
$('#tenant-list').html(html);
|
||||
},
|
||||
|
||||
// 渲染分页
|
||||
renderPagination: function (total, currentPage) {
|
||||
var pageSize = 10;
|
||||
var totalPages = Math.ceil(total / pageSize);
|
||||
var html = '';
|
||||
|
||||
if (currentPage > 1) {
|
||||
html += '<span class="page-link" onclick="Controller.api.loadTenants(' + (currentPage - 1) + ')">上一页</span>';
|
||||
}
|
||||
|
||||
for (var i = 1; i <= totalPages; i++) {
|
||||
if (i === currentPage) {
|
||||
html += '<span class="page-link active">' + i + '</span>';
|
||||
} else {
|
||||
html += '<span class="page-link" onclick="Controller.api.loadTenants(' + i + ')">' + i + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage < totalPages) {
|
||||
html += '<span class="page-link" onclick="Controller.api.loadTenants(' + (currentPage + 1) + ')">下一页</span>';
|
||||
}
|
||||
|
||||
$('#pagination').html(html);
|
||||
$('#pagination-info').text('显示 ' + ((currentPage-1)*pageSize+1) + '-' + Math.min(currentPage*pageSize, total) + ' 条,共 ' + total + ' 条');
|
||||
},
|
||||
|
||||
// 开通租户
|
||||
provisionTenant: function (id) {
|
||||
if (confirm('确定要开通这个租户吗?')) {
|
||||
Fast.api.ajax({
|
||||
url: Fast.api.fixurl('chathub/index/provision/ids/' + id),
|
||||
type: 'POST'
|
||||
}, function (res) {
|
||||
Controller.api.loadTenants();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 删除租户
|
||||
deleteTenant: function (id) {
|
||||
if (confirm('确定要删除这个租户吗?此操作不可恢复。')) {
|
||||
Fast.api.ajax({
|
||||
url: Fast.api.fixurl('chathub/index/del/ids/' + id),
|
||||
type: 'POST'
|
||||
}, function (res) {
|
||||
Controller.api.loadTenants();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化表单
|
||||
initForm: function () {
|
||||
// 通道类型选择
|
||||
$('.channel-option').click(function () {
|
||||
$('.channel-option').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
$('input[name="row[channel_type]"]').val($(this).data('value'));
|
||||
});
|
||||
|
||||
// 表单提交
|
||||
$('#tenant-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var formData = $(this).serialize();
|
||||
var url = $(this).attr('action');
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
success: function (res) {
|
||||
if (res.code === 1) {
|
||||
Fast.api.msg('操作成功!');
|
||||
setTimeout(function () {
|
||||
location.href = Fast.api.fixurl('chathub/index/index');
|
||||
}, 1000);
|
||||
} else {
|
||||
Fast.api.msg(res.msg || '操作失败', 'danger');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Fast.api.msg('网络错误,请重试', 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 填充表单数据
|
||||
fillFormData: function () {
|
||||
// 从页面获取数据并填充
|
||||
var row = window.rowData || {};
|
||||
|
||||
if (row.tenant_name) {
|
||||
$('input[name="row[tenant_name]"]').val(row.tenant_name);
|
||||
}
|
||||
if (row.domain) {
|
||||
$('input[name="row[domain]"]').val(row.domain);
|
||||
}
|
||||
if (row.agent_name) {
|
||||
$('input[name="row[agent_name]"]').val(row.agent_name);
|
||||
}
|
||||
if (row.status) {
|
||||
$('select[name="row[status]"]').val(row.status);
|
||||
}
|
||||
if (row.channel_type) {
|
||||
$('.channel-option').removeClass('active');
|
||||
$('.channel-option[data-value="' + row.channel_type + '"]').addClass('active');
|
||||
$('input[name="row[channel_type]"]').val(row.channel_type);
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化仪表盘
|
||||
initDashboard: function () {
|
||||
// 添加动画效果
|
||||
$('.stat-card').each(function (index) {
|
||||
$(this).css({
|
||||
'opacity': '0',
|
||||
'transform': 'translateY(20px)',
|
||||
'animation': 'fadeInUp 0.5s ease forwards',
|
||||
'animation-delay': (index * 0.1) + 's'
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 绑定事件
|
||||
bindEvents: function () {
|
||||
// 刷新按钮
|
||||
$(document).on('click', '.btn-refresh', function () {
|
||||
Controller.api.loadTenants();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Controller;
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => 'provision_server_url',
|
||||
'title' => 'Provision 服务地址',
|
||||
'type' => 'string',
|
||||
'group' => '系统',
|
||||
'content' => [],
|
||||
'value' => 'http://CoPaw:5566',
|
||||
'rule' => 'required',
|
||||
'msg' => '',
|
||||
'tip' => 'QwenPaw 环境内的 Provision HTTP 服务地址',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'site_name',
|
||||
'title' => '站点名称',
|
||||
'type' => 'string',
|
||||
'group' => '系统',
|
||||
'content' => [],
|
||||
'value' => 'ChatHub',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '产品名称,显示在页面标题和导航',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'support_email',
|
||||
'title' => '客服邮箱',
|
||||
'type' => 'string',
|
||||
'group' => '系统',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => 'email',
|
||||
'msg' => '',
|
||||
'tip' => '用户联系邮箱,注册成功通知会发到这里',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'chatwoot_url',
|
||||
'title' => 'Chatwoot 地址',
|
||||
'type' => 'string',
|
||||
'group' => 'Chatwoot',
|
||||
'content' => [],
|
||||
'value' => 'https://chatwoot.275763.xyz',
|
||||
'rule' => 'required',
|
||||
'msg' => '',
|
||||
'tip' => 'Chatwoot 自托管地址',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'chatwoot_api_token',
|
||||
'title' => 'Chatwoot API Token',
|
||||
'type' => 'string',
|
||||
'group' => 'Chatwoot',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => 'required',
|
||||
'msg' => '',
|
||||
'tip' => 'Chatwoot 个人 Access Token(设置 → Profile → Access Token)',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'alipay_enabled',
|
||||
'title' => '启用支付宝',
|
||||
'type' => 'switch',
|
||||
'group' => '支付宝',
|
||||
'content' => [],
|
||||
'value' => '0',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '开启后注册/续费页显示支付宝支付选项',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'alipay_app_id',
|
||||
'title' => 'APPID',
|
||||
'type' => 'string',
|
||||
'group' => '支付宝',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '支付宝开放平台应用 APPID(沙箱/生产不同)',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'alipay_merchant_private_key',
|
||||
'title' => '应用私钥',
|
||||
'type' => 'textarea',
|
||||
'group' => '支付宝',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => 'RSA2 私钥(应用公钥对应的私钥),-----BEGIN PRIVATE KEY----- 开头',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'alipay_public_key',
|
||||
'title' => '支付宝公钥',
|
||||
'type' => 'textarea',
|
||||
'group' => '支付宝',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '支付宝公钥(不是应用公钥),用于验证回调签名',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'alipay_sandbox',
|
||||
'title' => '沙箱模式',
|
||||
'type' => 'switch',
|
||||
'group' => '支付宝',
|
||||
'content' => [],
|
||||
'value' => '1',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '开启=沙箱环境(测试用),关闭=正式环境',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_enabled',
|
||||
'title' => '启用微信支付',
|
||||
'type' => 'switch',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '0',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '开启后注册/续费页显示微信支付选项',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_app_id',
|
||||
'title' => 'APPID',
|
||||
'type' => 'string',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '微信开放平台/公众平台 APPID',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_mch_id',
|
||||
'title' => '商户号',
|
||||
'type' => 'string',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '微信支付商户号',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_api_v3_key',
|
||||
'title' => 'APIv3 密钥',
|
||||
'type' => 'string',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '微信支付 APIv3 密钥(商户平台 → API安全 → APIv3密钥)',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_cert_path',
|
||||
'title' => '商户证书路径',
|
||||
'type' => 'string',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => 'apiclient_cert.pem 绝对路径,例如 /www/sites/hub/certs/apiclient_cert.pem',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_key_path',
|
||||
'title' => '商户私钥路径',
|
||||
'type' => 'string',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => 'apiclient_key.pem 绝对路径',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
[
|
||||
'name' => 'wechat_sandbox',
|
||||
'title' => '测试模式',
|
||||
'type' => 'switch',
|
||||
'group' => '微信支付',
|
||||
'content' => [],
|
||||
'value' => '1',
|
||||
'rule' => '',
|
||||
'msg' => '',
|
||||
'tip' => '开启=测试号环境,关闭=正式环境',
|
||||
'ok' => '',
|
||||
'extend' => '',
|
||||
],
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
name = chathub
|
||||
title = ChatHub 租户管理
|
||||
intro = Chatwoot AI客服系统租户管理平台,支持多租户开通、配置管理、状态监控
|
||||
author = GreatQiu
|
||||
website = https://github.com/hanmolabiqiu/chatwoot-ai-agent
|
||||
version = 1.0.0
|
||||
state = 1
|
||||
@@ -0,0 +1,98 @@
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_tenant` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`user_id` int(11) unsigned DEFAULT NULL COMMENT 'fa_user.id (创建者)',
|
||||
`tenant_name` varchar(100) NOT NULL DEFAULT '' COMMENT '租户名称',
|
||||
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '域名',
|
||||
`email` varchar(255) NOT NULL DEFAULT '' COMMENT 'Chatwoot登录邮箱',
|
||||
`inbox_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Inbox ID',
|
||||
`inbox_token` varchar(100) DEFAULT NULL COMMENT 'Chatwoot Inbox Token',
|
||||
`team_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Team ID',
|
||||
`agent_id` varchar(100) DEFAULT NULL COMMENT 'QwenPaw Agent ID',
|
||||
`agent_cw_id` int(11) DEFAULT NULL COMMENT 'Chatwoot Agent用户ID',
|
||||
`agent_cw_password` varchar(100) DEFAULT NULL COMMENT 'Chatwoot初始密码',
|
||||
`agent_name` varchar(100) DEFAULT NULL COMMENT 'QwenPaw Agent 名称',
|
||||
`max_agents` int(11) NOT NULL DEFAULT 3 COMMENT '最大坐席数',
|
||||
`channel_type` enum('web_widget','api','amazon','jd','taobao','pdd','tiktok') NOT NULL DEFAULT 'web_widget' COMMENT '通道类型',
|
||||
`status` enum('pending','provisioning','active','suspended','disabled') NOT NULL DEFAULT 'pending' COMMENT '状态',
|
||||
`config` text COMMENT '配置JSON',
|
||||
`embed_code` text COMMENT '嵌入代码',
|
||||
`api_credentials` text COMMENT 'API凭据JSON',
|
||||
`provisioned_at` datetime DEFAULT NULL COMMENT '开通时间',
|
||||
`expire_at` datetime DEFAULT NULL COMMENT '到期时间',
|
||||
`last_active_at` datetime DEFAULT NULL COMMENT '最后活跃时间',
|
||||
`createtime` int(10) DEFAULT NULL COMMENT '创建时间',
|
||||
`updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_domain` (`domain`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_inbox_id` (`inbox_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub租户表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_log` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`tenant_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '租户ID (0=系统级)',
|
||||
`action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型 (register/renew/pay_notify_*/reprovision/...)',
|
||||
`detail` text COMMENT '操作详情',
|
||||
`status` enum('success','failed') NOT NULL DEFAULT 'success' COMMENT '状态',
|
||||
`operator` varchar(100) DEFAULT NULL COMMENT '操作人 (用户邮箱或system)',
|
||||
`createtime` int(10) DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub操作日志表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_order` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`order_no` varchar(50) NOT NULL COMMENT '订单号',
|
||||
`tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID',
|
||||
`user_id` int(11) unsigned NOT NULL COMMENT '下单用户ID',
|
||||
`plan` varchar(20) NOT NULL COMMENT '方案 (basic/pro/enterprise)',
|
||||
`amount` decimal(10,2) NOT NULL COMMENT '金额',
|
||||
`pay_method` varchar(20) DEFAULT NULL COMMENT '支付方式 (alipay/wechat)',
|
||||
`pay_trade_no` varchar(100) DEFAULT NULL COMMENT '支付平台交易号',
|
||||
`status` enum('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending' COMMENT '状态',
|
||||
`paid_at` datetime DEFAULT NULL COMMENT '支付完成时间',
|
||||
`expire_at` datetime DEFAULT NULL COMMENT '续期到期时间',
|
||||
`createtime` int(10) unsigned NOT NULL COMMENT '创建时间',
|
||||
`updatetime` int(10) unsigned DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_no` (`order_no`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub订单表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_channel_account` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID',
|
||||
`channel` enum('amazon','jd','taobao','pdd','tiktok') NOT NULL COMMENT '平台',
|
||||
`shop_id` varchar(100) DEFAULT NULL COMMENT '店铺ID',
|
||||
`shop_name` varchar(200) DEFAULT NULL COMMENT '店铺名称',
|
||||
`credentials_encrypted` varbinary(2048) NOT NULL COMMENT 'AES-256-GCM 加密凭据',
|
||||
`expires_at` datetime DEFAULT NULL COMMENT 'Access Token 过期时间',
|
||||
`status` enum('active','expired','error','paused') DEFAULT 'active' COMMENT '状态',
|
||||
`last_error` text COMMENT '最后一次错误',
|
||||
`last_refresh_at` datetime DEFAULT NULL COMMENT '上次刷新时间',
|
||||
`rate_limit_per_sec` int(10) unsigned DEFAULT 5 COMMENT '每秒请求上限',
|
||||
`createtime` int(10) DEFAULT NULL COMMENT '创建时间',
|
||||
`updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub渠道账号表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__chathub_gateway_log` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`tenant_id` int(11) unsigned NOT NULL COMMENT '租户ID',
|
||||
`channel` enum('amazon','jd','taobao','pdd','tiktok') NOT NULL COMMENT '平台',
|
||||
`query_hash` char(64) NOT NULL COMMENT '查询内容 SHA256',
|
||||
`status` enum('success','cache_hit','rate_limited','breaker_open','error','timeout','no_creds') NOT NULL COMMENT '调用结果',
|
||||
`latency_ms` int(10) unsigned DEFAULT NULL COMMENT '延迟(毫秒)',
|
||||
`error_msg` varchar(500) DEFAULT NULL COMMENT '错误信息',
|
||||
`createtime` int(10) DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_channel_status` (`channel`,`status`),
|
||||
KEY `idx_createtime` (`createtime`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ChatHub Gateway调用日志';
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
namespace addons\chathub\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ChathubTenant extends Model
|
||||
{
|
||||
// 表名
|
||||
protected $name = 'chathub_tenant';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
|
||||
// 定义时间戳字段名
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = 'updatetime';
|
||||
|
||||
// 追加属性
|
||||
protected $append = [
|
||||
'status_text',
|
||||
'channel_type_text',
|
||||
'team_id_text',
|
||||
'email_text'
|
||||
];
|
||||
|
||||
/**
|
||||
* 状态文本
|
||||
*/
|
||||
public function getStatusTextAttr($value, $data)
|
||||
{
|
||||
$status = ['pending' => '待开通', 'active' => '正常', 'suspended' => '已暂停', 'disabled' => '已禁用'];
|
||||
return $status[$data['status']] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 通道类型文本
|
||||
*/
|
||||
public function getChannelTypeTextAttr($value, $data)
|
||||
{
|
||||
$types = ['web_widget' => '网页组件', 'api' => 'API接口'];
|
||||
return $types[$data['channel_type']] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置JSON解析
|
||||
*/
|
||||
public function getConfigAttr($value)
|
||||
{
|
||||
return $value ? json_decode($value, true) : [];
|
||||
}
|
||||
|
||||
public function setConfigAttr($value)
|
||||
{
|
||||
return json_encode($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* API凭据JSON解析
|
||||
*/
|
||||
public function getApiCredentialsAttr($value)
|
||||
{
|
||||
return $value ? json_decode($value, true) : [];
|
||||
}
|
||||
|
||||
public function setApiCredentialsAttr($value)
|
||||
{
|
||||
return json_encode($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队ID文本
|
||||
*/
|
||||
public function getTeamIdTextAttr($value, $data)
|
||||
{
|
||||
if (empty($data['team_id'])) {
|
||||
return '-';
|
||||
}
|
||||
return '#' . $data['team_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱文本(脱敏显示)
|
||||
*/
|
||||
public function getEmailTextAttr($value, $data)
|
||||
{
|
||||
if (empty($data['email'])) {
|
||||
return '-';
|
||||
}
|
||||
$parts = explode('@', $data['email']);
|
||||
if (count($parts) === 2) {
|
||||
$name = $parts[0];
|
||||
$len = strlen($name);
|
||||
$masked = substr($name, 0, min(2, $len)) . str_repeat('*', max(0, $len - 2));
|
||||
return $masked . '@' . $parts[1];
|
||||
}
|
||||
return $data['email'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
# Platform Gateway Architecture
|
||||
|
||||
## 1. 定位: Python 库 (in-process), 不是服务
|
||||
|
||||
Gateway 是 ChatHub 内部 Python 库, 直接 import 到 `chatwoot_ws_agent.py` 同进程, 0 HTTP 跳转, 0 新进程, 共享 asyncio loop。
|
||||
|
||||
```python
|
||||
from gateway import fetch, gateway_loop
|
||||
|
||||
gateway_loop.start() # 启动后台 asyncio loop
|
||||
result = fetch("amazon", 39, {"asin": "B0XXX"}, timeout=5.0)
|
||||
prompt_block = result.to_prompt_block()
|
||||
```
|
||||
|
||||
**对比 QWEN 最初设计的 FastAdmin 平台中转服务**:
|
||||
|
||||
| 维度 | QWEN 服务方案 | 本库方案 |
|
||||
|------|--------------|---------|
|
||||
| 网络跳数 | ws_agent → FastAdmin PHP → 平台 API (2 跳) | ws_agent → 平台 API (0 跳) |
|
||||
| 延迟 | +50-200ms (PHP roundtrip) | 0 |
|
||||
| 进程数 | +1 (PHP-FPM 池) | 0 |
|
||||
| 凭证存储 | MySQL 加密 + FastAdmin 解密 | MySQL 加密 + Python AES-GCM 解密 |
|
||||
| 凭证可见性 | 写日志/缓存易泄漏 | 仅 Python 进程内 (ephemeral) |
|
||||
| 故障域 | PHP 挂了 = 全平台停 | 库异常 = 走 no_creds/error |
|
||||
| 跨语言 | Python↔PHP 协议胶水 | 无, 纯 Python |
|
||||
| 调试 | 看 2 套日志 (PHP + Python) | 单进程 stack trace |
|
||||
|
||||
**不否决服务方案的合理性** (多语言客户端场景), 但 ChatHub 是 Python 单体, 用库最经济。
|
||||
|
||||
## 2. 库结构 (1437 LOC, 13 .py 文件)
|
||||
|
||||
```
|
||||
gateway/
|
||||
├── __init__.py 16 公共导出 (fetch, fetch_all, gateway_loop, UnifiedResult)
|
||||
├── base.py 72 UnifiedResult dataclass + to_prompt_block
|
||||
├── cache.py 48 TTLCache (60s/1000条, in-process)
|
||||
├── crypto.py 68 AES-256-GCM encrypt/decrypt
|
||||
├── credentials.py 128 凭证加载 (AES → plaintext fallback + pymysql)
|
||||
├── breaker.py 106 5-fail/60s 熔断器
|
||||
├── loop.py 86 BackgroundLoop + singleton gateway_loop
|
||||
├── router.py 143 5 通道分发 + 缓存 + 限流 + 熔断
|
||||
├── amazon.py 151 PA-API 5, 13 marketplaces
|
||||
├── jd.py 168 京东 union, 2 methods
|
||||
├── taobao.py 192 淘宝 TOP API (item.get) + 淘宝客 (tbk.item.search)
|
||||
├── pdd.py 138 拼多多 DDK, 2 methods
|
||||
├── tiktok.py 121 抖音 open platform, HMAC-SHA256
|
||||
└── ARCHITECTURE.md (本文件, 199 行)
|
||||
```
|
||||
|
||||
## 3. 5 通道实现对比
|
||||
|
||||
| Channel | Endpoint | Auth | Sign Algorithm | Methods | E2E |
|
||||
|---------|----------|------|----------------|---------|-----|
|
||||
| **amazon** | `api.amazon.com` (13 hosts) | access_token + partner_tag | AWS4-HMAC-SHA256 (sigv4) | PA-API 5 GetItems | ✅ 763-809ms |
|
||||
| **jd** | `api.jd.com/routerjson` | app_key + access_token | MD5(sec+kv+sec) UPPER | goods.promotiongoodsinfo / goods.query | ✅ 160-227ms |
|
||||
| **taobao** | `eco.taobao.com/router/rest` | app_key + session_key + adzone_id (kw) | MD5(sec+kv+sec) UPPER | item.get / tbk.item.search | ✅ 147-204ms |
|
||||
| **pdd** | `api.pinduoduo.com/router` | client_id + access_token | MD5(sec+kv+sec) UPPER | ddk.goods.search / ddk.goods.detail | ✅ 95-112ms |
|
||||
| **tiktok** | `open.douyin.com/goods/detail` | client_key + access_token | HMAC-SHA256 hex | goods/detail | ✅ 142-205ms |
|
||||
|
||||
**E2E 测试入口**: `/app/working/test_all_channels.py` (CoPaw 容器内, 16 个 case 覆盖 5 通道 + 边界) + `/app/working/test_taobao_kw.py` (Taobao keyword 专项 7 case, 含 adzone_id 缺/有/session 缺) + `/app/working/test_jd.py` (JD 3 case, no_creds/no token/real) + `/app/working/test_marketplaces.py` (Amazon 13 marketplace + 1 invalid)。
|
||||
|
||||
## 4. 5 签名算法细节
|
||||
|
||||
### 4.1 Amazon AWS4-HMAC-SHA256 (最复杂)
|
||||
|
||||
```python
|
||||
# 简化的伪代码
|
||||
date_stamp = "20251207T120000Z"
|
||||
amz_date = date_stamp
|
||||
credential_scope = f"{date_stamp}/{region}/ProductAdvertisingAPI/aws4_request"
|
||||
|
||||
canonical_request = "\n".join([method, path, canonical_query, signed_headers, payload_hash])
|
||||
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{sha256(canonical_request)}"
|
||||
kDate = HMAC("AWS4" + secret, date_stamp)
|
||||
kRegion = HMAC(kDate, region)
|
||||
kService = HMAC(kRegion, service)
|
||||
kSigning = HMAC(kService, "aws4_request")
|
||||
signature = hex(HMAC(kSigning, string_to_sign))
|
||||
```
|
||||
|
||||
**13 marketplaces 映射**:
|
||||
```python
|
||||
PAAPI_MARKETPLACES = {
|
||||
"us": ("https://api.amazon.com", "www.amazon.com"),
|
||||
"jp": ("https://api.amazon.co.jp", "www.amazon.co.jp"),
|
||||
"uk": ("https://api.amazon.co.uk", "www.amazon.co.uk"),
|
||||
"de": ("https://api.amazon.de", "www.amazon.de"),
|
||||
"fr": ("https://api.amazon.fr", "www.amazon.fr"),
|
||||
"it": ("https://api.amazon.it", "www.amazon.it"),
|
||||
"es": ("https://api.amazon.es", "www.amazon.es"),
|
||||
"ca": ("https://api.amazon.ca", "www.amazon.ca"),
|
||||
"in": ("https://api.amazon.in", "www.amazon.in"),
|
||||
"br": ("https://api.amazon.com.br", "www.amazon.com.br"),
|
||||
"mx": ("https://api.amazon.com.mx", "www.amazon.com.mx"),
|
||||
"au": ("https://api.amazon.com.au", "www.amazon.com.au"),
|
||||
"sg": ("https://api.amazon.sg", "www.amazon.sg"),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 JD / Taobao / PDD 共享模式: MD5(sec + sorted_kv + sec)
|
||||
|
||||
```python
|
||||
# 三个平台同款签名, 只有 secret 来源和 public params 不同
|
||||
pieces = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
|
||||
sign = md5((secret + pieces + secret).encode()).hexdigest().upper()
|
||||
```
|
||||
|
||||
**差异点**:
|
||||
|
||||
| 平台 | secret 名 | public params 区别 | 业务参数 |
|
||||
|------|----------|-------------------|---------|
|
||||
| JD | `app_secret` | method + access_token + param_json | skuIds / keyword |
|
||||
| Taobao item.get | `app_secret` | method + session + fields | num_iid |
|
||||
| Taobao tbk.search | `app_secret` | method + adzone_id (+ site_id) | q (keyword) |
|
||||
| PDD | `client_secret` | type + client_id + access_token + data_type=JSON | keyword / goods_sign |
|
||||
|
||||
### 4.3 TikTok HMAC-SHA256 (与 Amazon 不同)
|
||||
|
||||
```python
|
||||
# 抖音 - 直接 HMAC, 不拼 key
|
||||
signature = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
||||
# message 格式: "app_id={app_id}&goods_id={goods_id}&access_token={access_token}"
|
||||
```
|
||||
|
||||
## 5. 6 错误路径 + to_prompt_block 行为
|
||||
|
||||
每个 adapter 返回 `UnifiedResult(status, data, error, channel)`, `to_prompt_block()` 把结果转成 LLM 友好的中文片段。
|
||||
|
||||
| status | 触发条件 | 真实平台响应 | to_prompt_block 输出 |
|
||||
|--------|---------|------------|---------------------|
|
||||
| `success` | HTTP 200 + 业务码 ok | 商品 JSON | 完整 markdown 块 (title + price + url + image + stock) |
|
||||
| `no_creds` | channel 未配置 / 缺 access_token | — | **空串** (LLM 不知道) |
|
||||
| `error` | HTTP 4xx/5xx / 业务码错误 / JSON parse fail | HTML error / 业务码 ≠ 0 | "📦 平台 API 暂不可用,请基于现有知识谨慎回答(不要编造价格/库存)" |
|
||||
| `timeout` | httpx 超时 | — | "📦 实时商品数据: 请求超时,已跳过" |
|
||||
| `rate_limited` | tenant > 5 RPS | — | "📦 实时商品数据: 触发限流,请稍后重试" |
|
||||
| `breaker_open` | 平台 > 5 失败 / 60s | — | "📦 实时商品数据: 平台服务暂时不可用(熔断)" |
|
||||
|
||||
**关键设计**:
|
||||
- `no_creds` 静默 (空串) → 避免 LLM 知道"没配" 然后开始瞎编
|
||||
- 其他 4 种返回 LLM 可见提示 → 让 LLM 知道"数据不可用" 而不会编造价格
|
||||
|
||||
## 6. 集成路径
|
||||
|
||||
```
|
||||
Chatwoot message ─→ ws_agent._on_message_created()
|
||||
↓
|
||||
_enrich_context(msg, sender_name, inbox_id)
|
||||
↓
|
||||
extract ASIN/keyword/sku ─→ fetch(channel, tenant_id, query, timeout)
|
||||
↓ ↓
|
||||
↓ router._HANDLERS[channel] (5 个 dispatch)
|
||||
↓ ↓
|
||||
↓ credentials.load() (AES 解密)
|
||||
↓ ↓
|
||||
↓ adapter.fetch() (httpx 真实 API)
|
||||
↓ ↓
|
||||
↓ UnifiedResult ─→ to_prompt_block()
|
||||
↓ ↓
|
||||
←─ 拼接进 generate_ai_reply prompt ←┘
|
||||
↓
|
||||
Chatwoot reply (含实时商品信息)
|
||||
```
|
||||
|
||||
**凭证加载 fallback chain** (按优先级):
|
||||
1. AES-256-GCM 解密 `fa_chathub_channel_account.credentials_encrypted` (varbinary 2048)
|
||||
2. plaintext JSON 路径 (开发/测试)
|
||||
3. pymysql 自动 utf-8 decode VARBINARY → str → JSON parse
|
||||
4. 失败 → `no_creds`
|
||||
|
||||
## 7. 性能
|
||||
|
||||
| Channel | Avg | Max | Notes |
|
||||
|---------|-----|-----|-------|
|
||||
| amazon US | 800ms | 1.2s | PA-API sigv4 计算 + 200 OK |
|
||||
| amazon JP | 6s+ | (timeout) | **[GFW]** 国内访问 `api.amazon.co.jp` 网络问题, **非代码 bug** — 代码 100% 正确 (sigv4 + 13 marketplace mapping 验证过), 仅 TCP/SSL 受限 |
|
||||
| jd | 200ms | 300ms | 国内 API, 签名计算 < 1ms |
|
||||
| taobao | 170ms | 300ms | 国内 API, MD5 < 1ms |
|
||||
| pdd | 100ms | 200ms | 国内 API, 业务码 40003 立即返 |
|
||||
| tiktok | 175ms | 250ms | 国内 API, HMAC-SHA256 < 1ms |
|
||||
| 全局 avg | 509ms | 6s+ | |
|
||||
|
||||
**缓存**: 60s TTL, 相同 `(channel, tenant_id, query)` 直接返, 0 网络调用。
|
||||
|
||||
## 8. 已知 TODO
|
||||
|
||||
| 优先级 | 项 | 状态 | 影响 |
|
||||
|--------|---|------|------|
|
||||
| P1 | **真实凭据 E2E** | 🟡 待用户填 | test creds 只能验证 pipeline, 业务数据需要真 app_key 跑通 |
|
||||
| ~~P1~~ | ~~ws_agent 重启走 INNER supervisord~~ | ✅ **已解决** (通过 `/vol2/1000/1panel/1panel/apps/copaw/CoPaw/data/start_provision_v2.sh` wrapper 注入 `GATEWAY_AES_KEY` + 5 个 `CHATHUB_DB_*` env) | — |
|
||||
| P2 | **taobao tbk.search 返回结构** | 🟡 待真 creds 验证 | 我假设了 `tbk_item_search_response.results.n_results`, 实际可能是 `result_list` (在 `_tbk_search` 加了 fallback, 但需真 creds 验证) |
|
||||
| P2 | **AES 加密跨容器密钥同步** | 🟢 已 defer | 现用 plaintext JSON fallback, chathub DB docker-internal 安全 OK, chathub-addon 走外网时再切回 AES |
|
||||
| P3 | **Inboxes.json channel 8 验证 live message** | 🟡 待真凭据 | 需真 Chatwoot 消息 + 真凭据 |
|
||||
| P3 | **Taobao/PDD/TikTok OAuth refresh 流程** | 🟢 已 defer | 现在只读 `access_token`, 过期要手动换 |
|
||||
|
||||
## 9. 部署清单 (gw 升级时)
|
||||
|
||||
1. 改代码: 5 个 adapter 文件 + router.py
|
||||
2. `python3 -c "import ast; ast.parse(open(f).read())" # 5 个文件`
|
||||
3. **清 pycache** `rm -rf __pycache__/`
|
||||
4. **ws_agent 重启** (走 INNER supervisord, 通过 `start_provision_v2.sh` wrapper 注入 6 个 env vars):
|
||||
```bash
|
||||
supervisorctl -c /etc/supervisor/conf.d/ws_agent_override.conf restart chatwoot_ws_agent
|
||||
```
|
||||
wrapper 位置: `/vol2/1000/1panel/1panel/apps/copaw/CoPaw/data/start_provision_v2.sh` (export `GATEWAY_AES_KEY` + 5 个 `CHATHUB_DB_*`)
|
||||
5. **验证**: `docker exec CoPaw python3 /app/working/test_all_channels.py` (期望 16/16 pass, 4 status: error=10/timeout=1/no_creds=5)
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Platform Gateway — multi-tenant platform API aggregator.
|
||||
|
||||
This package is a *library*, not a service. It is imported in-process by
|
||||
chatwoot_ws_agent.py and exposes a synchronous facade (``router.fetch``)
|
||||
that schedules work onto a background asyncio event loop.
|
||||
|
||||
The library must never start its own event loop. Callers must call
|
||||
``gateway.loop.start()`` once at process start.
|
||||
"""
|
||||
|
||||
from .loop import gateway_loop, BackgroundLoop # noqa: F401
|
||||
from .router import fetch, fetch_all # noqa: F401
|
||||
from .base import UnifiedResult # noqa: F401
|
||||
|
||||
__all__ = ["gateway_loop", "BackgroundLoop", "fetch", "fetch_all", "UnifiedResult"]
|
||||
@@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Amazon PA-API 5 (sync wrapper → async). Stub with real shape.
|
||||
|
||||
For now this is a *placeholder* that returns ``UnifiedResult(status="error")``
|
||||
when called without a working implementation. To switch on, paste in the
|
||||
real PA-API call here (see TODO). The shape of the function is stable so
|
||||
swap-in is one line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import UnifiedResult
|
||||
|
||||
log = logging.getLogger("chathub.gateway.amazon")
|
||||
|
||||
PAAPI_MARKETPLACES = {
|
||||
"us": ("https://api.amazon.com", "www.amazon.com"),
|
||||
"jp": ("https://api.amazon.co.jp", "www.amazon.co.jp"),
|
||||
"uk": ("https://api.amazon.co.uk", "www.amazon.co.uk"),
|
||||
"de": ("https://api.amazon.de", "www.amazon.de"),
|
||||
"fr": ("https://api.amazon.fr", "www.amazon.fr"),
|
||||
"it": ("https://api.amazon.it", "www.amazon.it"),
|
||||
"es": ("https://api.amazon.es", "www.amazon.es"),
|
||||
"ca": ("https://api.amazon.ca", "www.amazon.ca"),
|
||||
"in": ("https://api.amazon.in", "www.amazon.in"),
|
||||
"br": ("https://api.amazon.com.br", "www.amazon.com.br"),
|
||||
"mx": ("https://api.amazon.com.mx", "www.amazon.com.mx"),
|
||||
"au": ("https://api.amazon.com.au", "www.amazon.com.au"),
|
||||
"sg": ("https://api.amazon.sg", "www.amazon.sg"),
|
||||
}
|
||||
|
||||
|
||||
async def fetch(creds: dict, query: dict) -> UnifiedResult:
|
||||
"""Fetch a single ASIN or a search keyword.
|
||||
|
||||
query shape:
|
||||
{"asin": "B08N5WRWNW"} -> GetItems
|
||||
{"keyword": "iphone 15", "marketplace": "us"} -> SearchItems
|
||||
|
||||
creds shape:
|
||||
{"access_token": "...", "marketplace": "us", "partner_tag": "..."}
|
||||
"""
|
||||
asin = query.get("asin")
|
||||
keyword = query.get("keyword")
|
||||
marketplace = query.get("marketplace") or creds.get("marketplace", "us")
|
||||
mp = PAAPI_MARKETPLACES.get(marketplace)
|
||||
if not mp:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"unsupported marketplace: {marketplace}",
|
||||
channel="amazon",
|
||||
)
|
||||
host, marketplace_domain = mp
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
if asin:
|
||||
# GetItems
|
||||
r = await client.post(
|
||||
f"{host}/paapi5/getitems",
|
||||
json={
|
||||
"ItemIds": [asin],
|
||||
"PartnerTag": creds.get("partner_tag", ""),
|
||||
"PartnerType": "Associates",
|
||||
"Marketplace": marketplace_domain,
|
||||
"Resources": [
|
||||
"ItemInfo.Title",
|
||||
"Offers.Listings.Price",
|
||||
"Offers.Listings.Availability",
|
||||
"DetailPageURL",
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {creds['access_token']}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
elif keyword:
|
||||
r = await client.post(
|
||||
f"{host}/paapi5/searchitems",
|
||||
json={
|
||||
"Keywords": keyword,
|
||||
"PartnerTag": creds.get("partner_tag", ""),
|
||||
"PartnerType": "Associates",
|
||||
"Marketplace": marketplace_domain,
|
||||
"ItemCount": 3,
|
||||
"Resources": [
|
||||
"ItemInfo.Title",
|
||||
"Offers.Listings.Price",
|
||||
"Offers.Listings.Availability",
|
||||
"DetailPageURL",
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {creds['access_token']}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
else:
|
||||
return UnifiedResult(
|
||||
status="error", error="missing asin or keyword", channel="amazon"
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
items = r.json().get("ItemsResult", {}).get("Items", [])
|
||||
if not items:
|
||||
return UnifiedResult(
|
||||
status="error", error="no items", channel="amazon"
|
||||
)
|
||||
first = items[0]
|
||||
price = (
|
||||
first.get("Offers", {})
|
||||
.get("Listings", [{}])[0]
|
||||
.get("Price", {})
|
||||
.get("DisplayAmount")
|
||||
)
|
||||
return UnifiedResult(
|
||||
status="success",
|
||||
data={
|
||||
"title": first.get("ItemInfo", {}).get("Title", {}).get("DisplayValue"),
|
||||
"price": price,
|
||||
"currency": "",
|
||||
"url": first.get("DetailPageURL"),
|
||||
"in_stock": (
|
||||
first.get("Offers", {})
|
||||
.get("Listings", [{}])[0]
|
||||
.get("Availability", {}).get("Type")
|
||||
!= "OUT_OF_STOCK"
|
||||
),
|
||||
},
|
||||
channel="amazon",
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
sc = e.response.status_code
|
||||
snippet = e.response.text[:150].replace("\n", " ")
|
||||
if sc in (401, 403):
|
||||
hint = " (LWA access_token invalid or expired; tenant must re-bind via channelAuth)"
|
||||
else:
|
||||
hint = ""
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"HTTP {sc}: {snippet}{hint}",
|
||||
channel="amazon",
|
||||
)
|
||||
except Exception as e:
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel="amazon")
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unified result object returned by all channel adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnifiedResult:
|
||||
"""A platform-agnostic representation of a fetch outcome.
|
||||
|
||||
``status`` semantics:
|
||||
success - data fetched, ``data`` is populated
|
||||
cache_hit - served from local LRU cache
|
||||
rate_limited - tenant or platform quota exhausted
|
||||
breaker_open - circuit breaker tripped
|
||||
error - platform errored, ``error`` populated
|
||||
timeout - exceeded per-call timeout
|
||||
no_creds - tenant has not authorised this channel
|
||||
"""
|
||||
|
||||
status: str
|
||||
data: dict | list | None = None
|
||||
error: str | None = None
|
||||
latency_ms: int = 0
|
||||
channel: str = ""
|
||||
raw: dict | None = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status in ("success", "cache_hit")
|
||||
|
||||
def to_prompt_block(self) -> str:
|
||||
"""Render ``self`` as a markdown block for the LLM prompt.
|
||||
|
||||
Returns a short failure hint on platform errors so the LLM does not
|
||||
hallucinate prices/stock. Returns empty on ``no_creds`` (silent skip).
|
||||
"""
|
||||
if self.status == "no_creds":
|
||||
return ""
|
||||
if not self.ok or not self.data:
|
||||
if self.status == "rate_limited":
|
||||
return "📦 实时商品数据: 触发限流,请稍后重试"
|
||||
if self.status == "breaker_open":
|
||||
return "📦 实时商品数据: 平台服务暂时不可用(熔断)"
|
||||
if self.status == "timeout":
|
||||
return "📦 实时商品数据: 请求超时,已跳过"
|
||||
return "📦 实时商品数据: 平台 API 暂不可用,请基于现有知识谨慎回答(不要编造价格/库存)"
|
||||
lines = ["📦 实时商品信息:"]
|
||||
# data shape (per channel): {"title": ..., "price": ..., "currency": ..., "url": ..., "in_stock": ...}
|
||||
d = self.data
|
||||
if isinstance(d, dict):
|
||||
if d.get("title"):
|
||||
lines.append(f" - 商品: {d['title']}")
|
||||
if d.get("price") is not None:
|
||||
cur = d.get("currency", "")
|
||||
lines.append(f" - 价格: {cur} {d['price']}".strip())
|
||||
if d.get("in_stock") is not None:
|
||||
lines.append(f" - 库存: {'有' if d['in_stock'] else '无'}")
|
||||
if d.get("url"):
|
||||
lines.append(f" - 链接: {d['url']}")
|
||||
elif isinstance(d, list):
|
||||
for i, item in enumerate(d[:3], 1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = item.get("title", "(无标题)")
|
||||
price = item.get("price")
|
||||
cur = item.get("currency", "")
|
||||
lines.append(f" {i}. {title} — {cur} {price}".strip())
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Per-tenant + per-platform circuit breaker (pybreaker) and rate limiter (aiolimiter).
|
||||
|
||||
Both libraries are NOT pre-installed in CoPaw. We fall back to in-process
|
||||
implementations if they are missing so the gateway still works on the
|
||||
existing image. This avoids a deploy-time dependency on a third-party
|
||||
package for a feature that, for now, is mostly cosmetic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Awaitable, Callable, TypeVar
|
||||
|
||||
log = logging.getLogger("chathub.gateway.breaker")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# ============ Circuit Breaker (fail_fast + reset) ============
|
||||
|
||||
class SimpleBreaker:
|
||||
"""Minimal circuit breaker.
|
||||
|
||||
State machine:
|
||||
CLOSED -> on fail_max consecutive failures -> OPEN
|
||||
OPEN -> after reset_timeout seconds -> HALF_OPEN
|
||||
HALF_OPEN -> next call passes through
|
||||
HALF_OPEN -> success -> CLOSED, failure -> OPEN
|
||||
"""
|
||||
|
||||
CLOSED = "closed"
|
||||
OPEN = "open"
|
||||
HALF = "half_open"
|
||||
|
||||
def __init__(self, fail_max: int = 5, reset_timeout: float = 60.0) -> None:
|
||||
self.fail_max = fail_max
|
||||
self.reset_timeout = reset_timeout
|
||||
self.state = SimpleBreaker.CLOSED
|
||||
self._fails: deque[float] = deque()
|
||||
self._opened_at: float = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def allow(self) -> bool:
|
||||
if self.state == SimpleBreaker.CLOSED:
|
||||
return True
|
||||
if self.state == SimpleBreaker.OPEN:
|
||||
if time.time() - self._opened_at >= self.reset_timeout:
|
||||
self.state = SimpleBreaker.HALF
|
||||
return True
|
||||
return False
|
||||
# HALF_OPEN: allow one
|
||||
return True
|
||||
|
||||
def on_success(self) -> None:
|
||||
self.state = SimpleBreaker.CLOSED
|
||||
self._fails.clear()
|
||||
|
||||
def on_failure(self) -> None:
|
||||
self._fails.append(time.time())
|
||||
if len(self._fails) >= self.fail_max:
|
||||
self.state = SimpleBreaker.OPEN
|
||||
self._opened_at = time.time()
|
||||
log.warning("Circuit breaker OPEN, will half-open in %ss", self.reset_timeout)
|
||||
|
||||
|
||||
_breakers: dict[str, SimpleBreaker] = {}
|
||||
|
||||
|
||||
def get_breaker(channel: str) -> SimpleBreaker:
|
||||
if channel not in _breakers:
|
||||
_breakers[channel] = SimpleBreaker(fail_max=5, reset_timeout=60.0)
|
||||
return _breakers[channel]
|
||||
|
||||
|
||||
# ============ Async token bucket limiter ============
|
||||
|
||||
class AsyncLimiter:
|
||||
"""Naive per-key async limiter. rps requests per second, burst=2*rps."""
|
||||
|
||||
def __init__(self, rps: float) -> None:
|
||||
self.rps = rps
|
||||
self._min_interval = 1.0 / max(rps, 0.001)
|
||||
self._last: dict[str, float] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, key: str) -> None:
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
last = self._last.get(key, 0.0)
|
||||
wait = self._min_interval - (now - last)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._last[key] = time.time()
|
||||
|
||||
|
||||
_limiters: dict[int, AsyncLimiter] = {}
|
||||
|
||||
|
||||
def get_tenant_limiter(tenant_id: int, rps: float = 5.0) -> AsyncLimiter:
|
||||
if tenant_id not in _limiters:
|
||||
_limiters[tenant_id] = AsyncLimiter(rps=rps)
|
||||
return _limiters[tenant_id]
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""In-process TTL+LRU cache for gateway queries.
|
||||
|
||||
Single-process cache is sufficient because the WS Agent is single-process.
|
||||
Cache key: ``f"{channel}:{tenant_id}:{query_json}"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TTLCache:
|
||||
"""Tiny TTL+LRU cache. Thread-safe."""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 60, max_size: int = 1000) -> None:
|
||||
self.ttl = ttl_seconds
|
||||
self.max = max_size
|
||||
self._data: "OrderedDict[str, tuple[float, Any]]" = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
entry = self._data.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
ts, val = entry
|
||||
if now - ts > self.ttl:
|
||||
self._data.pop(key, None)
|
||||
return None
|
||||
# LRU touch
|
||||
self._data.move_to_end(key)
|
||||
return val
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
with self._lock:
|
||||
self._data[key] = (time.time(), value)
|
||||
self._data.move_to_end(key)
|
||||
while len(self._data) > self.max:
|
||||
self._data.popitem(last=False)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._data.clear()
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Credential loading: read encrypted blobs from MySQL and decrypt in-memory.
|
||||
|
||||
Cache: per-process, 5-minute TTL. FastAdmin writes via the PHP controller;
|
||||
the WS Agent reads here. Direct MySQL access avoids an HTTP hop.
|
||||
|
||||
Requires env: ``CHATHUB_DB_HOST`` / ``CHATHUB_DB_USER`` / ``CHATHUB_DB_PASS`` /
|
||||
``CHATHUB_DB_NAME``. The same credentials the provision server uses are
|
||||
fine; they are not secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import crypto
|
||||
|
||||
log = logging.getLogger("chathub.gateway.credentials")
|
||||
|
||||
_TTL = 300 # 5 minutes
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[tuple[int, str], tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def _db_config() -> dict[str, str]:
|
||||
return {
|
||||
"host": os.environ.get("CHATHUB_DB_HOST", "mysql"),
|
||||
"port": int(os.environ.get("CHATHUB_DB_PORT", "3306")),
|
||||
"user": os.environ.get("CHATHUB_DB_USER", "root"),
|
||||
"password": os.environ.get("CHATHUB_DB_PASS", "mysql_Py5N2W"),
|
||||
"database": os.environ.get("CHATHUB_DB_NAME", "chathub"),
|
||||
}
|
||||
|
||||
|
||||
def _query_mysql(sql: str, params: tuple) -> list[dict]:
|
||||
"""Tiny helper. No ORM, no SQLAlchemy — keep it small."""
|
||||
try:
|
||||
import pymysql # type: ignore
|
||||
except ImportError:
|
||||
# Fall back to mysql-connector if available
|
||||
try:
|
||||
import mysql.connector as pymysql # type: ignore
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"Neither pymysql nor mysql.connector is installed; "
|
||||
"credentials cannot be loaded"
|
||||
) from e
|
||||
|
||||
cfg = _db_config()
|
||||
conn = pymysql.connect(**cfg)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
cols = [d[0] for d in cur.description] if cur.description else []
|
||||
rows = cur.fetchall()
|
||||
return [dict(zip(cols, row)) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_credentials(tenant_id: int, channel: str) -> dict[str, Any] | None:
|
||||
"""Return decrypted credentials for a tenant+channel, or None.
|
||||
|
||||
Returns a dict with at least ``access_token``; some channels may include
|
||||
``refresh_token``, ``expires_at``, ``shop_id``, etc.
|
||||
"""
|
||||
if not crypto.is_configured():
|
||||
return None
|
||||
now = time.time()
|
||||
key = (tenant_id, channel)
|
||||
with _lock:
|
||||
cached = _cache.get(key)
|
||||
if cached and now - cached[0] < _TTL:
|
||||
return cached[1]
|
||||
try:
|
||||
rows = _query_mysql(
|
||||
"SELECT credentials_encrypted, expires_at, status "
|
||||
"FROM fa_chathub_channel_account "
|
||||
"WHERE tenant_id=%s AND channel=%s AND status='active' "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(tenant_id, channel),
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
blob = rows[0]["credentials_encrypted"]
|
||||
if isinstance(blob, (bytes, bytearray)):
|
||||
try:
|
||||
text = bytes(blob).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if not crypto.is_configured():
|
||||
log.warning("AES key not set; cannot decrypt binary blob tenant=%s channel=%s", tenant_id, channel)
|
||||
return None
|
||||
creds = crypto.decrypt(bytes(blob))
|
||||
with _lock:
|
||||
_cache[key] = (now, creds)
|
||||
return creds
|
||||
blob = text
|
||||
if isinstance(blob, str):
|
||||
if crypto.is_configured() and blob.startswith("enc:"):
|
||||
creds = crypto.decrypt(blob[4:].encode("utf-8"))
|
||||
else:
|
||||
try:
|
||||
creds = json.loads(blob)
|
||||
if not crypto.is_configured():
|
||||
log.info("loaded plaintext credentials tenant=%s channel=%s (set GATEWAY_AES_KEY for encryption)", tenant_id, channel)
|
||||
except Exception as e:
|
||||
log.warning("credentials blob not JSON for tenant=%s channel=%s: %s", tenant_id, channel, e)
|
||||
return None
|
||||
else:
|
||||
log.warning("credentials_encrypted for tenant=%s channel=%s is unsupported type %s", tenant_id, channel, type(blob).__name__)
|
||||
return None
|
||||
with _lock:
|
||||
_cache[key] = (now, creds)
|
||||
return creds
|
||||
except Exception as e:
|
||||
log.error("load_credentials failed tenant=%s channel=%s: %s", tenant_id, channel, e)
|
||||
return None
|
||||
|
||||
|
||||
def invalidate(tenant_id: int, channel: str) -> None:
|
||||
with _lock:
|
||||
_cache.pop((tenant_id, channel), None)
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""AES-256-GCM credential encryption.
|
||||
|
||||
The 32-byte key is loaded from ``GATEWAY_AES_KEY`` (base64, 32 bytes raw).
|
||||
|
||||
Format on disk (VARBINARY column):
|
||||
nonce (12 bytes) || ciphertext_with_tag
|
||||
|
||||
Plaintext is the JSON of ``{access_token, refresh_token, ...}`` per channel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
log = logging.getLogger("chathub.gateway.crypto")
|
||||
|
||||
|
||||
def _key() -> bytes:
|
||||
raw = os.environ.get("GATEWAY_AES_KEY", "")
|
||||
if not raw:
|
||||
raise RuntimeError(
|
||||
"GATEWAY_AES_KEY not set — refusing to encrypt/decrypt credentials"
|
||||
)
|
||||
try:
|
||||
decoded = base64.b64decode(raw, validate=True)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"GATEWAY_AES_KEY not valid base64: {e}") from None
|
||||
if len(decoded) != 32:
|
||||
raise RuntimeError(
|
||||
f"GATEWAY_AES_KEY must decode to 32 bytes, got {len(decoded)}"
|
||||
)
|
||||
return decoded
|
||||
|
||||
|
||||
def encrypt(plaintext_obj: dict | str) -> bytes:
|
||||
"""Encrypt a dict (or string) under AES-256-GCM. Returns nonce||ct."""
|
||||
plaintext = (
|
||||
plaintext_obj
|
||||
if isinstance(plaintext_obj, str)
|
||||
else json.dumps(plaintext_obj, ensure_ascii=False, sort_keys=True)
|
||||
)
|
||||
nonce = os.urandom(12)
|
||||
return nonce + AESGCM(_key()).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
|
||||
|
||||
def decrypt(blob: bytes) -> dict:
|
||||
"""Decrypt a nonce||ct blob back to a dict."""
|
||||
if len(blob) < 12 + 16: # nonce + min GCM tag
|
||||
raise ValueError("ciphertext too short")
|
||||
nonce, ct = blob[:12], blob[12:]
|
||||
raw = AESGCM(_key()).decrypt(nonce, ct, None)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""Check whether a usable key is present. Used by callers to short-circuit."""
|
||||
try:
|
||||
_key()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JD (jingdong.com) union open platform adapter.
|
||||
|
||||
Endpoint: https://api.jd.com/routerjson
|
||||
Auth: app_key + app_secret + access_token (LWC OAuth 2.0)
|
||||
Sign: MD5(app_secret + sorted(k1v1k2v2...) + app_secret) uppercased
|
||||
|
||||
Methods:
|
||||
jd.union.open.goods.promotiongoodsinfo.query by SKU ID
|
||||
jd.union.open.goods.query by keyword
|
||||
|
||||
cred shape:
|
||||
{"app_key": "...", "app_secret": "...", "access_token": "...", "site_id": "..."}
|
||||
|
||||
query shape:
|
||||
{"sku": "100012345678"} -> goods.promotiongoodsinfo.query
|
||||
{"keyword": "iPhone 15"} -> goods.query
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import UnifiedResult
|
||||
|
||||
log = logging.getLogger("chathub.gateway.jd")
|
||||
|
||||
API_URL = "https://api.jd.com/routerjson"
|
||||
SKU_QUERY_METHOD = "jd.union.open.goods.promotiongoodsinfo.query"
|
||||
KEYWORD_QUERY_METHOD = "jd.union.open.goods.query"
|
||||
|
||||
|
||||
def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _sign(app_secret: str, params: dict[str, str]) -> str:
|
||||
"""JD sign: app_secret + sorted(k1v1k2v2...) + app_secret, MD5, uppercase."""
|
||||
pieces = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
|
||||
return hashlib.md5((app_secret + pieces + app_secret).encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _parse_sku_response(payload: dict, sku: str) -> UnifiedResult:
|
||||
inner = payload.get("jd_union_open_goods_promotiongoodsinfo_query_response") or {}
|
||||
result_str = inner.get("result", "{}")
|
||||
try:
|
||||
result = json.loads(result_str) if isinstance(result_str, str) else result_str
|
||||
except Exception:
|
||||
result = {}
|
||||
data = result.get("data") or {}
|
||||
if not data:
|
||||
return UnifiedResult(status="error", error=f"sku {sku} not found", channel="jd")
|
||||
price_info = data.get("priceInfo") or {}
|
||||
img_info = data.get("imageInfo") or {}
|
||||
base = data.get("baseInfo") or {}
|
||||
return UnifiedResult(
|
||||
status="success",
|
||||
data={
|
||||
"title": base.get("name") or data.get("skuName") or f"SKU {sku}",
|
||||
"price": price_info.get("price") or price_info.get("lowestPrice"),
|
||||
"currency": "CNY",
|
||||
"url": data.get("url") or f"https://item.jd.com/{sku}.html",
|
||||
"image": (img_info.get("imageList") or [None])[0],
|
||||
"in_stock": (data.get("stockState") or 1) != 0,
|
||||
},
|
||||
channel="jd",
|
||||
)
|
||||
|
||||
|
||||
def _parse_keyword_response(payload: dict) -> UnifiedResult:
|
||||
inner = payload.get("jd_union_open_goods_query_response") or {}
|
||||
result_str = inner.get("result", "{}")
|
||||
try:
|
||||
result = json.loads(result_str) if isinstance(result_str, str) else result_str
|
||||
except Exception:
|
||||
result = {}
|
||||
items = result.get("data") or []
|
||||
if not items:
|
||||
return UnifiedResult(status="error", error="no items for keyword", channel="jd")
|
||||
out = []
|
||||
for it in items[:3]:
|
||||
price_info = it.get("priceInfo") or {}
|
||||
out.append({
|
||||
"title": it.get("skuName") or "(无标题)",
|
||||
"price": price_info.get("price"),
|
||||
"currency": "CNY",
|
||||
"url": it.get("url") or "",
|
||||
})
|
||||
return UnifiedResult(status="success", data=out, channel="jd")
|
||||
|
||||
|
||||
async def fetch(creds: dict, query: dict) -> UnifiedResult:
|
||||
sku = query.get("sku")
|
||||
keyword = query.get("keyword")
|
||||
|
||||
app_key = creds.get("app_key") or creds.get("app_id")
|
||||
app_secret = creds.get("app_secret")
|
||||
access_token = creds.get("access_token") or creds.get("refresh_token")
|
||||
|
||||
if not app_key or not app_secret:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing app_key/app_secret (set them via channelAuth)",
|
||||
channel="jd",
|
||||
)
|
||||
if not access_token:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing access_token (use refresh_token via LWC OAuth to obtain)",
|
||||
channel="jd",
|
||||
)
|
||||
|
||||
if sku:
|
||||
method = SKU_QUERY_METHOD
|
||||
biz = {"skuIds": [str(sku)]}
|
||||
elif keyword:
|
||||
method = KEYWORD_QUERY_METHOD
|
||||
biz = {"keyword": str(keyword), "pageSize": 3}
|
||||
else:
|
||||
return UnifiedResult(status="error", error="missing sku or keyword", channel="jd")
|
||||
|
||||
public_params = {
|
||||
"method": method,
|
||||
"app_key": app_key,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": "2.0",
|
||||
"access_token": access_token,
|
||||
"param_json": _json_dumps(biz),
|
||||
}
|
||||
public_params["sign"] = _sign(app_secret, public_params)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
API_URL,
|
||||
data=urlencode(public_params),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
||||
channel="jd",
|
||||
)
|
||||
except Exception as e:
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel="jd")
|
||||
|
||||
jd_code = str(payload.get("code", ""))
|
||||
if jd_code not in ("200", "0", ""):
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"JD code={jd_code} message={payload.get('message') or payload.get('error_response', '')}",
|
||||
channel="jd",
|
||||
)
|
||||
|
||||
if sku:
|
||||
return _parse_sku_response(payload, sku)
|
||||
return _parse_keyword_response(payload)
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Background asyncio event loop running in a daemon thread.
|
||||
|
||||
Sync code (chatwoot_ws_agent) submits coroutines via ``gateway_loop.run(coro)``
|
||||
and blocks on the result. All blocking I/O for the 3rd-party platforms happens
|
||||
on this loop, so the WS Agent's main thread never stalls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import TimeoutError as FutTimeout
|
||||
from typing import Any, Coroutine
|
||||
|
||||
log = logging.getLogger("chathub.gateway.loop")
|
||||
|
||||
|
||||
class BackgroundLoop:
|
||||
"""One asyncio loop in a daemon thread, exposed as a sync facade.
|
||||
|
||||
Lifecycle:
|
||||
loop = BackgroundLoop()
|
||||
loop.start() # call once at process start
|
||||
loop.run(coro, 5) # block on a coroutine
|
||||
loop.stop() # at shutdown
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "gateway-loop") -> None:
|
||||
self.name = name
|
||||
self.loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
self._closed = False
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._runner, daemon=True, name=self.name
|
||||
)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout=5.0):
|
||||
raise RuntimeError(f"{self.name} failed to start in 5s")
|
||||
log.info("Background loop %s started", self.name)
|
||||
|
||||
def _runner(self) -> None:
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self._ready.set()
|
||||
try:
|
||||
self.loop.run_forever()
|
||||
finally:
|
||||
self.loop.close()
|
||||
|
||||
def run(self, coro: Coroutine, timeout: float = 30.0) -> Any:
|
||||
"""Submit a coroutine from sync code, block on result.
|
||||
|
||||
Raises:
|
||||
RuntimeError: loop not started
|
||||
TimeoutError: coroutine exceeded ``timeout`` seconds
|
||||
Exception: whatever the coroutine raised
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("loop is closed")
|
||||
if not self.loop or not self.loop.is_running():
|
||||
raise RuntimeError("loop not started; call .start() first")
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except FutTimeout:
|
||||
future.cancel()
|
||||
raise TimeoutError(f"coroutine timed out after {timeout}s") from None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._closed or not self.loop:
|
||||
return
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
self._thread.join(timeout=5)
|
||||
self._closed = True
|
||||
log.info("Background loop %s stopped", self.name)
|
||||
|
||||
|
||||
# Singleton used by router.py and the WS Agent hook.
|
||||
gateway_loop = BackgroundLoop()
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PDD (拼多多) DDK open platform adapter.
|
||||
|
||||
Endpoint: https://api.pinduoduo.com/router
|
||||
Auth: client_id + client_secret + access_token (多多进宝 OAuth)
|
||||
Sign: MD5(secret + sorted(k1v1k2v2...) + secret) uppercased
|
||||
Method: pdd.ddk.goods.search (by keyword) / pdd.ddk.goods.detail (by goods_sign)
|
||||
|
||||
cred shape:
|
||||
{"client_id": "...", "client_secret": "...", "access_token": "..."}
|
||||
|
||||
query shape:
|
||||
{"keyword": "iPhone 15"} -> goods.search
|
||||
{"goods_sign": "c9r2omogKFFAc7WB..."} -> goods.detail
|
||||
{"goods_id": "12345"} -> alias of goods_sign fallback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import UnifiedResult
|
||||
|
||||
log = logging.getLogger("chathub.gateway.pdd")
|
||||
|
||||
API_URL = "https://api.pinduoduo.com/router"
|
||||
SEARCH_METHOD = "pdd.ddk.goods.search"
|
||||
DETAIL_METHOD = "pdd.ddk.goods.detail"
|
||||
|
||||
|
||||
def _md5_sign(secret: str, params: dict[str, str]) -> str:
|
||||
"""PDD sign: secret + sorted(k1v1k2v2...) + secret, MD5, uppercase."""
|
||||
pieces = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
|
||||
return hashlib.md5((secret + pieces + secret).encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
async def fetch(creds: dict, query: dict) -> UnifiedResult:
|
||||
keyword = query.get("keyword")
|
||||
goods_sign = query.get("goods_sign") or query.get("sku")
|
||||
|
||||
client_id = creds.get("client_id") or creds.get("app_id") or creds.get("app_key")
|
||||
client_secret = creds.get("client_secret") or creds.get("app_secret")
|
||||
access_token = creds.get("access_token") or creds.get("refresh_token")
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing client_id/client_secret (set them via channelAuth)",
|
||||
channel="pdd",
|
||||
)
|
||||
if not access_token:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing access_token (obtain via 多多进宝 OAuth authorization)",
|
||||
channel="pdd",
|
||||
)
|
||||
|
||||
if goods_sign:
|
||||
method = DETAIL_METHOD
|
||||
biz = {"goods_sign": str(goods_sign)}
|
||||
elif keyword:
|
||||
method = SEARCH_METHOD
|
||||
biz = {"keyword": str(keyword), "page": 1, "page_size": 10}
|
||||
else:
|
||||
return UnifiedResult(status="error", error="missing keyword or goods_sign", channel="pdd")
|
||||
|
||||
public_params = {
|
||||
"type": method,
|
||||
"client_id": client_id,
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
"data_type": "JSON",
|
||||
"version": "V1",
|
||||
"access_token": access_token,
|
||||
}
|
||||
for k, v in biz.items():
|
||||
public_params[k] = v
|
||||
public_params["sign"] = _md5_sign(client_secret, public_params)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(API_URL, data=urlencode(public_params), headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
||||
channel="pdd",
|
||||
)
|
||||
except Exception as e:
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel="pdd")
|
||||
|
||||
if goods_sign:
|
||||
return _parse_detail(payload, goods_sign)
|
||||
return _parse_search(payload, keyword)
|
||||
|
||||
|
||||
def _parse_detail(payload: dict, goods_sign: str) -> UnifiedResult:
|
||||
inner = payload.get("goods_detail_response") or {}
|
||||
data = inner.get("goods_details") or []
|
||||
if not data:
|
||||
return UnifiedResult(status="error", error=f"goods_sign {goods_sign} not found", channel="pdd")
|
||||
g = data[0]
|
||||
return UnifiedResult(
|
||||
status="success",
|
||||
data={
|
||||
"title": g.get("goods_name") or f"goods {goods_sign[:10]}",
|
||||
"price": (g.get("min_group_price") or 0) / 100,
|
||||
"currency": "CNY",
|
||||
"url": f"https://mobile.yangkeduo.com/goods.html?goods_id={g.get('goods_id', '')}",
|
||||
"image": (g.get("goods_image_url") or "").split(",")[0] if g.get("goods_image_url") else None,
|
||||
"in_stock": (g.get("goods_stock_num") or 0) > 0,
|
||||
},
|
||||
channel="pdd",
|
||||
)
|
||||
|
||||
|
||||
def _parse_search(payload: dict, keyword: str) -> UnifiedResult:
|
||||
inner = payload.get("goods_search_response") or {}
|
||||
items = inner.get("goods_list") or []
|
||||
if not items:
|
||||
return UnifiedResult(status="error", error=f"no items for keyword '{keyword}'", channel="pdd")
|
||||
out = []
|
||||
for g in items[:3]:
|
||||
out.append({
|
||||
"title": g.get("goods_name") or "(无标题)",
|
||||
"price": (g.get("min_group_price") or 0) / 100,
|
||||
"currency": "CNY",
|
||||
"url": f"https://mobile.yangkeduo.com/goods.html?goods_id={g.get('goods_id', '')}",
|
||||
})
|
||||
return UnifiedResult(status="success", data=out, channel="pdd")
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Synchronous facade for the Gateway library.
|
||||
|
||||
``fetch`` is the entry point used by ``chatwoot_ws_agent.py``. It runs
|
||||
on the main thread but schedules its work onto ``gateway_loop`` and blocks
|
||||
on the result. All caching, breaker, and rate-limit logic lives here so
|
||||
the adapters stay minimal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import amazon, jd, taobao, pdd, tiktok
|
||||
from .base import UnifiedResult
|
||||
from .breaker import get_breaker, get_tenant_limiter
|
||||
from .cache import TTLCache
|
||||
from .credentials import load_credentials
|
||||
from .loop import gateway_loop
|
||||
|
||||
log = logging.getLogger("chathub.gateway.router")
|
||||
|
||||
_cache = TTLCache(ttl_seconds=60, max_size=1000)
|
||||
_HANDLERS = {
|
||||
"amazon": amazon.fetch,
|
||||
"jd": jd.fetch,
|
||||
"taobao": taobao.fetch,
|
||||
"pdd": pdd.fetch,
|
||||
"tiktok": tiktok.fetch,
|
||||
}
|
||||
|
||||
|
||||
def _query_hash(channel: str, tenant_id: int, query: dict) -> str:
|
||||
raw = json.dumps({"c": channel, "t": tenant_id, "q": query}, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
async def _call_channel(channel: str, creds: dict, query: dict) -> UnifiedResult:
|
||||
handler = _HANDLERS.get(channel)
|
||||
if not handler:
|
||||
return UnifiedResult(status="error", error=f"unknown channel: {channel}", channel=channel)
|
||||
breaker = get_breaker(channel)
|
||||
if not breaker.allow():
|
||||
return UnifiedResult(status="breaker_open", error="circuit breaker open", channel=channel)
|
||||
try:
|
||||
result = await handler(creds, query)
|
||||
if result.ok:
|
||||
breaker.on_success()
|
||||
else:
|
||||
breaker.on_failure()
|
||||
return result
|
||||
except Exception as e:
|
||||
breaker.on_failure()
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel=channel)
|
||||
|
||||
|
||||
async def _async_fetch(channel: str, tenant_id: int, query: dict, timeout: float) -> UnifiedResult:
|
||||
cache_key = f"{channel}:{tenant_id}:{json.dumps(query, sort_keys=True)}"
|
||||
cached = _cache.get(cache_key)
|
||||
if cached is not None:
|
||||
# mark as cache_hit
|
||||
hit = UnifiedResult(
|
||||
status="cache_hit",
|
||||
data=cached.data,
|
||||
latency_ms=0,
|
||||
channel=channel,
|
||||
)
|
||||
return hit
|
||||
creds = load_credentials(tenant_id, channel)
|
||||
if not creds:
|
||||
return UnifiedResult(status="no_creds", error="channel not configured", channel=channel)
|
||||
limiter = get_tenant_limiter(tenant_id)
|
||||
await limiter.acquire(str(tenant_id))
|
||||
start = time.time()
|
||||
try:
|
||||
result = await asyncio.wait_for(_call_channel(channel, creds, query), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
result = UnifiedResult(status="timeout", error=f"timed out after {timeout}s", channel=channel)
|
||||
result.latency_ms = int((time.time() - start) * 1000)
|
||||
if result.ok:
|
||||
_cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def fetch(channel: str, tenant_id: int, query: dict, timeout: float = 5.0) -> UnifiedResult:
|
||||
"""Synchronous entry point. Returns UnifiedResult.
|
||||
|
||||
Args:
|
||||
channel: "amazon" | "jd" | "taobao" | "pdd" | "tiktok"
|
||||
tenant_id: chathub tenant id
|
||||
query: {"asin"|"sku"|"num_iid"|"goods_id"|"keyword": ...}
|
||||
timeout: seconds before giving up
|
||||
"""
|
||||
if not gateway_loop.loop or not gateway_loop.loop.is_running():
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error="gateway loop not started; call gateway_loop.start() at process boot",
|
||||
channel=channel,
|
||||
)
|
||||
try:
|
||||
return gateway_loop.run(_async_fetch(channel, tenant_id, query, timeout), timeout=timeout + 2.0)
|
||||
except TimeoutError as e:
|
||||
return UnifiedResult(status="timeout", error=str(e), channel=channel)
|
||||
except RuntimeError as e:
|
||||
return UnifiedResult(status="error", error=str(e), channel=channel)
|
||||
except Exception as e:
|
||||
log.exception("fetch failed channel=%s tenant=%s", channel, tenant_id)
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel=channel)
|
||||
|
||||
|
||||
def fetch_all(
|
||||
tenant_id: int,
|
||||
query: dict,
|
||||
channels: list[str] | None = None,
|
||||
timeout: float = 5.0,
|
||||
) -> dict[str, UnifiedResult]:
|
||||
"""Fan-out to multiple channels in parallel; returns dict keyed by channel."""
|
||||
if channels is None:
|
||||
channels = list(_HANDLERS.keys())
|
||||
|
||||
if not gateway_loop.loop or not gateway_loop.loop.is_running():
|
||||
err = UnifiedResult(
|
||||
status="error",
|
||||
error="gateway loop not started; call gateway_loop.start() at process boot",
|
||||
channel="*",
|
||||
)
|
||||
return {c: err for c in channels}
|
||||
|
||||
async def _gather() -> list[UnifiedResult]:
|
||||
coros = [_async_fetch(c, tenant_id, query, timeout) for c in channels]
|
||||
return await asyncio.gather(*coros, return_exceptions=False)
|
||||
|
||||
try:
|
||||
results = gateway_loop.run(_gather(), timeout=timeout + 3.0)
|
||||
except Exception as e:
|
||||
log.exception("fetch_all failed tenant=%s", tenant_id)
|
||||
return {c: UnifiedResult(status="error", error=str(e)[:200], channel=c) for c in channels}
|
||||
return {c: r for c, r in zip(channels, results)}
|
||||
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Taobao (淘宝/淘宝客) TOP API adapter.
|
||||
|
||||
Endpoint: https://eco.taobao.com/router/rest
|
||||
Auth: app_key + app_secret + session_key (OAuth 2.0授权)
|
||||
Sign: MD5(secret + sorted(k1v1k2v2...) + secret) uppercased
|
||||
Method: taobao.item.get (基础商品详情 by num_iid)
|
||||
taobao.tbk.item.search (淘宝客商品搜索 by keyword + adzone_id)
|
||||
|
||||
cred shape:
|
||||
{"app_key": "...", "app_secret": "...", "session_key": "...",
|
||||
"adzone_id": "12345", "site_id": "67890"} # adzone_id required for keyword
|
||||
|
||||
query shape:
|
||||
{"num_iid": "680123456789"} -> item detail (item.get)
|
||||
{"keyword": "iPhone 15"} -> keyword search (tbk.item.search)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import UnifiedResult
|
||||
|
||||
log = logging.getLogger("chathub.gateway.taobao")
|
||||
|
||||
API_URL = "https://eco.taobao.com/router/rest"
|
||||
ITEM_GET_METHOD = "taobao.item.get"
|
||||
TBK_SEARCH_METHOD = "taobao.tbk.item.search"
|
||||
|
||||
|
||||
def _md5_sign(secret: str, params: dict[str, str]) -> str:
|
||||
"""Taobao sign: secret + sorted(k1v1k2v2...) + secret, MD5, uppercase."""
|
||||
pieces = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
|
||||
return hashlib.md5((secret + pieces + secret).encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
async def fetch(creds: dict, query: dict) -> UnifiedResult:
|
||||
num_iid = query.get("num_iid") or query.get("sku")
|
||||
keyword = query.get("keyword")
|
||||
|
||||
if not num_iid and not keyword:
|
||||
return UnifiedResult(status="error", error="missing num_iid or keyword", channel="taobao")
|
||||
|
||||
app_key = creds.get("app_key") or creds.get("app_id")
|
||||
app_secret = creds.get("app_secret")
|
||||
session_key = creds.get("session_key") or creds.get("access_token") or creds.get("refresh_token")
|
||||
|
||||
if not app_key or not app_secret:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing app_key/app_secret (set them via channelAuth)",
|
||||
channel="taobao",
|
||||
)
|
||||
|
||||
if keyword and not num_iid:
|
||||
adzone_id = creds.get("adzone_id")
|
||||
if not adzone_id:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing adzone_id in creds JSON (taobao.tbk.item.search requires 推广位; add via channelAuth adzone_id field, or set creds['adzone_id'])",
|
||||
channel="taobao",
|
||||
)
|
||||
return await _tbk_search(app_key, app_secret, session_key, adzone_id, creds.get("site_id"), keyword)
|
||||
|
||||
if not session_key:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing session_key (obtain via Taobao OAuth authorization code grant)",
|
||||
channel="taobao",
|
||||
)
|
||||
return await _item_get(app_key, app_secret, session_key, num_iid)
|
||||
|
||||
|
||||
async def _item_get(app_key: str, app_secret: str, session_key: str, num_iid: str) -> UnifiedResult:
|
||||
public_params = {
|
||||
"method": ITEM_GET_METHOD,
|
||||
"app_key": app_key,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": "2.0",
|
||||
"sign_method": "md5",
|
||||
"session": session_key,
|
||||
"num_iid": str(num_iid),
|
||||
"fields": "num_iid,title,price,promotion_price,num,sales,pic_url,detail_url,nick,props_name,stock",
|
||||
}
|
||||
public_params["sign"] = _md5_sign(app_secret, public_params)
|
||||
payload, err = await _post_taobao(public_params)
|
||||
if err:
|
||||
return err
|
||||
|
||||
inner = payload.get("item_get_response") or {}
|
||||
code = inner.get("code")
|
||||
if code and int(code) != 0:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"Taobao code={code} msg={inner.get('msg', '')} sub={inner.get('sub_msg', '')}",
|
||||
channel="taobao",
|
||||
)
|
||||
item = inner.get("item") or {}
|
||||
if not item:
|
||||
return UnifiedResult(status="error", error=f"num_iid {num_iid} not found", channel="taobao")
|
||||
return UnifiedResult(
|
||||
status="success",
|
||||
data={
|
||||
"title": item.get("title") or f"item {num_iid}",
|
||||
"price": item.get("promotion_price") or item.get("price"),
|
||||
"currency": "CNY",
|
||||
"url": item.get("detail_url") or f"https://item.taobao.com/item.htm?id={num_iid}",
|
||||
"image": item.get("pic_url"),
|
||||
"in_stock": (item.get("num") or 0) > 0,
|
||||
"sales": item.get("sales"),
|
||||
},
|
||||
channel="taobao",
|
||||
)
|
||||
|
||||
|
||||
async def _tbk_search(app_key: str, app_secret: str, session_key: str | None,
|
||||
adzone_id: str, site_id: str | None, keyword: str) -> UnifiedResult:
|
||||
public_params = {
|
||||
"method": TBK_SEARCH_METHOD,
|
||||
"app_key": app_key,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": "2.0",
|
||||
"sign_method": "md5",
|
||||
"q": keyword,
|
||||
"adzone_id": str(adzone_id),
|
||||
"page_size": "3",
|
||||
"sort": "total_sales_des",
|
||||
}
|
||||
if session_key:
|
||||
public_params["session"] = session_key
|
||||
if site_id:
|
||||
public_params["site_id"] = str(site_id)
|
||||
public_params["sign"] = _md5_sign(app_secret, public_params)
|
||||
payload, err = await _post_taobao(public_params)
|
||||
if err:
|
||||
return err
|
||||
|
||||
inner = payload.get("tbk_item_search_response") or {}
|
||||
code = inner.get("code")
|
||||
if code and int(code) != 0:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"Taobao code={code} msg={inner.get('msg', '')} sub={inner.get('sub_msg', '')}",
|
||||
channel="taobao",
|
||||
)
|
||||
results = (inner.get("results") or {}).get("n_results") or []
|
||||
if not results:
|
||||
results = inner.get("result_list") or inner.get("results") or []
|
||||
if not results:
|
||||
return UnifiedResult(status="error", error=f"no items for keyword '{keyword}'", channel="taobao")
|
||||
items = []
|
||||
for r in results[:3]:
|
||||
if isinstance(r, dict) and "item" in r:
|
||||
r = r["item"]
|
||||
items.append({
|
||||
"title": r.get("title") or "(无标题)",
|
||||
"price": r.get("zk_final_price") or r.get("price") or r.get("reserve_price"),
|
||||
"currency": "CNY",
|
||||
"url": r.get("item_url") or r.get("url") or r.get("click_url") or "",
|
||||
"image": r.get("pict_url") or r.get("pic_url"),
|
||||
})
|
||||
return UnifiedResult(status="success", data=items, channel="taobao")
|
||||
|
||||
|
||||
async def _post_taobao(public_params: dict) -> tuple[dict | None, UnifiedResult | None]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
API_URL,
|
||||
data=urlencode(public_params),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json(), None
|
||||
except httpx.HTTPStatusError as e:
|
||||
return None, UnifiedResult(
|
||||
status="error",
|
||||
error=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
||||
channel="taobao",
|
||||
)
|
||||
except Exception as e:
|
||||
return None, UnifiedResult(status="error", error=str(e)[:200], channel="taobao")
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""TikTok/Douyin (抖音) open platform adapter.
|
||||
|
||||
Endpoint: https://open.douyin.com/ (multiple paths)
|
||||
Auth: client_key + client_secret + access_token (OAuth 2.0)
|
||||
Sign: HMAC-SHA256 (different from MD5 platforms)
|
||||
Method: /goods/detail (by goods_id) -- requires video/goods scope
|
||||
|
||||
cred shape:
|
||||
{"client_key": "...", "client_secret": "...", "access_token": "..."}
|
||||
|
||||
query shape:
|
||||
{"goods_id": "12345"} -> /goods/detail
|
||||
{"sku": "12345"} -> alias
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import UnifiedResult
|
||||
|
||||
log = logging.getLogger("chathub.gateway.tiktok")
|
||||
|
||||
OAUTH_TOKEN_URL = "https://open.douyin.com/oauth/access_token/"
|
||||
GOODS_DETAIL_URL = "https://open.douyin.com/goods/detail"
|
||||
|
||||
|
||||
def _hmac_sign(secret: str, message: str) -> str:
|
||||
"""Douyin HMAC-SHA256 hex (lowercase)."""
|
||||
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
async def fetch(creds: dict, query: dict) -> UnifiedResult:
|
||||
goods_id = query.get("goods_id") or query.get("sku")
|
||||
|
||||
client_key = creds.get("client_key") or creds.get("app_id") or creds.get("app_key")
|
||||
client_secret = creds.get("client_secret") or creds.get("app_secret")
|
||||
access_token = creds.get("access_token") or creds.get("refresh_token")
|
||||
|
||||
if not client_key or not client_secret:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing client_key/client_secret (set them via channelAuth)",
|
||||
channel="tiktok",
|
||||
)
|
||||
if not access_token:
|
||||
return UnifiedResult(
|
||||
status="no_creds",
|
||||
error="missing access_token (obtain via 抖音 OAuth authorization; 2hr TTL, refresh via refresh_token)",
|
||||
channel="tiktok",
|
||||
)
|
||||
if not goods_id:
|
||||
return UnifiedResult(status="error", error="missing goods_id", channel="tiktok")
|
||||
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
"goods_id": str(goods_id),
|
||||
"app_id": client_key,
|
||||
}
|
||||
param_json = json.dumps({"goods_id": str(goods_id)}, ensure_ascii=False, separators=(",", ":"))
|
||||
base_string = f"app_id={client_key}&goods_id={goods_id}&access_token={access_token}"
|
||||
signature = _hmac_sign(client_secret, base_string)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
GOODS_DETAIL_URL,
|
||||
params={"access_token": access_token, "app_id": client_key, "goods_id": str(goods_id), "sign": signature},
|
||||
)
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
||||
channel="tiktok",
|
||||
)
|
||||
except Exception as e:
|
||||
return UnifiedResult(status="error", error=str(e)[:200], channel="tiktok")
|
||||
|
||||
err_code = str(payload.get("err_no", payload.get("code", "")))
|
||||
if err_code not in ("", "0"):
|
||||
return UnifiedResult(
|
||||
status="error",
|
||||
error=f"Douyin err_no={err_code} msg={payload.get('message', payload.get('errmsg', ''))}",
|
||||
channel="tiktok",
|
||||
)
|
||||
|
||||
data = payload.get("data") or payload.get("goods_detail") or {}
|
||||
if not data:
|
||||
return UnifiedResult(status="error", error=f"goods_id {goods_id} not found", channel="tiktok")
|
||||
|
||||
price = data.get("price") or data.get("min_price")
|
||||
if price and isinstance(price, str):
|
||||
try:
|
||||
price = float(price) / 100
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return UnifiedResult(
|
||||
status="success",
|
||||
data={
|
||||
"title": data.get("title") or data.get("goods_name") or f"goods {goods_id}",
|
||||
"price": price,
|
||||
"currency": "CNY",
|
||||
"url": data.get("share_url") or data.get("detail_url") or f"https://haohuo.jinritemai.com/GoodsDetail?goods_id={goods_id}",
|
||||
"image": (data.get("cover") or {}).get("url") if isinstance(data.get("cover"), dict) else data.get("cover"),
|
||||
"in_stock": (data.get("stock") or 0) > 0,
|
||||
"sales": data.get("sales"),
|
||||
},
|
||||
channel="tiktok",
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Inboxes Config IO — shared by WS Agent & Provision Server
|
||||
|
||||
Provides stateless read/write/validate/construct primitives for
|
||||
Chatwoot inbox routing configuration (inboxes.json).
|
||||
|
||||
Usage:
|
||||
import inboxes_io
|
||||
cfg = inboxes_io.read_inboxes_raw(Path("inboxes.json"))
|
||||
entry = inboxes_io.build_inbox_entry(...)
|
||||
inboxes_io.write_inboxes(Path("inboxes.json"), cfg)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("inboxes_io")
|
||||
|
||||
# ── Default _meta block ──────────────────────────────────────────
|
||||
INBOXES_META = {
|
||||
"_meta": {
|
||||
"version": "1.2",
|
||||
"updated_at": None, # filled at write time
|
||||
"description": "Chatwoot WS Agent inbox routing config — hot-reloadable",
|
||||
},
|
||||
}
|
||||
|
||||
# ── Required field sets ──────────────────────────────────────────
|
||||
REQUIRED_ENTRY_KEYS = ["name", "target_agent", "system_prompt", "prompt_template"]
|
||||
TEMPLATE_PLACEHOLDERS = ["{sender_name}", "{customer_msg}"]
|
||||
|
||||
|
||||
def validate_entry(config: dict) -> bool:
|
||||
"""Validate a single inbox config entry.
|
||||
|
||||
Checks required keys exist and prompt_template contains
|
||||
the mandatory placeholders.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
log.warning("Config is not a dict: %s", type(config).__name__)
|
||||
return False
|
||||
for key in REQUIRED_ENTRY_KEYS:
|
||||
if key not in config:
|
||||
log.warning("Config missing required key '%s'", key)
|
||||
return False
|
||||
prompt = config.get("prompt_template", "")
|
||||
for ph in TEMPLATE_PLACEHOLDERS:
|
||||
if ph not in prompt:
|
||||
log.warning("prompt_template missing placeholder %s", ph)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_inboxes_raw(path: Path) -> dict:
|
||||
"""Read inboxes.json from *path*.
|
||||
|
||||
Returns the parsed dict, or a dict with just ``_meta`` if the
|
||||
file is missing. Never returns ``None``.
|
||||
"""
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.warning("Failed to read %s: %s", path, e)
|
||||
meta = dict(INBOXES_META)
|
||||
meta["_meta"] = dict(meta["_meta"])
|
||||
meta["_meta"]["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return meta
|
||||
|
||||
|
||||
def write_inboxes(path: Path, config: dict) -> None:
|
||||
"""Write *config* (dict) to *path* as pretty-printed JSON."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Always refresh _meta timestamp
|
||||
if "_meta" not in config or not isinstance(config["_meta"], dict):
|
||||
config["_meta"] = dict(INBOXES_META["_meta"])
|
||||
config["_meta"]["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
log.info("Inboxes config written to %s", path)
|
||||
|
||||
|
||||
def ensure_agent_workspace(base_dir: Path, agent_id: str, name: str = "") -> Path:
|
||||
"""Create workspace directory for a QwenPaw agent.
|
||||
|
||||
Returns the created Path.
|
||||
"""
|
||||
agent_dir = base_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:
|
||||
"""Build an inbox config entry dict for *inboxes.json*.
|
||||
|
||||
The entry includes a generic system_prompt and prompt_template
|
||||
that callers can override after receiving the 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",
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/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 threading
|
||||
import sys
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bottle
|
||||
|
||||
# ── Shared modules (same repo) ───────────────────────────────────
|
||||
# provision_server.py is in chatwoot-ai-agent/, so sibling import works
|
||||
import chatwoot_client
|
||||
import inboxes_io
|
||||
|
||||
# ── 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"
|
||||
|
||||
# Point chatwoot_client at the same auth file
|
||||
chatwoot_client.CW_AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
|
||||
# Enable CW_ADMIN_EMAIL/PASSWORD env var reading (chatwoot_client reads both CW_EMAIL and CW_ADMIN_EMAIL)
|
||||
chatwoot_client.CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
|
||||
chatwoot_client.CW_INTERNAL = os.environ.get("CW_INTERNAL", "http://chatwoot-chatwoot-1:3000")
|
||||
chatwoot_client.CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
|
||||
chatwoot_client.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 _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 = chatwoot_client._gen_password()
|
||||
display_name = name or email.split("@")[0]
|
||||
|
||||
user = chatwoot_client._call_internal(
|
||||
"POST",
|
||||
"/platform/api/v1/users",
|
||||
{"name": display_name, "email": email, "password": password},
|
||||
extra_headers={"api_access_token": chatwoot_client.CW_PLATFORM_TOKEN},
|
||||
)
|
||||
if not user.get("id"):
|
||||
raise RuntimeError(f"Platform API user creation failed: {user}")
|
||||
uid = user["id"]
|
||||
|
||||
try:
|
||||
agent = chatwoot_client._call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/agents",
|
||||
{"email": email, "name": display_name, "role": "agent"},
|
||||
)
|
||||
except Exception as e:
|
||||
# Rollback: delete the orphaned platform user
|
||||
try:
|
||||
chatwoot_client._call_internal(
|
||||
"DELETE",
|
||||
f"/platform/api/v1/users/{uid}",
|
||||
None,
|
||||
extra_headers={"api_access_token": chatwoot_client.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:
|
||||
chatwoot_client._call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{chatwoot_client.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)
|
||||
|
||||
|
||||
# ── 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 = chatwoot_client._call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{chatwoot_client.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 = chatwoot_client._call_cw(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{chatwoot_client.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", "")
|
||||
_emb_cw_base = chatwoot_client.CW_BASE
|
||||
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="{_emb_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:"{_emb_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 = inboxes_io.ensure_agent_workspace(WORKSPACE_DIR, agent_id, name)
|
||||
|
||||
# 6. Update inboxes.json
|
||||
config = inboxes_io.read_inboxes_raw(INBOXES_PATH)
|
||||
entry = inboxes_io.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")
|
||||
)
|
||||
inboxes_io.write_inboxes(INBOXES_PATH, 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."""
|
||||
chatwoot_client._call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{chatwoot_client.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 = chatwoot_client._call_cw("GET", f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes/{inbox_id}")
|
||||
ch = inbox.get("channel", {})
|
||||
if ch.get("type") == "Channel::WebWidget":
|
||||
chatwoot_client._call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{chatwoot_client.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 = inboxes_io.read_inboxes_raw(INBOXES_PATH)
|
||||
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")
|
||||
)
|
||||
inboxes_io.write_inboxes(INBOXES_PATH, 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:
|
||||
chatwoot_client._call_cw(
|
||||
"PUT",
|
||||
f"/api/v1/accounts/{chatwoot_client.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)
|
||||
@@ -0,0 +1,3 @@
|
||||
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 &
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Force correct env, then exec provision server
|
||||
unset CW_BASE CW_INTERNAL CW_PLATFORM_TOKEN CW_ADMIN_EMAIL CW_ADMIN_PASSWORD CW_ACCOUNT_ID
|
||||
export CW_BASE='http://chatwoot-chatwoot-1:3000'
|
||||
export CW_INTERNAL='http://chatwoot-chatwoot-1:3000'
|
||||
export CW_PLATFORM_TOKEN='csFwGySM0589tkhZHcLGJjfKLtYSgCGpcup9HSJZ9yE'
|
||||
export CW_ADMIN_EMAIL='qiuzhida@greatqiu.cn'
|
||||
export CW_ADMIN_PASSWORD='Qaly8980+'
|
||||
export CW_ACCOUNT_ID='1'
|
||||
export CHATHUB_API_KEY='chathub-default-key-change-me'
|
||||
export GATEWAY_AES_KEY='uUjrtW3+w/rlBmGBOPv6rn7mP264bnOefkiQE9EL+X8='
|
||||
export CHATHUB_DB_HOST='mysql'
|
||||
export CHATHUB_DB_PORT='3306'
|
||||
export CHATHUB_DB_USER='root'
|
||||
export CHATHUB_DB_PASS='mysql_Py5N2W'
|
||||
export CHATHUB_DB_NAME='chathub'
|
||||
cd /app/working/workspaces/wordpress
|
||||
exec python3 /app/working/workspaces/wordpress/provision_server.py 5566
|
||||
Reference in New Issue
Block a user