mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 21:57:14 +08:00
feat(feishu): one-click QR-scan app creation
This commit is contained in:
41
README.md
41
README.md
@@ -724,9 +724,15 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
<details>
|
<details>
|
||||||
<summary>3. Feishu - 飞书</summary>
|
<summary>3. Feishu - 飞书</summary>
|
||||||
|
|
||||||
飞书支持两种事件接收模式:WebSocket 长连接(推荐)和 Webhook。
|
飞书使用 WebSocket 长连接模式,无需公网 IP。详细步骤参考 [飞书接入](https://docs.cowagent.ai/channels/feishu)。
|
||||||
|
|
||||||
**方式一:WebSocket 模式(默认,无需公网 IP)**
|
**方式一:扫码一键创建(推荐)**
|
||||||
|
|
||||||
|
启动 Cow 后打开 Web 控制台,**通道** → **接入通道** → 选择 **飞书** → 扫码创建。也支持 CLI 启动时在终端打印二维码。
|
||||||
|
|
||||||
|
**方式二:手动配置**
|
||||||
|
|
||||||
|
在飞书开放平台创建自建应用并配置权限后,将凭据填入 `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -737,25 +743,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式二:Webhook 模式(需要公网 IP)**
|
- `feishu_stream_reply`:是否开启流式打字机回复,默认开启(需 `cardkit:card:write` 权限 + 飞书客户端 ≥ 7.20)
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channel_type": "feishu",
|
|
||||||
"feishu_app_id": "APP_ID",
|
|
||||||
"feishu_app_secret": "APP_SECRET",
|
|
||||||
"feishu_token": "VERIFICATION_TOKEN",
|
|
||||||
"feishu_event_mode": "webhook",
|
|
||||||
"feishu_port": 9891,
|
|
||||||
"feishu_stream_reply": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `feishu_event_mode`: 事件接收模式,`websocket`(推荐)或 `webhook`
|
|
||||||
- `feishu_stream_reply`: 是否开启流式打字机回复,需开通 `cardkit:card:write` 权限且飞书客户端 ≥ 7.20
|
|
||||||
- WebSocket 模式需安装依赖:`pip3 install lark-oapi`
|
|
||||||
|
|
||||||
详细步骤和参数说明参考 [飞书接入](https://docs.cowagent.ai/channels/feishu)
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -777,7 +765,15 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
<details>
|
<details>
|
||||||
<summary>5. WeCom Bot - 企微智能机器人</summary>
|
<summary>5. WeCom Bot - 企微智能机器人</summary>
|
||||||
|
|
||||||
企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,配置简单:
|
企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名。详细步骤参考 [企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。
|
||||||
|
|
||||||
|
**方式一:扫码一键创建(推荐)**
|
||||||
|
|
||||||
|
启动 Cow 后打开 Web 控制台,**通道** → **接入通道** → 选择 **企微智能机器人** → 使用企业微信扫码创建。
|
||||||
|
|
||||||
|
**方式二:手动配置**
|
||||||
|
|
||||||
|
在企业微信中创建智能机器人并选择**长连接模式**,记录 Bot ID 和 Secret 后填入 `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -786,7 +782,6 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
"wecom_bot_secret": "YOUR_SECRET"
|
"wecom_bot_secret": "YOUR_SECRET"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
详细步骤和参数说明参考 [企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,176 @@ def _ensure_lark_imported():
|
|||||||
return lark
|
return lark
|
||||||
|
|
||||||
|
|
||||||
|
def _print_qr_to_terminal(qr_url: str):
|
||||||
|
"""Render a QR code as ASCII art and emit it via logger.
|
||||||
|
|
||||||
|
走 logger 而非 print 是为了避免 nohup/cow 后台启动场景下 stdout 块缓冲导致
|
||||||
|
二维码滞后输出(看起来像出现了两次)。logger 的 StreamHandler 是行缓冲,
|
||||||
|
既能在前台终端看到,也能进 run.log。
|
||||||
|
"""
|
||||||
|
qr_lines = []
|
||||||
|
try:
|
||||||
|
import qrcode as qr_lib
|
||||||
|
import io
|
||||||
|
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
|
||||||
|
qr.add_data(qr_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
buf = io.StringIO()
|
||||||
|
qr.print_ascii(out=buf, invert=True)
|
||||||
|
qr_lines = buf.getvalue().splitlines()
|
||||||
|
except ImportError:
|
||||||
|
qr_lines = ["(未安装 qrcode 包,无法渲染 ASCII 二维码:pip install qrcode)"]
|
||||||
|
except Exception as e:
|
||||||
|
qr_lines = [f"(渲染二维码失败:{e})"]
|
||||||
|
|
||||||
|
header = "=" * 60
|
||||||
|
banner = [
|
||||||
|
"",
|
||||||
|
header,
|
||||||
|
" 飞书一键创建应用:请使用 飞书 App 扫描下方二维码",
|
||||||
|
" (二维码 10 分钟内有效,仅供一次扫描)",
|
||||||
|
header,
|
||||||
|
]
|
||||||
|
footer = [
|
||||||
|
f" 或点击链接创建: {qr_url}",
|
||||||
|
" 等待扫码...",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
full = banner + qr_lines + footer
|
||||||
|
logger.info("[FeiShu] One-click 飞书应用创建二维码(请用飞书 App 扫码):\n" + "\n".join(full))
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_feishu_credentials(app_id: str, app_secret: str) -> bool:
|
||||||
|
"""Write feishu_app_id / feishu_app_secret + ensure feishu in channel_type into config.json.
|
||||||
|
|
||||||
|
Returns True on success, False on failure (e.g. config.json missing or unwritable).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"config.json",
|
||||||
|
)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
file_cfg = json.load(f)
|
||||||
|
else:
|
||||||
|
file_cfg = {}
|
||||||
|
|
||||||
|
file_cfg["feishu_app_id"] = app_id
|
||||||
|
file_cfg["feishu_app_secret"] = app_secret
|
||||||
|
|
||||||
|
# 保证 channel_type 中包含 feishu(用户可能纯通过 CLI 启动单通道)
|
||||||
|
ch_type = file_cfg.get("channel_type", conf().get("channel_type", "")) or ""
|
||||||
|
existing = [s.strip() for s in ch_type.split(",") if s.strip()]
|
||||||
|
if "feishu" not in existing:
|
||||||
|
existing.append("feishu")
|
||||||
|
file_cfg["channel_type"] = ",".join(existing)
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 同步到内存中的 conf(),让本次启动直接生效
|
||||||
|
conf()["feishu_app_id"] = app_id
|
||||||
|
conf()["feishu_app_secret"] = app_secret
|
||||||
|
if "channel_type" in file_cfg:
|
||||||
|
conf()["channel_type"] = file_cfg["channel_type"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(config_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] Failed to persist credentials to config.json: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _register_via_qr_in_terminal() -> bool:
|
||||||
|
"""CLI-side one-click app creation via lark_oapi.register_app.
|
||||||
|
|
||||||
|
Blocks the calling thread (typically the channel startup thread) until the user
|
||||||
|
finishes scanning, the QR code expires, or registration is cancelled.
|
||||||
|
|
||||||
|
Returns True if credentials were obtained AND persisted; False otherwise.
|
||||||
|
The caller should fall back to the original "missing credentials" error in that case.
|
||||||
|
"""
|
||||||
|
if not LARK_SDK_AVAILABLE:
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] 缺少 feishu_app_id / feishu_app_secret。"
|
||||||
|
"未安装 lark-oapi SDK,无法在终端发起扫码创建。"
|
||||||
|
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
lark_mod = _ensure_lark_imported()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] Import lark_oapi failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# register_app 是 lark-oapi 1.5.5 才引入的能力,旧版本调用会得到难以理解的
|
||||||
|
# AttributeError。提前显式检查,给出明确的升级提示。
|
||||||
|
if not hasattr(lark_mod, "register_app"):
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
installed = _pkg_version("lark-oapi")
|
||||||
|
except Exception:
|
||||||
|
installed = "unknown"
|
||||||
|
logger.error(
|
||||||
|
f"[FeiShu] 当前 lark-oapi 版本 ({installed}) 不支持一键创建应用,需要 >= 1.5.5。"
|
||||||
|
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("[FeiShu] 检测到尚未配置 feishu_app_id / feishu_app_secret,"
|
||||||
|
"正在向飞书申请一键创建应用...")
|
||||||
|
|
||||||
|
def _on_qr(info):
|
||||||
|
url = info.get("url", "")
|
||||||
|
if url:
|
||||||
|
_print_qr_to_terminal(url)
|
||||||
|
|
||||||
|
def _on_status(info):
|
||||||
|
# 过滤 polling 心跳(每 5 秒一次),保留 slow_down / domain_switched 等
|
||||||
|
status = info.get("status")
|
||||||
|
if status == "polling":
|
||||||
|
return
|
||||||
|
logger.info(f"[FeiShu] register_app status: {info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = lark_mod.register_app(
|
||||||
|
on_qr_code=_on_qr,
|
||||||
|
on_status_change=_on_status,
|
||||||
|
source="cowagent",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
err_cls = e.__class__.__name__
|
||||||
|
if "Expired" in err_cls:
|
||||||
|
logger.error("[FeiShu] 二维码已过期,请重启程序后重试。")
|
||||||
|
elif "Denied" in err_cls:
|
||||||
|
logger.error("[FeiShu] 已取消授权。")
|
||||||
|
else:
|
||||||
|
logger.error(f"[FeiShu] 一键创建失败:{e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
app_id = result.get("client_id", "")
|
||||||
|
app_secret = result.get("client_secret", "")
|
||||||
|
if not app_id or not app_secret:
|
||||||
|
logger.error("[FeiShu] 创建结果缺少 app_id/app_secret,无法继续。")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not _persist_feishu_credentials(app_id, app_secret):
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] 应用创建成功但写入 config.json 失败,请手动复制以下值到配置文件:\n"
|
||||||
|
f" feishu_app_id = {app_id}\n"
|
||||||
|
f" feishu_app_secret = {app_secret}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"[FeiShu] 应用创建成功,凭据已写入 config.json (app_id={app_id})。")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@singleton
|
@singleton
|
||||||
class FeiShuChanel(ChatChannel):
|
class FeiShuChanel(ChatChannel):
|
||||||
feishu_app_id = conf().get('feishu_app_id')
|
feishu_app_id = conf().get('feishu_app_id')
|
||||||
@@ -90,6 +260,20 @@ class FeiShuChanel(ChatChannel):
|
|||||||
self.feishu_app_secret = conf().get('feishu_app_secret')
|
self.feishu_app_secret = conf().get('feishu_app_secret')
|
||||||
self.feishu_token = conf().get('feishu_token')
|
self.feishu_token = conf().get('feishu_token')
|
||||||
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
|
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
|
||||||
|
|
||||||
|
# 命令行启动场景:缺少凭据时尝试通过 lark.register_app 在终端弹二维码
|
||||||
|
# 引导用户扫码创建应用。Web 控制台启动同样会走到这里,但控制台用户通常
|
||||||
|
# 已经通过 /api/feishu/register 完成了创建并写回 config.json。
|
||||||
|
if not self.feishu_app_id or not self.feishu_app_secret:
|
||||||
|
if _register_via_qr_in_terminal():
|
||||||
|
self.feishu_app_id = conf().get('feishu_app_id')
|
||||||
|
self.feishu_app_secret = conf().get('feishu_app_secret')
|
||||||
|
else:
|
||||||
|
err = "[FeiShu] feishu_app_id 与 feishu_app_secret 缺失,无法启动通道"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
self._fetch_bot_open_id()
|
self._fetch_bot_open_id()
|
||||||
if self.feishu_event_mode == 'websocket':
|
if self.feishu_event_mode == 'websocket':
|
||||||
self._startup_websocket()
|
self._startup_websocket()
|
||||||
|
|||||||
@@ -78,6 +78,19 @@ const I18N = {
|
|||||||
wecom_scan_success: '创建成功,正在启动通道...',
|
wecom_scan_success: '创建成功,正在启动通道...',
|
||||||
wecom_scan_fail: '创建失败',
|
wecom_scan_fail: '创建失败',
|
||||||
wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写',
|
wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写',
|
||||||
|
feishu_scan_btn: '一键创建飞书应用',
|
||||||
|
feishu_scan_desc: '使用飞书 App 扫码,自动创建应用并预置全部权限与事件订阅',
|
||||||
|
feishu_scan_replace_desc: '使用飞书 App 扫码创建新机器人,将覆盖当前的 App ID / Secret',
|
||||||
|
feishu_scan_loading: '正在向飞书申请二维码...',
|
||||||
|
feishu_scan_waiting: '等待扫码...',
|
||||||
|
feishu_scan_tip: '二维码 10 分钟内有效,仅供一次扫描',
|
||||||
|
feishu_scan_open_link: '或点击此处在浏览器中打开',
|
||||||
|
feishu_scan_success: '应用创建成功,正在启动通道...',
|
||||||
|
feishu_scan_expired: '二维码已过期,请重试',
|
||||||
|
feishu_scan_denied: '已取消授权',
|
||||||
|
feishu_scan_fail: '创建失败',
|
||||||
|
feishu_scan_retry: '重试',
|
||||||
|
feishu_mode_scan: '扫码创建', feishu_mode_manual: '手动填写',
|
||||||
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||||
@@ -164,6 +177,19 @@ const I18N = {
|
|||||||
wecom_scan_success: 'Bot created, starting channel...',
|
wecom_scan_success: 'Bot created, starting channel...',
|
||||||
wecom_scan_fail: 'Bot creation failed',
|
wecom_scan_fail: 'Bot creation failed',
|
||||||
wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual',
|
wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual',
|
||||||
|
feishu_scan_btn: 'One-click Create Feishu App',
|
||||||
|
feishu_scan_desc: 'Scan with Feishu App to create an app with all required permissions pre-configured',
|
||||||
|
feishu_scan_replace_desc: 'Scan with Feishu App to create a new bot — will overwrite the current App ID / Secret',
|
||||||
|
feishu_scan_loading: 'Requesting QR code from Feishu...',
|
||||||
|
feishu_scan_waiting: 'Waiting for scan...',
|
||||||
|
feishu_scan_tip: 'QR code expires in 10 minutes, single use only',
|
||||||
|
feishu_scan_open_link: 'Or click here to open in browser',
|
||||||
|
feishu_scan_success: 'App created, starting channel...',
|
||||||
|
feishu_scan_expired: 'QR code expired, please retry',
|
||||||
|
feishu_scan_denied: 'Authorization cancelled',
|
||||||
|
feishu_scan_fail: 'App creation failed',
|
||||||
|
feishu_scan_retry: 'Retry',
|
||||||
|
feishu_mode_scan: 'Scan QR', feishu_mode_manual: 'Manual',
|
||||||
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||||
@@ -2999,6 +3025,8 @@ function renderActiveChannels() {
|
|||||||
|
|
||||||
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
|
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
|
||||||
const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
|
const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
|
||||||
|
// 飞书 active 卡片渲染带 Tab 的 panel:手动填写 + 扫码重建(覆盖现有配置)
|
||||||
|
const isFeishu = ch.name === 'feishu';
|
||||||
let statusDot, statusText;
|
let statusDot, statusText;
|
||||||
if (weixinWaiting) {
|
if (weixinWaiting) {
|
||||||
statusDot = 'bg-amber-400 animate-pulse';
|
statusDot = 'bg-amber-400 animate-pulse';
|
||||||
@@ -3014,7 +3042,7 @@ function renderActiveChannels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds ? ' mb-5' : ''}">
|
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds || isFeishu ? ' mb-5' : ''}">
|
||||||
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -3050,7 +3078,7 @@ function renderActiveChannels() {
|
|||||||
</button>
|
</button>
|
||||||
<div id="wecom-card-scan-status" class="mt-3"></div>
|
<div id="wecom-card-scan-status" class="mt-3"></div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
${hasFields ? `<div class="space-y-4">
|
${isFeishu ? buildFeishuPanel(ch, true) : (hasFields ? `<div class="space-y-4">
|
||||||
${fieldsHtml}
|
${fieldsHtml}
|
||||||
<div class="flex items-center justify-end gap-3 pt-1">
|
<div class="flex items-center justify-end gap-3 pt-1">
|
||||||
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||||
@@ -3059,7 +3087,7 @@ function renderActiveChannels() {
|
|||||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
id="ch-save-${ch.name}">${t('channels_save')}</button>
|
id="ch-save-${ch.name}">${t('channels_save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}`;
|
</div>` : '')}`;
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
bindSecretFieldEvents(card);
|
bindSecretFieldEvents(card);
|
||||||
@@ -3256,6 +3284,7 @@ function openAddChannelPanel() {
|
|||||||
|
|
||||||
function closeAddChannelPanel() {
|
function closeAddChannelPanel() {
|
||||||
stopWeixinQrPoll();
|
stopWeixinQrPoll();
|
||||||
|
stopFeishuRegisterPoll();
|
||||||
const panel = document.getElementById('channels-add-panel');
|
const panel = document.getElementById('channels-add-panel');
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.classList.add('hidden');
|
panel.classList.add('hidden');
|
||||||
@@ -3267,6 +3296,7 @@ function closeAddChannelPanel() {
|
|||||||
|
|
||||||
function onAddChannelSelect(chName) {
|
function onAddChannelSelect(chName) {
|
||||||
stopWeixinQrPoll();
|
stopWeixinQrPoll();
|
||||||
|
stopFeishuRegisterPoll();
|
||||||
const fieldsContainer = document.getElementById('add-channel-fields');
|
const fieldsContainer = document.getElementById('add-channel-fields');
|
||||||
const actions = document.getElementById('add-channel-actions');
|
const actions = document.getElementById('add-channel-actions');
|
||||||
|
|
||||||
@@ -3293,6 +3323,13 @@ function onAddChannelSelect(chName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chName === 'feishu') {
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
const ch = channelsData.find(c => c.name === chName);
|
||||||
|
fieldsContainer.innerHTML = buildFeishuPanel(ch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ch = channelsData.find(c => c.name === chName);
|
const ch = channelsData.find(c => c.name === chName);
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
|
|
||||||
@@ -3690,15 +3727,246 @@ function startWecomBotAuthInCard() {
|
|||||||
// Initialize wecom bot panel with correct default mode when inserted into DOM
|
// Initialize wecom bot panel with correct default mode when inserted into DOM
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const observer = new MutationObserver(function() {
|
const observer = new MutationObserver(function() {
|
||||||
const panel = document.getElementById('wecom-bot-panel');
|
const wecomPanel = document.getElementById('wecom-bot-panel');
|
||||||
if (panel && !panel.dataset.initialized) {
|
if (wecomPanel && !wecomPanel.dataset.initialized) {
|
||||||
panel.dataset.initialized = '1';
|
wecomPanel.dataset.initialized = '1';
|
||||||
switchWecomBotMode(panel.dataset.defaultMode || 'scan');
|
switchWecomBotMode(wecomPanel.dataset.defaultMode || 'scan');
|
||||||
|
}
|
||||||
|
const feishuPanel = document.getElementById('feishu-panel');
|
||||||
|
if (feishuPanel && !feishuPanel.dataset.initialized) {
|
||||||
|
feishuPanel.dataset.initialized = '1';
|
||||||
|
switchFeishuMode(feishuPanel.dataset.defaultMode || 'scan');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Feishu One-click App Registration (lark-oapi register_app)
|
||||||
|
// =====================================================================
|
||||||
|
let _feishuRegisterPollTimer = null;
|
||||||
|
|
||||||
|
function _feishuHasCreds(ch) {
|
||||||
|
if (!ch || !ch.fields) return false;
|
||||||
|
const idField = ch.fields.find(f => f.key === 'feishu_app_id');
|
||||||
|
const secretField = ch.fields.find(f => f.key === 'feishu_app_secret');
|
||||||
|
return !!(idField && idField.value && secretField && secretField.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeishuPanel(ch, isActive) {
|
||||||
|
const scanLabel = t('feishu_mode_scan');
|
||||||
|
const manualLabel = t('feishu_mode_manual');
|
||||||
|
// 已有凭据时默认进入手动 Tab,方便修改;否则推荐扫码
|
||||||
|
const defaultMode = _feishuHasCreds(ch) ? 'manual' : 'scan';
|
||||||
|
const activeAttr = isActive ? 'data-active="1"' : '';
|
||||||
|
return `
|
||||||
|
<div id="feishu-panel" data-default-mode="${defaultMode}" ${activeAttr}>
|
||||||
|
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
|
||||||
|
<button id="feishu-tab-scan" onclick="switchFeishuMode('scan')"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||||
|
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
|
||||||
|
${scanLabel}
|
||||||
|
</button>
|
||||||
|
<button id="feishu-tab-manual" onclick="switchFeishuMode('manual')"
|
||||||
|
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||||
|
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||||||
|
${manualLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="feishu-mode-content"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchFeishuMode(mode) {
|
||||||
|
const panel = document.getElementById('feishu-panel');
|
||||||
|
const scanTab = document.getElementById('feishu-tab-scan');
|
||||||
|
const manualTab = document.getElementById('feishu-tab-manual');
|
||||||
|
const content = document.getElementById('feishu-mode-content');
|
||||||
|
if (!scanTab || !manualTab || !content) return;
|
||||||
|
|
||||||
|
// 已激活通道卡片中嵌入此 panel 时,没有 add-channel-actions(保存按钮就近渲染)
|
||||||
|
const isActive = panel && panel.dataset.active === '1';
|
||||||
|
const actions = isActive ? null : document.getElementById('add-channel-actions');
|
||||||
|
|
||||||
|
const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm';
|
||||||
|
const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200';
|
||||||
|
|
||||||
|
stopFeishuRegisterPoll();
|
||||||
|
|
||||||
|
if (mode === 'scan') {
|
||||||
|
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
|
||||||
|
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
|
||||||
|
if (actions) actions.classList.add('hidden');
|
||||||
|
// active 卡片下扫码替换的提示文案,强调"创建新机器人会覆盖现有配置"
|
||||||
|
const desc = isActive
|
||||||
|
? t('feishu_scan_replace_desc')
|
||||||
|
: t('feishu_scan_desc');
|
||||||
|
content.innerHTML = `
|
||||||
|
<div id="feishu-scan-panel" class="flex flex-col items-center py-4">
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-300 mb-3 text-center">${desc}</p>
|
||||||
|
<button onclick="startFeishuRegister()"
|
||||||
|
class="mt-2 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-qrcode mr-2"></i>${t('feishu_scan_btn')}
|
||||||
|
</button>
|
||||||
|
<div id="feishu-scan-status" class="mt-4 w-full"></div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
|
||||||
|
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
|
||||||
|
const ch = channelsData.find(c => c.name === 'feishu');
|
||||||
|
const fieldsHtml = buildChannelFieldsHtml('feishu', ch ? ch.fields || [] : []);
|
||||||
|
if (isActive) {
|
||||||
|
// 已接入卡片:内置保存按钮,复用 saveChannelConfig 走 update 流程
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
${fieldsHtml}
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<span id="ch-status-feishu" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||||
|
<button onclick="saveChannelConfig('feishu')"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
id="ch-save-feishu">${t('channels_save')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
content.innerHTML = `<div class="space-y-4">${fieldsHtml}</div>`;
|
||||||
|
if (actions) actions.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
bindSecretFieldEvents(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFeishuRegisterPoll() {
|
||||||
|
if (_feishuRegisterPollTimer) {
|
||||||
|
clearTimeout(_feishuRegisterPollTimer);
|
||||||
|
_feishuRegisterPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFeishuRegister(targetStatusId) {
|
||||||
|
const statusId = targetStatusId || 'feishu-scan-status';
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('feishu_scan_loading')}</p>`;
|
||||||
|
}
|
||||||
|
stopFeishuRegisterPoll();
|
||||||
|
fetch('/api/feishu/register')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderFeishuQr(statusId, data.qr_image, data.qrcode_url);
|
||||||
|
pollFeishuRegisterStatus(statusId);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
renderFeishuRegisterError(statusId, err.message || t('feishu_scan_fail'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeishuQr(statusId, qrImage, qrUrl) {
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (!statusEl) return;
|
||||||
|
const imgHtml = qrImage
|
||||||
|
? `<img src="${qrImage}" alt="QR" class="w-44 h-44 rounded-lg border border-slate-200 dark:border-white/10 bg-white p-2"/>`
|
||||||
|
: `<div class="w-44 h-44 rounded-lg border border-dashed border-slate-300 flex items-center justify-center text-xs text-slate-400">QR</div>`;
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
${imgHtml}
|
||||||
|
<p class="text-xs text-amber-500">${t('feishu_scan_waiting')}</p>
|
||||||
|
<p class="text-xs text-slate-400 dark:text-slate-500">${t('feishu_scan_tip')}</p>
|
||||||
|
${qrUrl ? `<a href="${qrUrl}" target="_blank" rel="noopener"
|
||||||
|
class="text-xs text-blue-500 hover:text-blue-600 underline">${t('feishu_scan_open_link')}</a>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeishuRegisterError(statusId, message) {
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center gap-2 py-2">
|
||||||
|
<p class="text-sm text-red-500 text-center">${message}</p>
|
||||||
|
<button onclick="startFeishuRegister('${statusId}')"
|
||||||
|
class="mt-1 px-4 py-1.5 rounded-md text-xs font-medium
|
||||||
|
bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-200
|
||||||
|
hover:bg-slate-200 dark:hover:bg-white/20 cursor-pointer">
|
||||||
|
<i class="fas fa-rotate-right mr-1"></i>${t('feishu_scan_retry')}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollFeishuRegisterStatus(statusId) {
|
||||||
|
stopFeishuRegisterPoll();
|
||||||
|
_feishuRegisterPollTimer = setTimeout(() => {
|
||||||
|
fetch('/api/feishu/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'poll' })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rs = data.register_status;
|
||||||
|
if (rs === 'done') {
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center py-2">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||||||
|
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('feishu_scan_success')}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
connectFeishuAfterRegister(data.app_id, data.app_secret);
|
||||||
|
} else if (rs === 'expired') {
|
||||||
|
renderFeishuRegisterError(statusId, t('feishu_scan_expired'));
|
||||||
|
} else if (rs === 'denied') {
|
||||||
|
renderFeishuRegisterError(statusId, t('feishu_scan_denied'));
|
||||||
|
} else if (rs === 'error') {
|
||||||
|
renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail'));
|
||||||
|
} else {
|
||||||
|
pollFeishuRegisterStatus(statusId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pollFeishuRegisterStatus(statusId);
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectFeishuAfterRegister(appId, appSecret) {
|
||||||
|
fetch('/api/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'connect',
|
||||||
|
channel: 'feishu',
|
||||||
|
config: { feishu_app_id: appId, feishu_app_secret: appSecret }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
const ch = channelsData.find(c => c.name === 'feishu');
|
||||||
|
if (ch) {
|
||||||
|
ch.active = true;
|
||||||
|
(ch.fields || []).forEach(f => {
|
||||||
|
if (f.key === 'feishu_app_id') f.value = appId;
|
||||||
|
if (f.key === 'feishu_app_secret') f.value = ChannelsHandler_maskSecret(appSecret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(() => renderActiveChannels(), 1500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Scheduler View
|
// Scheduler View
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -575,6 +575,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/config', 'ConfigHandler',
|
'/config', 'ConfigHandler',
|
||||||
'/api/channels', 'ChannelsHandler',
|
'/api/channels', 'ChannelsHandler',
|
||||||
'/api/weixin/qrlogin', 'WeixinQrHandler',
|
'/api/weixin/qrlogin', 'WeixinQrHandler',
|
||||||
|
'/api/feishu/register', 'FeishuRegisterHandler',
|
||||||
'/api/tools', 'ToolsHandler',
|
'/api/tools', 'ToolsHandler',
|
||||||
'/api/skills', 'SkillsHandler',
|
'/api/skills', 'SkillsHandler',
|
||||||
'/api/memory', 'MemoryHandler',
|
'/api/memory', 'MemoryHandler',
|
||||||
@@ -1034,8 +1035,6 @@ class ChannelsHandler:
|
|||||||
"fields": [
|
"fields": [
|
||||||
{"key": "feishu_app_id", "label": "App ID", "type": "text"},
|
{"key": "feishu_app_id", "label": "App ID", "type": "text"},
|
||||||
{"key": "feishu_app_secret", "label": "App Secret", "type": "secret"},
|
{"key": "feishu_app_secret", "label": "App Secret", "type": "secret"},
|
||||||
{"key": "feishu_token", "label": "Verification Token", "type": "secret"},
|
|
||||||
{"key": "feishu_bot_name", "label": "Bot Name", "type": "text"},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
("dingtalk", {
|
("dingtalk", {
|
||||||
@@ -1530,6 +1529,174 @@ class WeixinQrHandler:
|
|||||||
return json.dumps({"status": "success", "qr_status": qr_status})
|
return json.dumps({"status": "success", "qr_status": qr_status})
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuRegisterHandler:
|
||||||
|
"""飞书智能体应用一键创建(OAuth 设备授权流,基于 lark.register_app SDK)。
|
||||||
|
|
||||||
|
GET /api/feishu/register → 启动注册:调用 SDK 生成二维码 URL,立即返回;
|
||||||
|
后台线程继续轮询飞书侧直到用户扫码授权。
|
||||||
|
POST /api/feishu/register → 轮询当前会话状态(pending / done / error / expired)。
|
||||||
|
注册成功后不直接写 config,由前端再调
|
||||||
|
/api/channels {action:'connect'} 走标准启用流程。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 进程内单例状态({url, expire_in, status, app_id, app_secret, error, thread})。
|
||||||
|
# 简单的本地自部署场景下不需要 session 隔离。
|
||||||
|
_state = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qr_to_data_uri(data: str) -> str:
|
||||||
|
"""复用 WeixinQrHandler 的二维码渲染。"""
|
||||||
|
return WeixinQrHandler._qr_to_data_uri(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reset_state(cls):
|
||||||
|
with cls._lock:
|
||||||
|
cls._state = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _start_register_thread(cls):
|
||||||
|
"""启动一次新的注册会话。如已有进行中的会话,先取消(通过 cancel_event)。"""
|
||||||
|
# 先取消可能存在的上一次会话,避免两个 SDK 线程并发 poll 同一个端点
|
||||||
|
with cls._lock:
|
||||||
|
old_cancel = cls._state.get("cancel_event") if cls._state else None
|
||||||
|
if old_cancel is not None:
|
||||||
|
old_cancel.set()
|
||||||
|
cancel_event = threading.Event()
|
||||||
|
cls._state = {"status": "starting", "cancel_event": cancel_event}
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
import lark_oapi as lark
|
||||||
|
except ImportError:
|
||||||
|
with cls._lock:
|
||||||
|
cls._state["status"] = "error"
|
||||||
|
cls._state["error"] = "lark-oapi SDK 未安装,请执行 pip install -U lark-oapi"
|
||||||
|
return
|
||||||
|
|
||||||
|
def _on_qr(info):
|
||||||
|
# SDK 拿到二维码 URL 后立即回调;写入 state 让前端 GET 立刻能拿到
|
||||||
|
with cls._lock:
|
||||||
|
cls._state["url"] = info.get("url", "")
|
||||||
|
cls._state["expire_in"] = info.get("expire_in", 600)
|
||||||
|
cls._state["qr_image"] = cls._qr_to_data_uri(info.get("url", ""))
|
||||||
|
cls._state["status"] = "pending"
|
||||||
|
logger.info(f"[FeishuRegister] QR ready, expire_in={info.get('expire_in')}s")
|
||||||
|
|
||||||
|
def _on_status(info):
|
||||||
|
# 过滤掉 polling 心跳(每 5 秒一次,纯噪音);
|
||||||
|
# 保留 slow_down / domain_switched 等真正的状态切换事件
|
||||||
|
status = info.get("status")
|
||||||
|
if status == "polling":
|
||||||
|
return
|
||||||
|
logger.info(f"[FeishuRegister] SDK status: {info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = lark.register_app(
|
||||||
|
on_qr_code=_on_qr,
|
||||||
|
on_status_change=_on_status,
|
||||||
|
source="cowagent",
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
)
|
||||||
|
with cls._lock:
|
||||||
|
cls._state["status"] = "done"
|
||||||
|
cls._state["app_id"] = result.get("client_id", "")
|
||||||
|
cls._state["app_secret"] = result.get("client_secret", "")
|
||||||
|
logger.info(f"[FeishuRegister] App created: app_id={result.get('client_id')}")
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = str(e)
|
||||||
|
err_cls = e.__class__.__name__
|
||||||
|
# 飞书 SDK 抛出的 AppExpiredError / AppAccessDeniedError / RegisterAppError
|
||||||
|
if "Expired" in err_cls:
|
||||||
|
status = "expired"
|
||||||
|
elif "Denied" in err_cls:
|
||||||
|
status = "denied"
|
||||||
|
elif "abort" in err_msg.lower() or "cancel" in err_msg.lower():
|
||||||
|
# 被新一轮注册抢占,保持安静
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
with cls._lock:
|
||||||
|
# 仅当当前 state 仍属于本次 worker 时才写入,避免覆盖更新的会话
|
||||||
|
if cls._state.get("cancel_event") is cancel_event:
|
||||||
|
cls._state["status"] = status
|
||||||
|
cls._state["error"] = err_msg
|
||||||
|
logger.warning(f"[FeishuRegister] Register failed ({err_cls}): {err_msg}")
|
||||||
|
|
||||||
|
threading.Thread(target=_worker, daemon=True, name="feishu-register").start()
|
||||||
|
|
||||||
|
def GET(self):
|
||||||
|
"""启动一次新的注册会话。如果已有 pending/done 会话则覆盖。"""
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
self._start_register_thread()
|
||||||
|
# 等待 SDK 拿到二维码 URL(最多 10s)。SDK 内部会马上回调 _on_qr。
|
||||||
|
import time as _t
|
||||||
|
for _ in range(100):
|
||||||
|
with self._lock:
|
||||||
|
if self._state.get("url") or self._state.get("status") in ("error", "expired", "denied"):
|
||||||
|
break
|
||||||
|
_t.sleep(0.1)
|
||||||
|
with self._lock:
|
||||||
|
if self._state.get("status") in ("error", "expired", "denied"):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": self._state.get("error", "register failed"),
|
||||||
|
})
|
||||||
|
if not self._state.get("url"):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "等待飞书二维码超时,请重试",
|
||||||
|
})
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"qrcode_url": self._state["url"],
|
||||||
|
"qr_image": self._state.get("qr_image", ""),
|
||||||
|
"expire_in": self._state.get("expire_in", 600),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] FeishuRegister GET error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
def POST(self):
|
||||||
|
"""轮询注册结果。"""
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data() or b"{}")
|
||||||
|
action = body.get("action", "poll")
|
||||||
|
if action != "poll":
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown action: {action}"})
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
status = self._state.get("status", "idle")
|
||||||
|
if status == "done":
|
||||||
|
payload = {
|
||||||
|
"status": "success",
|
||||||
|
"register_status": "done",
|
||||||
|
"app_id": self._state.get("app_id", ""),
|
||||||
|
"app_secret": self._state.get("app_secret", ""),
|
||||||
|
}
|
||||||
|
# 一次性返回凭据后清掉,避免敏感信息长期驻留内存
|
||||||
|
self._state = {}
|
||||||
|
return json.dumps(payload)
|
||||||
|
if status in ("error", "expired", "denied"):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"register_status": status,
|
||||||
|
"message": self._state.get("error", ""),
|
||||||
|
})
|
||||||
|
# pending / starting:还在等用户扫码
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"register_status": "pending",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] FeishuRegister POST error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
def _get_workspace_root():
|
def _get_workspace_root():
|
||||||
"""Resolve the agent workspace directory."""
|
"""Resolve the agent workspace directory."""
|
||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
|
|||||||
@@ -3,25 +3,43 @@ title: 飞书
|
|||||||
description: 将 CowAgent 接入飞书应用
|
description: 将 CowAgent 接入飞书应用
|
||||||
---
|
---
|
||||||
|
|
||||||
通过自建应用将 CowAgent 接入飞书,需要是飞书企业用户且具有企业管理权限。
|
> 通过飞书自建应用接入 CowAgent,支持单聊与群聊(@机器人),使用 WebSocket 长连接模式,无需公网 IP,支持流式打字机回复、语音消息收发。
|
||||||
|
|
||||||
## 一、创建企业自建应用
|
<Note>
|
||||||
|
接入需要是飞书企业用户且具有企业管理权限。
|
||||||
|
</Note>
|
||||||
|
|
||||||
### 1. 创建应用
|
## 一、接入方式
|
||||||
|
|
||||||
进入 [飞书开发平台](https://open.feishu.cn/app/),点击 **创建企业自建应用**,填写必要信息后点击 **创建**:
|
### 方式一:扫码一键接入(推荐)
|
||||||
|
|
||||||
|
启动 Cow 项目后在终端中即可完成扫码创建。或打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,点击 **一键创建飞书应用**,使用 **飞书 App** 扫描二维码即可自动完成应用创建并接入:
|
||||||
|
|
||||||
|
|
||||||
|
<img src="https://cdn.link-ai.tech/doc/20260505181126.png" width="800"/>
|
||||||
|
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
1. `lark-oapi` 依赖版本需要 >=1.5.5
|
||||||
|
2. 扫码创建出的应用会自动预置全部所需权限(消息收发、卡片读写、群聊事件等)和事件订阅,无需到开发者后台手动配置。
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
|
||||||
|
### 方式二:手动创建接入
|
||||||
|
|
||||||
|
需要先在飞书开放平台创建自建应用并配置权限,再通过 Web 控制台或配置文件接入。
|
||||||
|
|
||||||
|
**步骤一:创建应用**
|
||||||
|
|
||||||
|
1. 进入 [飞书开发平台](https://open.feishu.cn/app/),点击 **创建企业自建应用**:
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
||||||
|
|
||||||
### 2. 添加机器人能力
|
2. 在 **添加应用能力** 中,为应用添加 **机器人** 能力:
|
||||||
|
|
||||||
在 **添加应用能力** 菜单中,为应用添加 **机器人** 能力:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
||||||
|
|
||||||
### 3. 配置应用权限
|
3. 在 **权限管理** 中,将以下权限粘贴到输入框,全选并 **批量开通**:
|
||||||
|
|
||||||
点击 **权限管理**,复制以下权限配置,粘贴到 **权限配置** 下方的输入框内,全选筛选出来的权限,点击 **批量开通** 并确认:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
||||||
@@ -29,43 +47,65 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
||||||
|
|
||||||
## 二、项目配置
|
4. 在 **凭证与基础信息** 中获取 `App ID` 和 `App Secret`:
|
||||||
|
|
||||||
1. 在 **凭证与基础信息** 中获取 `App ID` 和 `App Secret`:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
||||||
|
|
||||||
2. 将以下配置加入项目根目录的 `config.json` 文件:
|
**步骤二:接入 CowAgent**
|
||||||
|
|
||||||
```json
|
<Tabs>
|
||||||
{
|
<Tab title="Web 控制台">
|
||||||
"channel_type": "feishu",
|
打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,切换到「手动填写」Tab,输入 App ID 和 App Secret,点击接入即可。
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
</Tab>
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
<Tab title="配置文件">
|
||||||
"feishu_stream_reply": true
|
在 `config.json` 中添加以下配置后启动程序:
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 说明 | 默认值 |
|
```json
|
||||||
| --- | --- | --- |
|
{
|
||||||
| `feishu_app_id` | 飞书机器人应用 App ID | - |
|
"channel_type": "feishu",
|
||||||
| `feishu_app_secret` | 飞书机器人 App Secret | - |
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复,关闭则一次性返回完整文本 | `true` |
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
|
"feishu_stream_reply": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> **流式回复要求**:需要为机器人开通 `cardkit:card:write` 权限,且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示 "请升级客户端" 占位提示;权限未开通时会自动降级为普通文本回复。
|
| 参数 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `feishu_app_id` | 飞书应用 App ID | - |
|
||||||
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
配置完成后启动项目。
|
**步骤三:发布应用**
|
||||||
|
|
||||||
## 三、配置事件订阅
|
1. 启动 Cow 项目后,在飞书开放平台点击 **事件与回调**,选择 **长连接** 模式并保存:
|
||||||
|
|
||||||
1. 成功运行项目后,在飞书开放平台点击 **事件与回调**,选择 **长连接** 方式,点击保存:
|
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
||||||
|
|
||||||
2. 点击下方的 **添加事件**,搜索 "接收消息",选择 "**接收消息v2.0**",确认添加。
|
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
||||||
|
|
||||||
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端查看审批消息并审核通过:
|
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
完成后在飞书中搜索机器人名称,即可开始对话。
|
## 二、功能说明
|
||||||
|
|
||||||
|
| 功能 | 支持情况 |
|
||||||
|
| --- | --- |
|
||||||
|
| 单聊 | ✅ |
|
||||||
|
| 群聊(@机器人) | ✅ |
|
||||||
|
| 文本消息 | ✅ 收发 |
|
||||||
|
| 图片消息 | ✅ 收发 |
|
||||||
|
| 语音消息 | ✅ 收发 |
|
||||||
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## 三、使用
|
||||||
|
|
||||||
|
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
||||||
|
|
||||||
|
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
||||||
|
|||||||
@@ -1,73 +1,107 @@
|
|||||||
---
|
---
|
||||||
title: Feishu (Lark)
|
title: Feishu (Lark)
|
||||||
description: Integrate CowAgent into Feishu application
|
description: Integrate CowAgent into Feishu via a custom enterprise app
|
||||||
---
|
---
|
||||||
|
|
||||||
Integrate CowAgent into Feishu by creating a custom enterprise app. You need to be a Feishu enterprise user with admin privileges.
|
> Integrate CowAgent into Feishu via a custom enterprise app. Supports p2p chat and group chat (@bot), uses WebSocket long connection (no public IP needed), supports streaming typewriter replies and voice messages.
|
||||||
|
|
||||||
## 1. Create Enterprise Custom App
|
<Note>
|
||||||
|
You need to be a Feishu enterprise user with admin privileges.
|
||||||
|
</Note>
|
||||||
|
|
||||||
### 1.1 Create App
|
## 1. Setup
|
||||||
|
|
||||||
Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**, fill in the required information and click **Create**:
|
### Option 1: One-click Scan to Create (Recommended)
|
||||||
|
|
||||||
|
No need to manually create an app on the Feishu Developer Platform. Start the Cow project, open the web console (default `http://127.0.0.1:9899/`), go to **Channels**, click **Add Channel**, choose **Feishu**, then under the **Scan QR** tab click **One-click Create Feishu App** and scan with the **Feishu App** to complete app creation and connection automatically.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The created app comes with all required permissions (messaging, card read/write, group events, etc.) and event subscriptions pre-configured. Currently only the Feishu mainland version is supported (Lark international not yet supported).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
When starting from CLI without `feishu_app_id` configured, the QR code is also printed to the terminal.
|
||||||
|
|
||||||
|
### Option 2: Manual Setup
|
||||||
|
|
||||||
|
Manually create a custom app on the Feishu Developer Platform, then connect via Web Console or config file.
|
||||||
|
|
||||||
|
**Step 1: Create the App**
|
||||||
|
|
||||||
|
1. Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**:
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
||||||
|
|
||||||
### 1.2 Add Bot Capability
|
2. In **Add App Capabilities**, add the **Bot** capability:
|
||||||
|
|
||||||
In **Add App Capabilities**, add **Bot** capability to the app:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
||||||
|
|
||||||
### 1.3 Configure App Permissions
|
3. In **Permission Management**, paste the following permissions and **Batch Enable** all:
|
||||||
|
|
||||||
Click **Permission Management**, paste the following permission string into the input box below **Permission Configuration**, select all filtered permissions, click **Batch Enable** and confirm:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
||||||
```
|
```
|
||||||
|
|
||||||
`cardkit:card:write` is used for streaming typewriter replies (creating and updating streaming cards). You can skip it if streaming is not needed.
|
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
||||||
|
|
||||||
## 2. Project Configuration
|
4. Get `App ID` and `App Secret` from **Credentials & Basic Info**:
|
||||||
|
|
||||||
1. Get `App ID` and `App Secret` from **Credentials & Basic Info**:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
||||||
|
|
||||||
2. Add the following configuration to `config.json` in the project root:
|
**Step 2: Connect to CowAgent**
|
||||||
|
|
||||||
```json
|
<Tabs>
|
||||||
{
|
<Tab title="Web Console">
|
||||||
"channel_type": "feishu",
|
Open the web console, go to **Channels**, click **Add Channel**, choose **Feishu**, switch to the **Manual** tab, enter App ID and App Secret, then click connect.
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
</Tab>
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
<Tab title="Config File">
|
||||||
"feishu_stream_reply": true
|
Add the following to `config.json` and start the program:
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
```json
|
||||||
| --- | --- | --- |
|
{
|
||||||
| `feishu_app_id` | Feishu bot App ID | - |
|
"channel_type": "feishu",
|
||||||
| `feishu_app_secret` | Feishu bot App Secret | - |
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply (powered by Feishu cardkit streaming card API). Disable to return the full text at once. | `true` |
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
|
"feishu_stream_reply": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> **Streaming requirements**: requires `cardkit:card:write` permission on the bot, and recipient Feishu client version ≥ 7.20. Older clients will see a "please upgrade" placeholder; if the permission is missing, replies automatically fall back to plain text.
|
| Parameter | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `feishu_app_id` | Feishu app App ID | - |
|
||||||
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
Start the project after configuration is complete.
|
**Step 3: Publish the App**
|
||||||
|
|
||||||
## 3. Configure Event Subscription
|
1. After Cow is running, go to **Events & Callbacks** in the Feishu Developer Platform, choose **Long Connection** mode and save:
|
||||||
|
|
||||||
1. After the project is running successfully, go to the Feishu Developer Platform, click **Events & Callbacks**, select **Long Connection** mode, and click save:
|
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
||||||
|
|
||||||
2. Click **Add Event** below, search for "Receive Message", select "**Receive Message v2.0**", and confirm.
|
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
|
||||||
|
|
||||||
3. Click **Version Management & Release**, create a new version and apply for **Production Release**. Check the approval message in the Feishu client and approve:
|
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
Once completed, search for the bot name in Feishu to start chatting.
|
## 2. Features
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| P2P chat | ✅ |
|
||||||
|
| Group chat (@bot) | ✅ |
|
||||||
|
| Text messages | ✅ send/receive |
|
||||||
|
| Image messages | ✅ send/receive |
|
||||||
|
| Voice messages | ✅ send/receive |
|
||||||
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## 3. Usage
|
||||||
|
|
||||||
|
After connection, search for the bot name in Feishu to start a chat.
|
||||||
|
|
||||||
|
To use in groups, add the bot to a group and @-mention it.
|
||||||
|
|||||||
@@ -1,73 +1,107 @@
|
|||||||
---
|
---
|
||||||
title: Feishu (Lark)
|
title: Feishu (Lark)
|
||||||
description: CowAgent を Feishu アプリケーションに統合する
|
description: 企業向けカスタムアプリで CowAgent を Feishu に接続
|
||||||
---
|
---
|
||||||
|
|
||||||
企業向けカスタムアプリを作成して、CowAgent を Feishu に統合します。管理者権限を持つ Feishu 企業ユーザーである必要があります。
|
> 飛書(Feishu)の企業向けカスタムアプリを通じて CowAgent を接続。1 対 1 チャット、グループチャット(@メンション)に対応。WebSocket 長接続を使用するため公開 IP 不要、ストリーミングのタイプライター応答や音声メッセージにも対応します。
|
||||||
|
|
||||||
## 1. 企業カスタムアプリの作成
|
<Note>
|
||||||
|
接続には管理者権限を持つ Feishu 企業ユーザーが必要です。
|
||||||
|
</Note>
|
||||||
|
|
||||||
### 1.1 アプリの作成
|
## 1. 接続方法
|
||||||
|
|
||||||
[Feishu 開発者プラットフォーム](https://open.feishu.cn/app/)にアクセスし、**企業カスタムアプリを作成**をクリックして、必要な情報を入力し**作成**をクリックします:
|
### 方式 1: ワンクリック作成(推奨)
|
||||||
|
|
||||||
|
事前に Feishu 開発者プラットフォームでアプリを作成する必要はありません。Cow を起動後、Web コンソール(既定 `http://127.0.0.1:9899/`)を開き、**チャネル** メニュー → **チャネルを追加** → **Feishu** を選択し、**QR スキャン** タブで **ワンクリックで Feishu アプリを作成** をクリック。**Feishu アプリ** で QR コードをスキャンするとアプリ作成と接続が自動完了します。
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
作成されたアプリには必要な権限(メッセージ送受信、カード読み書き、グループイベントなど)とイベント購読がすべて事前設定されています。現在は Feishu 中国版のみ対応で、Lark 国際版は未対応です。
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
CLI から `feishu_app_id` 未設定で起動した場合は、ターミナルにも QR コードが表示されます。
|
||||||
|
|
||||||
|
### 方式 2: 手動作成
|
||||||
|
|
||||||
|
Feishu 開発者プラットフォームで自分でアプリを作成し、Web コンソールまたは設定ファイルから接続します。
|
||||||
|
|
||||||
|
**ステップ 1: アプリ作成**
|
||||||
|
|
||||||
|
1. [Feishu 開発者プラットフォーム](https://open.feishu.cn/app/) にアクセスし、**企業カスタムアプリを作成** をクリック:
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
|
||||||
|
|
||||||
### 1.2 Bot 機能の追加
|
2. **アプリ機能の追加** で **Bot** 機能を追加:
|
||||||
|
|
||||||
**アプリ機能の追加**で、アプリに **Bot** 機能を追加します:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
|
||||||
|
|
||||||
### 1.3 アプリ権限の設定
|
3. **権限管理** で以下の権限を貼り付け、全選択して **一括有効化**:
|
||||||
|
|
||||||
**権限管理**をクリックし、**権限設定**の下の入力欄に以下の権限文字列を貼り付け、フィルタされたすべての権限を選択し、**一括有効化**をクリックして確認します:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
|
||||||
```
|
```
|
||||||
|
|
||||||
`cardkit:card:write` はストリーミングタイプライター応答(ストリーミングカードの作成と更新)に使用されます。ストリーミングが不要な場合は省略できます。
|
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
|
||||||
|
|
||||||
## 2. プロジェクト設定
|
4. **認証情報と基本情報** から `App ID` と `App Secret` を取得:
|
||||||
|
|
||||||
1. **認証情報と基本情報**から `App ID` と `App Secret` を取得します:
|
|
||||||
|
|
||||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
|
||||||
|
|
||||||
2. プロジェクトルートの `config.json` に以下の設定を追加します:
|
**ステップ 2: CowAgent に接続**
|
||||||
|
|
||||||
```json
|
<Tabs>
|
||||||
{
|
<Tab title="Web コンソール">
|
||||||
"channel_type": "feishu",
|
Web コンソールから **チャネル** → **チャネルを追加** → **Feishu** → **手動入力** タブに切り替え、App ID と App Secret を入力して接続。
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
</Tab>
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
<Tab title="設定ファイル">
|
||||||
"feishu_stream_reply": true
|
`config.json` に以下を追加して起動:
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| パラメータ | 説明 | デフォルト値 |
|
```json
|
||||||
| --- | --- | --- |
|
{
|
||||||
| `feishu_app_id` | Feishu Bot の App ID | - |
|
"channel_type": "feishu",
|
||||||
| `feishu_app_secret` | Feishu Bot の App Secret | - |
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効にするか(Feishu 公式 cardkit ストリーミングカード API を使用)。無効化するとテキストを一括で返します。 | `true` |
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
|
"feishu_stream_reply": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> **ストリーミングの要件**: Bot に `cardkit:card:write` 権限を付与する必要があり、受信者の Feishu クライアントバージョンが 7.20 以上である必要があります。古いバージョンのクライアントでは「クライアントをアップグレードしてください」というプレースホルダーが表示されます。権限が付与されていない場合は、自動的に通常のテキスト応答にフォールバックします。
|
| パラメータ | 説明 | デフォルト |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `feishu_app_id` | Feishu アプリの App ID | - |
|
||||||
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
設定完了後、プロジェクトを起動します。
|
**ステップ 3: アプリの公開**
|
||||||
|
|
||||||
## 3. イベントサブスクリプションの設定
|
1. Cow 起動後、Feishu 開発者プラットフォームの **イベントとコールバック** で **ロングコネクション** モードを選択して保存:
|
||||||
|
|
||||||
1. プロジェクトが正常に動作した後、Feishu 開発者プラットフォームに移動し、**イベントとコールバック**をクリックし、**ロングコネクション**モードを選択して保存をクリックします:
|
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
|
||||||
|
|
||||||
2. 下の**イベントを追加**をクリックし、「メッセージ受信」を検索して「**メッセージ受信 v2.0**」を選択し、確認します。
|
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
||||||
|
|
||||||
3. **バージョン管理とリリース**をクリックし、新しいバージョンを作成して**本番リリース**を申請します。Feishu クライアントで承認メッセージを確認し、承認します:
|
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
## 2. 機能一覧
|
||||||
|
|
||||||
|
| 機能 | 対応状況 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 対 1 チャット | ✅ |
|
||||||
|
| グループチャット(@Bot) | ✅ |
|
||||||
|
| テキストメッセージ | ✅ 送受信 |
|
||||||
|
| 画像メッセージ | ✅ 送受信 |
|
||||||
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## 3. 使い方
|
||||||
|
|
||||||
|
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||||
|
|
||||||
|
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ zai-sdk
|
|||||||
# tongyi qwen sdk
|
# tongyi qwen sdk
|
||||||
dashscope
|
dashscope
|
||||||
|
|
||||||
# feishu websocket mode
|
# feishu
|
||||||
lark-oapi
|
lark-oapi>=1.5.5
|
||||||
# dingtalk
|
# dingtalk
|
||||||
dingtalk_stream
|
dingtalk_stream
|
||||||
# wecom bot websocket mode
|
# wecom bot websocket mode
|
||||||
|
|||||||
Reference in New Issue
Block a user