2026-06-03 11:40:21 +08:00
|
|
|
"""飞书群消息通知(出站,内网可用)。"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
2026-06-15 17:25:28 +08:00
|
|
|
import logging
|
2026-06-03 11:40:21 +08:00
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
from as_platform.auth.feishu import _get_tenant_access_token
|
|
|
|
|
from as_platform.config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_LABELING_CHAT_ID
|
|
|
|
|
|
2026-06-15 17:25:28 +08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-06-03 11:40:21 +08:00
|
|
|
IM_MSG_URL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
|
|
|
|
|
2026-06-15 17:25:28 +08:00
|
|
|
IM_MESSAGE_SCOPE_URL = (
|
|
|
|
|
f"https://open.feishu.cn/app/{FEISHU_APP_ID}/auth"
|
|
|
|
|
"?q=im:message:send_as_bot,im:message:send,im:message"
|
|
|
|
|
"&op_from=openapi&token_type=tenant"
|
|
|
|
|
if FEISHU_APP_ID
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
FEISHU_BOT_SETUP_URL = f"https://open.feishu.cn/app/{FEISHU_APP_ID}/bot" if FEISHU_APP_ID else ""
|
|
|
|
|
FEISHU_PUBLISH_URL = f"https://open.feishu.cn/app/{FEISHU_APP_ID}/appPublish" if FEISHU_APP_ID else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _feishu_send_error_hint(message: str) -> dict[str, str]:
|
|
|
|
|
"""将飞书 API 英文报错映射为可操作的中文提示。"""
|
|
|
|
|
msg = message or ""
|
|
|
|
|
if "Bot ability is not activated" in msg:
|
|
|
|
|
return {
|
|
|
|
|
"reason": "bot_not_activated",
|
|
|
|
|
"help_text": "应用未启用「机器人」能力:开放平台 → 应用能力 → 添加机器人 → 创建版本并发布",
|
|
|
|
|
"help_url": FEISHU_BOT_SETUP_URL or IM_MESSAGE_SCOPE_URL,
|
|
|
|
|
}
|
|
|
|
|
if "NO availability" in msg or "230013" in msg:
|
|
|
|
|
return {
|
|
|
|
|
"reason": "user_out_of_scope",
|
|
|
|
|
"help_text": "你在机器人可用范围外:版本管理与发布 → 编辑可用范围 → 加入全员或你的部门 → 重新发布",
|
|
|
|
|
"help_url": FEISHU_PUBLISH_URL or IM_MESSAGE_SCOPE_URL,
|
|
|
|
|
}
|
|
|
|
|
if "im:message" in msg or "Access denied" in msg:
|
|
|
|
|
return {
|
|
|
|
|
"reason": "missing_im_scope",
|
|
|
|
|
"help_text": "应用未开通「发送消息」权限,请在权限管理中申请 im:message:send_as_bot",
|
|
|
|
|
"help_url": IM_MESSAGE_SCOPE_URL,
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
"reason": "send_failed",
|
|
|
|
|
"help_text": msg[:120],
|
|
|
|
|
"help_url": IM_MESSAGE_SCOPE_URL,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_feishu_send_response(resp: httpx.Response) -> dict[str, Any]:
|
|
|
|
|
data = resp.json() if resp.content else {}
|
|
|
|
|
if resp.status_code < 400 and data.get("code") in (0, None):
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
msg = data.get("msg") or f"HTTP {resp.status_code}"
|
|
|
|
|
hint = _feishu_send_error_hint(msg)
|
|
|
|
|
return {"ok": False, "message": msg, **hint}
|
|
|
|
|
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
def is_notify_configured() -> bool:
|
|
|
|
|
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET and FEISHU_LABELING_CHAT_ID)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_chat_text(text: str) -> dict[str, Any]:
|
|
|
|
|
if not is_notify_configured():
|
|
|
|
|
return {"ok": False, "message": "未配置 FEISHU_LABELING_CHAT_ID"}
|
|
|
|
|
with httpx.Client(timeout=30.0) as client:
|
|
|
|
|
token = _get_tenant_access_token(client)
|
|
|
|
|
resp = client.post(
|
|
|
|
|
IM_MSG_URL,
|
|
|
|
|
params={"receive_id_type": "chat_id"},
|
|
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
json={
|
|
|
|
|
"receive_id": FEISHU_LABELING_CHAT_ID,
|
|
|
|
|
"msg_type": "text",
|
|
|
|
|
"content": json.dumps({"text": text}, ensure_ascii=False),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
2026-06-15 17:25:28 +08:00
|
|
|
return _parse_feishu_send_response(resp)
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_chat_async(text: str) -> None:
|
2026-06-15 17:25:28 +08:00
|
|
|
"""异步发送飞书群消息,不阻塞主流程。"""
|
2026-06-03 11:40:21 +08:00
|
|
|
import threading
|
2026-06-15 17:25:28 +08:00
|
|
|
threading.Thread(target=_send_chat_safe, args=(text,), daemon=True, name="feishu-notify").start()
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
|
2026-06-15 17:25:28 +08:00
|
|
|
def send_user_text(open_id: str, text: str) -> dict[str, Any]:
|
|
|
|
|
"""向指定飞书用户发送私聊消息(需应用具备 im:message 权限)。"""
|
|
|
|
|
if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
|
|
|
|
|
return {"ok": False, "message": "飞书未配置"}
|
|
|
|
|
if not open_id:
|
|
|
|
|
return {"ok": False, "message": "open_id 为空"}
|
|
|
|
|
try:
|
|
|
|
|
with httpx.Client(timeout=30.0) as client:
|
|
|
|
|
token = _get_tenant_access_token(client)
|
|
|
|
|
resp = client.post(
|
|
|
|
|
IM_MSG_URL,
|
|
|
|
|
params={"receive_id_type": "open_id"},
|
|
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
json={
|
|
|
|
|
"receive_id": open_id,
|
|
|
|
|
"msg_type": "text",
|
|
|
|
|
"content": json.dumps({"text": text}, ensure_ascii=False),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
data = resp.json() if resp.content else {}
|
|
|
|
|
if resp.status_code >= 400 or data.get("code") not in (0, None):
|
|
|
|
|
result = _parse_feishu_send_response(resp)
|
|
|
|
|
logger.warning(
|
|
|
|
|
"飞书私聊发送失败 open_id=%s reason=%s: %s",
|
|
|
|
|
open_id[:12],
|
|
|
|
|
result.get("reason"),
|
|
|
|
|
result.get("message"),
|
|
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("飞书私聊发送异常 open_id=%s: %s", open_id[:12], exc)
|
|
|
|
|
hint = _feishu_send_error_hint(str(exc))
|
|
|
|
|
return {"ok": False, "message": str(exc), **hint}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_user_async(open_id: str, text: str) -> None:
|
|
|
|
|
"""异步向飞书用户发私聊,不阻塞主流程。"""
|
|
|
|
|
import threading
|
|
|
|
|
threading.Thread(
|
|
|
|
|
target=_send_user_safe,
|
|
|
|
|
args=(open_id, text),
|
|
|
|
|
daemon=True,
|
|
|
|
|
name="feishu-user-notify",
|
|
|
|
|
).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _send_chat_safe(text: str) -> None:
|
2026-06-03 11:40:21 +08:00
|
|
|
try:
|
|
|
|
|
send_chat_text(text)
|
|
|
|
|
except Exception:
|
2026-06-15 17:25:28 +08:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _send_user_safe(open_id: str, text: str) -> None:
|
|
|
|
|
try:
|
|
|
|
|
send_user_text(open_id, text)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_labeling_assignment(
|
|
|
|
|
*,
|
|
|
|
|
open_id: str,
|
|
|
|
|
assignee_name: str,
|
|
|
|
|
task: str,
|
|
|
|
|
batch: str,
|
|
|
|
|
count: int,
|
|
|
|
|
campaign_id: str,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""分配标注任务后向被指派人发送飞书私聊通知,返回发送结果。"""
|
|
|
|
|
from as_platform.config import FRONTEND_URL
|
|
|
|
|
|
|
|
|
|
link = f"{FRONTEND_URL.rstrip('/')}/labeling/my-tasks?campaign={campaign_id}"
|
|
|
|
|
text = (
|
|
|
|
|
f"[HSAP] 您有新的标注任务\n"
|
|
|
|
|
f"被指派人: {assignee_name}\n"
|
|
|
|
|
f"任务: {task} / 批次: {batch}\n"
|
|
|
|
|
f"分配数量: {count} 张\n"
|
|
|
|
|
f"请打开我的标注: {link}"
|
|
|
|
|
)
|
|
|
|
|
result = send_user_text(open_id, text)
|
|
|
|
|
if not result.get("ok") and is_notify_configured():
|
|
|
|
|
# 私聊失败时尝试发到标注协作群
|
|
|
|
|
fallback = send_chat_text(
|
|
|
|
|
f"[HSAP] 任务分配通知\n"
|
|
|
|
|
f"@{assignee_name} 您有 {count} 张新标注任务\n"
|
|
|
|
|
f"任务: {task} / 批次: {batch}\n"
|
|
|
|
|
f"打开我的标注: {link}"
|
|
|
|
|
)
|
|
|
|
|
if fallback.get("ok"):
|
|
|
|
|
return {"ok": True, "channel": "chat", "name": assignee_name}
|
|
|
|
|
return {**result, "channel": "dm", "name": assignee_name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_labeling_assignment_async(
|
|
|
|
|
*,
|
|
|
|
|
open_id: str,
|
|
|
|
|
assignee_name: str,
|
|
|
|
|
task: str,
|
|
|
|
|
batch: str,
|
|
|
|
|
count: int,
|
|
|
|
|
campaign_id: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
import threading
|
|
|
|
|
threading.Thread(
|
|
|
|
|
target=notify_labeling_assignment,
|
|
|
|
|
kwargs={
|
|
|
|
|
"open_id": open_id,
|
|
|
|
|
"assignee_name": assignee_name,
|
|
|
|
|
"task": task,
|
|
|
|
|
"batch": batch,
|
|
|
|
|
"count": count,
|
|
|
|
|
"campaign_id": campaign_id,
|
|
|
|
|
},
|
|
|
|
|
daemon=True,
|
|
|
|
|
name="feishu-assign-notify",
|
|
|
|
|
).start()
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def notify_batch_progress(
|
|
|
|
|
*,
|
|
|
|
|
delivery_id: str,
|
|
|
|
|
task: str,
|
|
|
|
|
batch_name: str,
|
|
|
|
|
progress: str,
|
|
|
|
|
link: str,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
text = (
|
|
|
|
|
f"[HSAP] 标注进度 {delivery_id or batch_name}\n"
|
|
|
|
|
f"任务: {task} / 批次: {batch_name}\n"
|
|
|
|
|
f"进度: {progress}\n"
|
|
|
|
|
f"链接: {link}"
|
|
|
|
|
)
|
|
|
|
|
return send_chat_text(text)
|