Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d8fcca4
feat(deps): 添加 discord.py 依赖
aurevian-biz Aug 14, 2026
00d1545
test(channels): 添加 discord 适配器单元测试
aurevian-biz Aug 14, 2026
9c3ae06
feat(channels): 实现 discord 适配器与入站持久化
aurevian-biz Aug 14, 2026
019633b
feat(channels): 注册 discord 渠道并接入入站处理
aurevian-biz Aug 14, 2026
5dbd199
feat(api): 添加 discord 凭证保存端点
aurevian-biz Aug 14, 2026
f865e19
feat(ui): 添加 discord 渠道配置界面
aurevian-biz Aug 14, 2026
8cf472d
fix(channels): 修正 discord stream manager 的 engine 导入路径
aurevian-biz Aug 14, 2026
adc4cd2
fix(channels): 修复 discord 网关停止与连接状态语义
aurevian-biz Aug 14, 2026
e6a471c
fix(channels): 修复 discord wait_binding_stopped 签名与生命周期调用不一致
aurevian-biz Aug 14, 2026
d080e08
test(channels): 添加 hub 层 discord 等待停止回归测试
aurevian-biz Aug 14, 2026
8641ac2
fix(channels): 修复 discord 事件处理器注册名与 gateway 启动语义
aurevian-biz Aug 14, 2026
e9ba5c7
fix(channels): 修复 discord 回复投递 target 校验误判 delivery_target_missing
aurevian-biz Aug 14, 2026
1d74952
test(channels): 添加 discord 投递链路集成回归测试
aurevian-biz Aug 14, 2026
15d4d9c
fix(channels): discord 投递 nonce 幂等映射与创建者告警 target 修复
aurevian-biz Aug 14, 2026
5872d99
chore(deps): 更新 uv.lock 锁定 discord.py 依赖
aurevian-biz Aug 14, 2026
174c0f7
feat(channels): 添加渠道能力协议与投递模型扩展
aurevian-biz Aug 15, 2026
fc4d583
feat(channels): 实现 Discord 渠道 8 项功能扩展
aurevian-biz Aug 15, 2026
ebb6fbc
feat(ui): 添加 Discord 渠道功能配置界面
aurevian-biz Aug 15, 2026
e16e77b
fix(channels): 桥接 harness 产物到 Discord 附件投递
aurevian-biz Aug 15, 2026
1aa34e6
feat(channels): Web 端提问镜像投递到已绑定渠道
aurevian-biz Aug 15, 2026
e1e222c
feat(discord): 入站 target 记录 thread_id 并新增 create_thread 方法
aurevian-biz Aug 16, 2026
622359f
feat(channels): 新增 auto_thread 配置开关与能力声明
aurevian-biz Aug 16, 2026
041d922
feat(channels): 功能配置面板增加自动建线程开关
aurevian-biz Aug 16, 2026
b851c6c
feat(discord): outbox 自动创建线程投递与降级
aurevian-biz Aug 16, 2026
13c22cd
fix(channels): 绑定响应回传 config_json 修复功能开关回显
aurevian-biz Aug 16, 2026
544678e
Merge branch 'main' of https://github.com/OpenBMB/StaffDeck into feat…
aurevian-biz Aug 16, 2026
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
376 changes: 371 additions & 5 deletions backend/app/api/channels.py

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion backend/app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from starlette.background import BackgroundTask

from app.agents.branching import model_for_agent, visible_published_skills
from app.channels.service_outbox import stage_channel_delivery
from app.channels.service_outbox import stage_channel_delivery, stage_user_message_mirror
from app.core import AgentLoop
from app.core.cancellation import cancel_chat_turn
from app.core.capability_manifest import CapabilityManifestBuilder
Expand Down Expand Up @@ -652,6 +652,8 @@ def _maybe_handle_scheduled_task_request(
created_at=now,
)
db.add(user_message)
if request.channel == "web":
stage_user_message_mirror(db, chat_session, user_message, web_origin=True)
draft_payload = draft.model_dump(mode="json")
db.add(
AgentEvent(
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
89 changes: 89 additions & 0 deletions backend/app/channels/adapters/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, Protocol

import httpx
Expand Down Expand Up @@ -154,6 +155,28 @@ def remove_reaction(
) -> None: ...


class ChannelCapability(StrEnum):
"""渠道能力枚举:8 项 Discord 功能扩展的运行时能力声明。"""

SLASH_COMMANDS = "slash_commands" # 原生斜杠命令
THREADS = "threads" # 线程
BATCH_SEND = "batch_send" # 批量投递
BACKFILL = "backfill" # 历史回填
TYPING = "typing" # typing indicator
VOICE = "voice" # 语音
RICH_MEDIA = "rich_media" # embeds/附件


class ChannelCapabilityAdapter(Protocol):
"""可选混入:声明该渠道支持的能力集合。

与 ChannelReactionAdapter 一样是可选协议;未实现此协议的渠道
自动视为无任何扩展能力,所有门禁降级跳过。
"""

def channel_capabilities(self) -> set[ChannelCapability]: ...


_adapters: dict[str, ChannelAdapter] = {}


Expand Down Expand Up @@ -184,6 +207,72 @@ def channel_reaction_token(channel: str) -> str | None:
return token or None


def channel_capabilities_of(adapter: object) -> set[ChannelCapability]:
"""安全获取适配器的能力声明集合。

实现了 ChannelCapabilityAdapter 协议(有可调用的 channel_capabilities
方法)时返回其声明集合,否则返回空集。存量渠道(微信/企微/飞书/钉钉)
未实现该协议,自动降级为无能力,保证向后兼容。
"""
capabilities = getattr(adapter, "channel_capabilities", None)
if not callable(capabilities):
return set()
declared = capabilities()
if not isinstance(declared, set):
return set()
return set(declared)


def evaluate_allowlist(message_ctx: dict[str, Any], allowlist: dict[str, Any] | None) -> bool:
"""入站白名单判定:True 放行,False 拒绝(功能5)。

allowlist schema(§3.1/§4.5):
{mode: "allow_all"|"deny_all"(缺省 allow_all),
guild_ids/channel_ids/role_ids/user_ids: [str],
deny: [str]}(条目支持 "dimension:id" 前缀,纯 id 匹配任意维度)
判定序:deny 命中→拒绝;deny_all 且 allow 未命中→拒绝;allow_all 且 allow
非空未命中→拒绝;其余放行。role_ids 需要 members intent,首版(P2)不参与。
"""
if not isinstance(allowlist, dict) or not allowlist:
return True
mode = str(allowlist.get("mode") or "").strip() or "allow_all"
if mode not in ("allow_all", "deny_all"):
mode = "allow_all"
guild_id = str(message_ctx.get("guild_id") or "").strip()
channel_id = str(message_ctx.get("channel_id") or "").strip()
author_id = str(message_ctx.get("author_id") or "").strip()
for entry in allowlist.get("deny") or []:
token = str(entry).strip()
if not token:
continue
if ":" in token:
dimension, _, value = token.partition(":")
value = value.strip()
if not value or dimension not in {"guild", "channel", "user"}:
continue
if dimension == "guild" and value == guild_id:
return False
if dimension == "channel" and value == channel_id:
return False
if dimension == "user" and value == author_id:
return False
elif token in (guild_id, channel_id, author_id):
return False
allow_guilds = _allowlist_ids(allowlist, "guild_ids")
allow_channels = _allowlist_ids(allowlist, "channel_ids")
allow_users = _allowlist_ids(allowlist, "user_ids")
allow_hit = guild_id in allow_guilds or channel_id in allow_channels or author_id in allow_users
if mode == "deny_all":
return allow_hit
if not allow_guilds and not allow_channels and not allow_users:
return True
return allow_hit


def _allowlist_ids(allowlist: dict[str, Any], key: str) -> set[str]:
return {str(value).strip() for value in (allowlist.get(key) or []) if str(value).strip()}


def split_channel_text(text: str, limit: int = CHANNEL_TEXT_LIMIT) -> list[str]:
"""按渠道 2000 字上限拆分长文本,优先 \n\n / \n / 空格边界,找不到则硬切。"""
if not text:
Expand Down
Loading