Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion backend/app/api/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
DingTalkPermanentError,
validate_dingtalk_credentials,
)
from app.channels.adapters.discord import (
DiscordPermanentError,
validate_discord_credentials,
)
from app.channels.adapters.feishu import (
FeishuPermanentError,
validate_feishu_credentials,
Expand All @@ -45,6 +49,7 @@
ChannelQRCodeRead,
ChannelQRCodeStatusRead,
DingTalkCredentialsRequest,
DiscordCredentialsRequest,
FeishuCredentialsRequest,
MyIdentityBindingRead,
WeComCredentialsRequest,
Expand Down Expand Up @@ -119,7 +124,7 @@ def _patch_binding_config_key(
if result.rowcount != 1:
raise HTTPException(status_code=404, detail="渠道绑定不存在")

SUPPORTED_CHANNELS = {"wechat", "wecom", "feishu", "dingtalk"}
SUPPORTED_CHANNELS = {"wechat", "wecom", "feishu", "dingtalk", "discord"}
INGRESS_QUIESCE_TIMEOUT_SECONDS = 5.0

# 渠道描述:前端接入页据此渲染渠道卡片与凭证表单,新渠道只加条目不动页面骨架
Expand Down Expand Up @@ -168,6 +173,15 @@ def _patch_binding_config_key(
],
"capabilities": [],
},
{
"channel": "discord",
"name": "Discord",
"setup": "credentials",
"credential_fields": [
{"key": "bot_token", "label": "Bot Token", "placeholder": "Discord Developer Portal 获取", "secret": True},
],
"capabilities": [],
},
]


Expand Down Expand Up @@ -1084,6 +1098,82 @@ def save_dingtalk_credentials(
return channel_binding_read(db, binding)


@router.post("/{binding_id}/discord/credentials", response_model=ChannelBindingRead)
def save_discord_credentials(
binding_id: str,
request: DiscordCredentialsRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> ChannelBindingRead:
"""Validate and save Discord Bot Token, then start its connector."""
ensure_current_user_tenant(request.tenant_id, current_user)
binding = _get_binding(db, request.tenant_id, binding_id)
_ensure_binding_manager(db, request.tenant_id, binding, current_user)
if binding.channel != "discord":
raise HTTPException(status_code=400, detail="该绑定不是 Discord 渠道")
bot_token = request.bot_token.strip()
if not bot_token:
raise HTTPException(status_code=400, detail="Bot Token 不能为空")
old_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip()
try:
bot_info = validate_discord_credentials(bot_token)
except DiscordPermanentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.warning("验证 Discord 凭证失败 binding=%s", binding_id, exc_info=True)
raise HTTPException(status_code=502, detail="Discord 凭证验证暂时失败,请稍后重试") from exc
bot_id = str(bot_info.get("bot_id") or "").strip()
bot_name = str(bot_info.get("bot_name") or "").strip()
if not bot_id:
raise HTTPException(status_code=400, detail="Bot 信息无效")
if old_bot_id and old_bot_id != bot_id:
raise HTTPException(status_code=400, detail="应用变更不允许直接修改,请删除后重新创建绑定")
account_key = external_account_key("discord", {"bot_id": bot_id})
if not account_key:
raise HTTPException(status_code=400, detail="Bot 信息无效")
_ensure_external_account_available(db, account_key, binding_id)
db.rollback()
with binding_lifecycle_lock(binding_id):
binding = _get_binding(db, request.tenant_id, binding_id)
expected_revision = binding.config_revision
should_run = bool(binding.status == "active" and binding.credentials_enc)
current_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip()
if current_bot_id and current_bot_id != bot_id:
raise HTTPException(status_code=409, detail="渠道配置已被其他请求修改,请重试")
db.rollback()
_quiesce_binding_or_409(binding.channel, binding_id, should_run=should_run)
try:
binding = _get_binding(db, request.tenant_id, binding_id)
_ensure_revision(binding, expected_revision)
latest_bot_id = str((binding.config_json or {}).get("bot_id") or "").strip()
if latest_bot_id and latest_bot_id != bot_id:
raise HTTPException(status_code=409, detail="渠道配置已被其他请求修改,请重试")
_ensure_external_account_available(db, account_key, binding_id)
config = dict(binding.config_json or {})
config.update({"bot_id": bot_id, "bot_name": bot_name, "bound_at": utc_now().isoformat()})
binding.credentials_enc = encrypt_channel_secret(bot_token)
binding.config_json = config
binding.external_account_key = account_key
binding.config_revision += 1
binding.status = "active"
binding.connected = False
binding.updated_at = utc_now()
db.add(binding)
adopt_orphan_channel_sessions(db, binding)
db.commit()
db.refresh(binding)
except IntegrityError as exc:
db.rollback()
_resume_binding(binding.channel, binding_id, start=should_run)
raise HTTPException(status_code=409, detail="该 Discord 机器人已被其他渠道绑定使用") from exc
except Exception:
db.rollback()
_resume_binding(binding.channel, binding_id, start=should_run)
raise
_resume_binding(binding.channel, binding_id, start=True)
return channel_binding_read(db, binding)


@router.get("/delivery-audit", response_model=ChannelDeliveryPage)
def list_tenant_delivery_audit(
tenant_id: str = Query(...),
Expand Down
21 changes: 21 additions & 0 deletions backend/app/channels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_wecom_stream_manager = None
_feishu_process_manager = None
_dingtalk_stream_manager = None
_discord_stream_manager = None
_binding_lifecycle_locks: dict[str, threading.RLock] = {}
_binding_lifecycle_locks_guard = threading.Lock()
_connector_lock_file: IO[bytes] | None = None
Expand Down Expand Up @@ -126,6 +127,15 @@ def get_dingtalk_stream_manager():
return _dingtalk_stream_manager


def get_discord_stream_manager():
global _discord_stream_manager
if _discord_stream_manager is None:
from app.channels.adapters.discord import DiscordStreamManager

_discord_stream_manager = DiscordStreamManager()
return _discord_stream_manager


def channel_services_enabled() -> bool:
# staffdeck_role 预留角色拆分:all=单体全量,connector=仅渠道连接器
return get_settings().staffdeck_role in {"all", "connector"}
Expand All @@ -135,6 +145,7 @@ def _ensure_adapters_registered() -> None:
# 各适配器模块导入即自注册(模块级 register_channel_adapter)
import app.channels.adapters.feishu # noqa: F401
import app.channels.adapters.dingtalk # noqa: F401
import app.channels.adapters.discord # noqa: F401
import app.channels.adapters.wechat # noqa: F401
import app.channels.adapters.wecom # noqa: F401

Expand Down Expand Up @@ -167,6 +178,8 @@ def _ingress_manager(channel: str):
return get_feishu_process_manager()
if channel == "dingtalk":
return get_dingtalk_stream_manager()
if channel == "discord":
return get_discord_stream_manager()
return None


Expand Down Expand Up @@ -213,6 +226,8 @@ def wait_binding_ingress_stopped(channel: str, binding_id: str, timeout_seconds:
return get_feishu_process_manager().wait_binding_stopped(binding_id, timeout_seconds)
if channel == "dingtalk":
return get_dingtalk_stream_manager().wait_binding_stopped(binding_id, timeout_seconds)
if channel == "discord":
return get_discord_stream_manager().wait_binding_stopped(binding_id, timeout_seconds)
return True


Expand Down Expand Up @@ -243,6 +258,7 @@ def start_channel_services() -> None:
get_wecom_stream_manager().start()
get_feishu_process_manager().start()
get_dingtalk_stream_manager().start()
get_discord_stream_manager().start()
start_delivery_daemon()
start_staged_inbound_daemon()
# 启动恢复:一次性清扫崩溃残留的 processing 入站事件(独立线程,不阻塞启动)
Expand Down Expand Up @@ -285,6 +301,10 @@ def stop_channel_services(timeout_seconds: float = 5.0) -> bool:
dingtalk_stopped = dingtalk_manager is None or dingtalk_manager.stop(
timeout_seconds=max(0.0, deadline - time.monotonic())
)
discord_manager = _discord_stream_manager
discord_stopped = discord_manager is None or discord_manager.stop(
timeout_seconds=max(0.0, deadline - time.monotonic())
)
sweep_thread = _intake_sweep_thread
if sweep_thread and sweep_thread.is_alive():
sweep_thread.join(timeout=max(0.0, deadline - time.monotonic()))
Expand All @@ -296,6 +316,7 @@ def stop_channel_services(timeout_seconds: float = 5.0) -> bool:
and wecom_stopped
and feishu_stopped
and dingtalk_stopped
and discord_stopped
and sweep_stopped
)
if stopped:
Expand Down
Loading