diff --git a/.github/drone.yml b/.github/drone.yml index 6b1410b3e..2fd9ebc6a 100644 --- a/.github/drone.yml +++ b/.github/drone.yml @@ -35,9 +35,17 @@ steps: - git config --global core.compression 0 - git config --global http.postBuffer 524288000 - git clone --no-single-branch https://github.com/dataelement/Clawith.git . + # For forked pull requests, the head commit may not exist in the normal + # branch refs of dataelement/Clawith. Fetch the GitHub PR ref explicitly. + - | + if [ "$DRONE_BUILD_EVENT" = "pull_request" ] && [ -n "$DRONE_PULL_REQUEST" ]; then + git fetch origin "refs/pull/${DRONE_PULL_REQUEST}/head:refs/remotes/origin/pr/${DRONE_PULL_REQUEST}" + git checkout "refs/remotes/origin/pr/${DRONE_PULL_REQUEST}" + else + git checkout "$DRONE_COMMIT" + fi # 将 shallow clone 转换为完整克隆,获取完整 commit 历史 - git fetch --unshallow --tags || git fetch --tags - - git checkout $DRONE_COMMIT - echo "当前 commit $(git log --oneline -1)" - echo "上一个 tag $(git describe --tags --abbrev=0 2>/dev/null || echo '无 tag')" diff --git a/backend/alembic/versions/060_add_external_http_channel.py b/backend/alembic/versions/060_add_external_http_channel.py new file mode 100644 index 000000000..419381fea --- /dev/null +++ b/backend/alembic/versions/060_add_external_http_channel.py @@ -0,0 +1,25 @@ +"""Add external_http channel type. + +Revision ID: add_external_http_channel +Revises: add_title_to_agent_focus_items +Create Date: 2026-06-08 +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "add_external_http_channel" +down_revision: Union[str, Sequence[str], None] = "add_title_to_agent_focus_items" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TYPE channel_type_enum ADD VALUE IF NOT EXISTS 'external_http'") + + +def downgrade() -> None: + # PostgreSQL cannot safely remove enum values in place. + pass diff --git a/backend/app/api/external_http.py b/backend/app/api/external_http.py new file mode 100644 index 000000000..aba297e93 --- /dev/null +++ b/backend/app/api/external_http.py @@ -0,0 +1,488 @@ +"""External HTTP channel for business-system integrations.""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import secrets +import time +import uuid +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from loguru import logger +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.events import get_redis +from app.core.permissions import check_agent_access, is_agent_creator +from app.core.security import get_current_user +from app.database import async_session, get_db +from app.models.audit import ChatMessage +from app.models.channel_config import ChannelConfig +from app.models.user import User +from app.schemas.schemas import ChannelConfigOut +from app.services.channel_session import find_or_create_channel_session +from app.services.channel_user_service import channel_user_service +from app.services.llm.utils import convert_chat_messages_to_llm_format + +router = APIRouter(tags=["external-http"]) + +CHANNEL_TYPE = "external_http" +DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024 +DEFAULT_SYNC_TIMEOUT_SECONDS = 120 +EXTERNAL_USER_ID_MAX_LENGTH = 100 +EXTERNAL_USER_NAME_MAX_LENGTH = 100 +EXTERNAL_CONVERSATION_ID_MAX_LENGTH = 200 - len(f"{CHANNEL_TYPE}:") + + +class ExternalHttpChannelConfigIn(BaseModel): + require_hmac: bool = False + sync_timeout_seconds: int = Field(DEFAULT_SYNC_TIMEOUT_SECONDS, ge=5, le=300) + max_payload_bytes: int = Field(DEFAULT_MAX_PAYLOAD_BYTES, ge=1024, le=1024 * 1024) + regenerate_api_key: bool = False + regenerate_signing_secret: bool = False + + +class ExternalHttpMessageIn(BaseModel): + content: str = Field(min_length=1, max_length=60000) + external_user_id: str = Field(default="external", min_length=1, max_length=EXTERNAL_USER_ID_MAX_LENGTH) + external_user_name: str | None = Field(default=None, max_length=EXTERNAL_USER_NAME_MAX_LENGTH) + conversation_id: str | None = Field(default=None, max_length=EXTERNAL_CONVERSATION_ID_MAX_LENGTH) + metadata: dict[str, Any] | None = None + mode: str = Field(default="sync", pattern="^(sync|async)$") + + @field_validator("external_user_id", mode="before") + @classmethod + def normalize_external_user_id(cls, value: str) -> str: + stripped = (value or "").strip() + if not stripped: + raise ValueError("external_user_id cannot be blank") + return stripped + + +def _hash_secret(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _new_api_key() -> str: + return f"ext-{secrets.token_urlsafe(32)}" + + +def _new_signing_secret() -> str: + return secrets.token_urlsafe(32) + + +def _extract_api_key(request: Request) -> str: + auth = request.headers.get("authorization", "") + if auth.lower().startswith("bearer "): + return auth.split(" ", 1)[1].strip() + return (request.headers.get("x-api-key") or "").strip() + + +def _safe_extra(config: ChannelConfig) -> dict: + extra = dict(config.extra_config or {}) + extra.pop("api_key_hash", None) + return extra + + +def _serialize_config( + config: ChannelConfig, + *, + api_key: str | None = None, + signing_secret: str | None = None, + webhook_url: str | None = None, +) -> dict: + payload = ChannelConfigOut.model_validate(config).model_dump() + payload["extra_config"] = _safe_extra(config) + payload["app_secret"] = None + payload["encrypt_key"] = None + if api_key: + payload["api_key"] = api_key + if signing_secret: + payload["signing_secret"] = signing_secret + if webhook_url: + payload["webhook_url"] = webhook_url + return payload + + +async def _public_message_url(request: Request, db: AsyncSession, agent_id: uuid.UUID) -> str: + from app.services.platform_service import platform_service + + public_base = await platform_service.get_public_base_url(db, request) + return f"{public_base.rstrip('/')}/api/channel/external-http/{agent_id}/message" + + +def _verify_api_key(config: ChannelConfig, request: Request) -> None: + expected_hash = (config.extra_config or {}).get("api_key_hash") or "" + api_key = _extract_api_key(request) + if not api_key or not expected_hash: + raise HTTPException(status_code=401, detail="Missing external HTTP channel API key") + if not hmac.compare_digest(_hash_secret(api_key), expected_hash): + raise HTTPException(status_code=401, detail="Invalid external HTTP channel API key") + + +def _verify_hmac_signature(config: ChannelConfig, request: Request, body: bytes) -> None: + extra = config.extra_config or {} + if not extra.get("require_hmac"): + return + + signing_secret = config.encrypt_key or "" + if not signing_secret: + raise HTTPException(status_code=401, detail="External HTTP channel signing secret is not configured") + + timestamp = request.headers.get("x-timestamp", "") + signature = request.headers.get("x-signature-sha256", "") + if not timestamp or not signature: + raise HTTPException(status_code=401, detail="Missing HMAC signature headers") + + try: + ts_value = int(timestamp) + except ValueError: + raise HTTPException(status_code=401, detail="Invalid HMAC timestamp") from None + + if abs(int(time.time()) - ts_value) > 300: + raise HTTPException(status_code=401, detail="Expired HMAC timestamp") + + signed_payload = timestamp.encode("utf-8") + b"." + body + expected = hmac.new(signing_secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + provided = signature.removeprefix("sha256=").strip() + if not hmac.compare_digest(expected, provided): + raise HTTPException(status_code=401, detail="Invalid HMAC signature") + + +async def _record_and_count_hits(config: ChannelConfig) -> int: + try: + redis = await get_redis() + now = time.time() + token_key = (config.extra_config or {}).get("api_key_hash") or str(config.agent_id) + key = f"external_http:rate:{token_key}" + member = f"{now}:{secrets.token_hex(4)}" + async with redis.pipeline(transaction=True) as pipe: + pipe.zremrangebyscore(key, 0, now - 60) + pipe.zadd(key, {member: now}) + pipe.zcard(key) + pipe.expire(key, 120) + _, _, count, _ = await pipe.execute() + return int(count) + except Exception as exc: + logger.warning(f"[ExternalHTTP] Rate limiter unavailable: {exc}") + return 1 + + +def _llm_text(message: ExternalHttpMessageIn) -> str: + if not message.metadata: + return message.content + metadata_text = json.dumps(message.metadata, ensure_ascii=False, indent=2, default=str) + return f"{message.content}\n\n[External HTTP metadata]\n{metadata_text}" + + +async def _process_external_http_message( + *, + agent_id: uuid.UUID, + message: ExternalHttpMessageIn, + request_id: str, +) -> dict: + from app.api.feishu import _call_llm_with_config, _load_agent_and_model + from app.models.agent import DEFAULT_CONTEXT_WINDOW_SIZE + from app.models.chat_session import ChatSession + from app.services.activity_logger import log_activity + + async with async_session() as db: + agent, model, fallback_model = await _load_agent_and_model(db, agent_id) + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + + ctx_size = agent.context_window_size or DEFAULT_CONTEXT_WINDOW_SIZE + external_user_id = message.external_user_id.strip() + external_name = (message.external_user_name or "").strip() or f"External User {external_user_id[:8]}" + platform_user = await channel_user_service.resolve_channel_user( + db=db, + agent=agent, + channel_type=CHANNEL_TYPE, + external_user_id=external_user_id, + extra_info={ + "name": external_name, + "external_id": external_user_id, + }, + ) + + external_conv = (message.conversation_id or "").strip() or external_user_id + external_conv_id = f"{CHANNEL_TYPE}:{external_conv}" + session = await find_or_create_channel_session( + db=db, + agent_id=agent_id, + user_id=platform_user.id, + external_conv_id=external_conv_id, + source_channel=CHANNEL_TYPE, + first_message_title=message.content, + ) + session_id = str(session.id) + + history_r = await db.execute( + select(ChatMessage) + .where(ChatMessage.agent_id == agent_id, ChatMessage.conversation_id == session_id) + .order_by(ChatMessage.created_at.desc()) + .limit(ctx_size) + ) + history = convert_chat_messages_to_llm_format(reversed(history_r.scalars().all())) + + content_for_llm = _llm_text(message) + db.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user.id, + role="user", + content=content_for_llm, + conversation_id=session_id, + ) + ) + session.last_message_at = datetime.now(timezone.utc) + await db.commit() + platform_user_id = platform_user.id + + reply_text = await _call_llm_with_config( + agent, + model, + fallback_model, + agent_id, + content_for_llm, + history=history, + user_id=platform_user_id, + session_id=session_id, + ) + + async with async_session() as db: + db.add( + ChatMessage( + agent_id=agent_id, + user_id=platform_user_id, + role="assistant", + content=reply_text, + conversation_id=session_id, + ) + ) + session_r = await db.execute(select(ChatSession).where(ChatSession.id == uuid.UUID(session_id))) + session = session_r.scalar_one_or_none() + if session: + session.last_message_at = datetime.now(timezone.utc) + await db.commit() + + await log_activity( + agent_id, + "chat_reply", + f"Replied to external HTTP message: {reply_text[:80]}", + detail={ + "channel": CHANNEL_TYPE, + "request_id": request_id, + "external_user_id": external_user_id, + "conversation_id": message.conversation_id, + "user_text": message.content[:500], + "reply": reply_text[:500], + }, + ) + + return { + "ok": True, + "request_id": request_id, + "session_id": session_id, + "reply": reply_text, + } + + +@router.post("/agents/{agent_id}/external-http-channel", status_code=status.HTTP_201_CREATED) +async def configure_external_http_channel( + agent_id: uuid.UUID, + request: Request, + data: ExternalHttpChannelConfigIn = ExternalHttpChannelConfigIn(), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + agent, _ = await check_agent_access(db, current_user, agent_id) + if not is_agent_creator(current_user, agent): + raise HTTPException(status_code=403, detail="Only creator can configure channel") + + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == CHANNEL_TYPE, + ) + ) + config = result.scalar_one_or_none() + + generated_api_key = None + generated_signing_secret = None + extra = { + "require_hmac": data.require_hmac, + "sync_timeout_seconds": data.sync_timeout_seconds, + "max_payload_bytes": data.max_payload_bytes, + "auth_scheme": "bearer", + "signature": "HMAC-SHA256 over '.' in X-Signature-SHA256", + } + + if config: + old_extra = config.extra_config or {} + if data.regenerate_api_key or not old_extra.get("api_key_hash"): + generated_api_key = _new_api_key() + extra["api_key_hash"] = _hash_secret(generated_api_key) + else: + extra["api_key_hash"] = old_extra.get("api_key_hash") + + if data.regenerate_signing_secret or (data.require_hmac and not config.encrypt_key): + generated_signing_secret = _new_signing_secret() + config.encrypt_key = generated_signing_secret + + config.app_id = CHANNEL_TYPE + config.app_secret = None + config.extra_config = extra + config.is_configured = True + config.is_connected = True + await db.flush() + else: + generated_api_key = _new_api_key() + extra["api_key_hash"] = _hash_secret(generated_api_key) + generated_signing_secret = _new_signing_secret() if data.require_hmac else None + config = ChannelConfig( + agent_id=agent_id, + channel_type=CHANNEL_TYPE, + app_id=CHANNEL_TYPE, + app_secret=None, + encrypt_key=generated_signing_secret, + extra_config=extra, + is_configured=True, + is_connected=True, + ) + db.add(config) + await db.flush() + + webhook_url = await _public_message_url(request, db, agent_id) + await db.commit() + return _serialize_config( + config, + api_key=generated_api_key, + signing_secret=generated_signing_secret, + webhook_url=webhook_url, + ) + + +@router.get("/agents/{agent_id}/external-http-channel") +async def get_external_http_channel( + agent_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await check_agent_access(db, current_user, agent_id) + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == CHANNEL_TYPE, + ) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="External HTTP channel not configured") + return _serialize_config(config) + + +@router.get("/agents/{agent_id}/external-http-channel/webhook-url") +async def get_external_http_message_url( + agent_id: uuid.UUID, + request: Request, + db: AsyncSession = Depends(get_db), +): + return {"webhook_url": await _public_message_url(request, db, agent_id)} + + +@router.delete("/agents/{agent_id}/external-http-channel", status_code=status.HTTP_204_NO_CONTENT) +async def delete_external_http_channel( + agent_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + agent, _ = await check_agent_access(db, current_user, agent_id) + if not is_agent_creator(current_user, agent): + raise HTTPException(status_code=403, detail="Only creator can remove channel") + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == CHANNEL_TYPE, + ) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="External HTTP channel not configured") + await db.delete(config) + await db.commit() + + +@router.post("/channel/external-http/{agent_id}/message") +async def external_http_message( + agent_id: uuid.UUID, + request: Request, +): + async with async_session() as db: + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == CHANNEL_TYPE, + ChannelConfig.is_configured == True, # noqa: E712 + ) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="External HTTP channel not configured") + + _verify_api_key(config, request) + + max_payload = int((config.extra_config or {}).get("max_payload_bytes") or DEFAULT_MAX_PAYLOAD_BYTES) + body = await request.body() + if len(body) > max_payload: + raise HTTPException(status_code=413, detail="Payload too large") + + _verify_hmac_signature(config, request, body) + + hit_count = await _record_and_count_hits(config) + from app.models.agent import Agent + + agent_r = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent = agent_r.scalar_one_or_none() + rate_limit = (agent.webhook_rate_limit if agent else None) or 5 + timeout_seconds = int((config.extra_config or {}).get("sync_timeout_seconds") or DEFAULT_SYNC_TIMEOUT_SECONDS) + if hit_count > rate_limit: + raise HTTPException(status_code=429, detail="Rate limit exceeded") + + try: + payload = ExternalHttpMessageIn.model_validate_json(body) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Invalid request body: {exc}") from None + + request_id = str(uuid.uuid4()) + if payload.mode == "async": + task = asyncio.create_task( + _process_external_http_message(agent_id=agent_id, message=payload, request_id=request_id) + ) + + def _log_background_result(done_task: asyncio.Task) -> None: + try: + done_task.result() + except Exception as exc: + logger.error(f"[ExternalHTTP] Async request {request_id} failed: {exc}") + + task.add_done_callback(_log_background_result) + return {"ok": True, "status": "accepted", "request_id": request_id} + + try: + return await asyncio.wait_for( + _process_external_http_message(agent_id=agent_id, message=payload, request_id=request_id), + timeout=timeout_seconds, + ) + except asyncio.TimeoutError: + return Response( + content=json.dumps({"ok": False, "request_id": request_id, "error": "Timed out"}), + media_type="application/json", + status_code=504, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 5ccd19369..9ec6f7a8d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -392,6 +392,7 @@ def _bg_task_error(t): from app.api.agentbay_control import router as agentbay_control_router from app.api.okr import router as okr_router from app.api.onboarding import router as onboarding_router +from app.api.external_http import router as external_http_router app.include_router(auth_router, prefix=settings.API_PREFIX) app.include_router(agents_router, prefix=settings.API_PREFIX) @@ -438,6 +439,7 @@ def _bg_task_error(t): app.include_router(agentbay_control_router, prefix=settings.API_PREFIX) app.include_router(okr_router) # OKR — self-prefixed at /api/okr app.include_router(onboarding_router, prefix=settings.API_PREFIX) +app.include_router(external_http_router, prefix=settings.API_PREFIX) @app.get("/api/health", response_model=HealthResponse, tags=["health"]) diff --git a/backend/app/models/channel_config.py b/backend/app/models/channel_config.py index 77e31f086..462954080 100644 --- a/backend/app/models/channel_config.py +++ b/backend/app/models/channel_config.py @@ -18,7 +18,7 @@ class ChannelConfig(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) agent_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) channel_type: Mapped[str] = mapped_column( - Enum("feishu", "wecom", "wechat", "whatsapp", "dingtalk", "slack", "discord","atlassian", "microsoft_teams", "agentbay", name="channel_type_enum"), + Enum("feishu", "wecom", "wechat", "whatsapp", "dingtalk", "slack", "discord","atlassian", "microsoft_teams", "agentbay", "external_http", name="channel_type_enum"), default="feishu", nullable=False, ) diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index 88b615a41..d839a8bd1 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -35,6 +35,7 @@ interface ChannelField { key: string; label: string; placeholder?: string; + description?: string; type?: 'text' | 'password'; required?: boolean; } @@ -91,6 +92,27 @@ const DingTalkIcon = DingTalk; +const ExternalHttpIcon = ( + + HTTP + +); + // Eye icons for password toggle const EyeOpen = ; const EyeClosed = ; @@ -228,6 +250,49 @@ const CHANNEL_REGISTRY: ChannelDef[] = [ ], guide: { prefix: 'channelGuide.atlassian', steps: 5 }, }, + { + id: 'external_http', + icon: ExternalHttpIcon, + nameKey: 'common.channels.externalHttp', + nameFallback: 'External HTTP', + desc: 'Business system API', + apiSlug: 'external-http-channel', + editOnly: true, + fields: [ + { + key: 'require_hmac', + label: '是否启用 HMAC 签名', + placeholder: 'false', + description: 'true 时请求必须携带 X-Timestamp 和 X-Signature-SHA256;false 时仅校验 API Key。', + }, + { + key: 'sync_timeout_seconds', + label: '同步等待超时时间(秒)', + placeholder: '120', + description: 'mode=sync 时等待智能体回复的最长时间,范围 5-300,默认 120。', + }, + { + key: 'max_payload_bytes', + label: '最大请求体大小(字节)', + placeholder: '65536', + description: '限制单次请求 JSON Body 的最大大小,默认 65536,最大 1048576。', + }, + { + key: 'regenerate_api_key', + label: '重新生成 API Key', + placeholder: 'false', + description: 'true 时生成新的外部调用密钥,旧 API Key 会失效;新密钥只在保存后显示一次。', + }, + { + key: 'regenerate_signing_secret', + label: '重新生成签名密钥', + placeholder: 'false', + description: 'true 时生成新的 HMAC 签名密钥;开启 HMAC 签名后外部系统需要使用它计算签名。', + }, + ], + guide: { prefix: 'channelGuide.externalHttp', steps: 4 }, + webhookLabel: '消息接口地址', + }, ]; // ─── Feishu Permission JSON ───────────────────────────── @@ -296,7 +361,7 @@ const FEISHU_PERM_FULL_DISPLAY = `{ // ─── Main Component ───────────────────────────────────── export default function ChannelConfig({ mode, agentId, canManage = true, values, onChange }: ChannelConfigProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); // Feishu Permission Mode @@ -332,6 +397,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, const [atlassianTesting, setAtlassianTesting] = useState(false); const [atlassianTestResult, setAtlassianTestResult] = useState<{ ok: boolean; message?: string; tool_count?: number; error?: string } | null>(null); const [actionFeedback, setActionFeedback] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [externalHttpSecrets, setExternalHttpSecrets] = useState<{ api_key?: string; signing_secret?: string; webhook_url?: string } | null>(null); const [wechatQr, setWechatQr] = useState<{ qrcode: string; qrcode_img_content: string } | null>(null); const [wechatQrImageSrc, setWechatQrImageSrc] = useState(''); const [wechatQrStatus, setWechatQrStatus] = useState(''); @@ -405,6 +471,16 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, queryFn: () => fetchAuth(`/agents/${agentId}/atlassian-channel`).catch(() => null), enabled: enabled, }); + const { data: externalHttpConfig } = useQuery({ + queryKey: ['external-http-channel', agentId], + queryFn: () => fetchAuth(`/agents/${agentId}/external-http-channel`).catch(() => null), + enabled: enabled, + }); + const { data: externalHttpWebhook } = useQuery({ + queryKey: ['external_http-webhook-url', agentId], + queryFn: () => fetchAuth(`/agents/${agentId}/external-http-channel/webhook-url`), + enabled: enabled, + }); // Helper: get config data for a channel const getConfig = (id: string): any => { switch (id) { @@ -416,6 +492,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, case 'wechat': return wechatConfig; case 'wecom': return wecomConfig; case 'atlassian': return atlassianConfig; + case 'external_http': return externalHttpConfig; default: return null; } }; @@ -428,6 +505,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, case 'discord': return discordWebhook; case 'teams': return teamsWebhook; case 'wecom': return wecomWebhook; + case 'external_http': return externalHttpWebhook; default: return null; } }; @@ -440,11 +518,18 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, } return fetchAuth(`/agents/${agentId}/${ch.apiSlug}`, { method: 'POST', body: JSON.stringify(data) }); }, - onSuccess: (_d, { ch }) => { + onSuccess: (_d: any, { ch }) => { const keys = ch.useChannelApi ? [['channel', agentId]] : [[`${ch.apiSlug}`, agentId], [`${ch.id}-webhook-url`, agentId]]; keys.forEach(k => queryClient.invalidateQueries({ queryKey: k })); + if (ch.id === 'external_http') { + setExternalHttpSecrets({ + api_key: _d?.api_key, + signing_secret: _d?.signing_secret, + webhook_url: _d?.webhook_url, + }); + } // Reset form setForms(prev => ({ ...prev, [ch.id]: {} })); setEditing(ch.id, false); @@ -474,6 +559,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, : [[`${ch.apiSlug}`, agentId]]; keys.forEach(k => queryClient.invalidateQueries({ queryKey: k })); if (ch.id === 'atlassian') setAtlassianTestResult(null); + if (ch.id === 'external_http') setExternalHttpSecrets(null); setEditing(ch.id, false); setActionFeedback({ type: 'success', @@ -670,6 +756,19 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, }, }; } + if (ch.id === 'external_http') { + const requireHmacRaw = (form.require_hmac || '').trim().toLowerCase(); + const regenerateApiKeyRaw = (form.regenerate_api_key || '').trim().toLowerCase(); + const regenerateSigningSecretRaw = (form.regenerate_signing_secret || '').trim().toLowerCase(); + const truthy = ['1', 'true', 'yes', 'y', 'on']; + return { + require_hmac: truthy.includes(requireHmacRaw), + sync_timeout_seconds: Number(form.sync_timeout_seconds || 120), + max_payload_bytes: Number(form.max_payload_bytes || 65536), + regenerate_api_key: truthy.includes(regenerateApiKeyRaw), + regenerate_signing_secret: truthy.includes(regenerateSigningSecretRaw), + }; + } // Generic channels return form; }; @@ -740,12 +839,13 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, const isSecret = field.type === 'password'; const labelText = field.label.startsWith('channelGuide.') ? t(field.label) : field.label; const placeholderText = field.placeholder?.startsWith('channelGuide.') ? t(field.placeholder) : field.placeholder; + const descriptionText = field.description?.startsWith('channelGuide.') ? t(field.description) : field.description; return (
)}
+ {descriptionText && ( +
+ {descriptionText} +
+ )} {/* Tenant ID hint for Teams */} {channelId === 'teams' && field.key === 'tenant_id' && (
{t('channelGuide.teams.tenantIdHint')}
@@ -873,7 +978,11 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, } // Webhook URL for this channel - const webhookUrl = webhook?.webhook_url || `${window.location.origin}/api/channel/${ch.id === 'feishu' ? 'feishu' : ch.apiSlug?.replace('-channel', '')}/${agentId}/webhook`; + const webhookUrl = webhook?.webhook_url || ( + ch.id === 'external_http' + ? `${window.location.origin}/api/channel/external-http/${agentId}/message` + : `${window.location.origin}/api/channel/${ch.id === 'feishu' ? 'feishu' : ch.apiSlug?.replace('-channel', '')}/${agentId}/webhook` + ); // Determine which fields to use (wecom websocket mode has different fields) const activeFields = (ch.connectionMode && isWs && ch.wsFields) ? ch.wsFields : ch.fields; @@ -1017,6 +1126,42 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, {config.cloud_id &&
Cloud ID: {config.cloud_id}
}
)} + {ch.id === 'external_http' && ( +
+
Status
+
External HTTP endpoint enabled
+
+ Auth: Bearer API key + {config.extra_config?.require_hmac ? ' + HMAC signature' : ''} +
+
+ Timeout: {config.extra_config?.sync_timeout_seconds || 120}s · Max payload: {config.extra_config?.max_payload_bytes || 65536} bytes +
+
+ )} + {ch.id === 'external_http' && (externalHttpSecrets?.api_key || externalHttpSecrets?.signing_secret) && ( +
+
Copy these credentials now. They are shown only once.
+ {externalHttpSecrets.api_key && ( + <> +
API Key
+
+ {externalHttpSecrets.api_key} + +
+ + )} + {externalHttpSecrets.signing_secret && ( + <> +
Signing Secret
+
+ {externalHttpSecrets.signing_secret} + +
+ + )} +
+ )} {ch.id === 'wechat' && (
Status
@@ -1105,6 +1250,12 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, } else if (ch.id === 'atlassian') { prefill.api_key = ''; prefill.cloud_id = config.cloud_id || ''; + } else if (ch.id === 'external_http') { + prefill.require_hmac = config.extra_config?.require_hmac ? 'true' : 'false'; + prefill.sync_timeout_seconds = String(config.extra_config?.sync_timeout_seconds || 120); + prefill.max_payload_bytes = String(config.extra_config?.max_payload_bytes || 65536); + prefill.regenerate_api_key = 'false'; + prefill.regenerate_signing_secret = 'false'; } setForms(prev => ({ ...prev, [ch.id]: prefill })); setEditing(ch.id, true); diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f7cefc213..48867bbba 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1624,7 +1624,8 @@ "wechat": "WeChat", "whatsapp": "WhatsApp", "dingtalk": "DingTalk", - "wecom": "WeCom" + "wecom": "WeCom", + "externalHttp": "External HTTP" }, "searching": "Searching...", "upload": "⬆ Upload", @@ -1703,6 +1704,13 @@ "feishuPermFullDesc": "Includes Docs, Drive, Bitable, Calendar, Approval", "feishuPermCopy": "Copy", "feishuPermCopied": "✓ Copied", + "externalHttp": { + "step1": "Open this agent's settings and save the External HTTP channel configuration.", + "step2": "Copy the API Key shown after saving. It is displayed only once.", + "step3": "Send POST requests to the message endpoint with Authorization: Bearer .", + "step4": "Use conversation_id to merge related external calls into the same web session.", + "note": "Enable HMAC for production integrations. When enabled, sign '.' with the signing secret and send X-Timestamp and X-Signature-SHA256." + }, "slack": { "step1": "Go to api.slack.com/apps → click Create New App → choose From Scratch → enter an app name and pick your workspace → click Create App", "step2": "In the left sidebar, click OAuth & Permissions → scroll down to Scopes → under Bot Token Scopes click Add an OAuth Scope → add: chat:write, im:history, app_mentions:read", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 964a97ee2..280a3ba74 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -1717,7 +1717,8 @@ "wechat": "微信", "whatsapp": "WhatsApp", "dingtalk": "钉钉", - "wecom": "企业微信" + "wecom": "企业微信", + "externalHttp": "外部 HTTP" }, "searching": "搜索中...", "upload": "⬆ 上传", @@ -1877,6 +1878,13 @@ "feishuPermFullDesc": "包含文档、云空间、多维表格、日历、审批等全部功能", "feishuPermCopy": "复制", "feishuPermCopied": "✓ 已复制", + "externalHttp": { + "step1": "在此数字员工设置页保存 External HTTP 渠道配置。", + "step2": "复制保存后显示的 API Key。该密钥只显示一次。", + "step3": "外部系统使用 Authorization: Bearer 调用消息接口。", + "step4": "使用 conversation_id 将同一业务对象的多次调用归并到同一个网页会话。", + "note": "生产环境建议启用 HMAC。启用后,使用签名密钥对 '.' 做签名,并传入 X-Timestamp 和 X-Signature-SHA256。" + }, "slack": { "step1": "打开 api.slack.com/apps → 点击 Create New App → 选择 From Scratch → 填写应用名称并选择你的 Workspace → 点击 Create App", "step2": "左侧菜单点击 OAuth & Permissions → 向下滚动到 Scopes → 在 Bot Token Scopes 下点击 Add an OAuth Scope → 依次添加:chat:write、im:history、app_mentions:read", @@ -2198,4 +2206,4 @@ "loginWith": "使用 {{provider}} 登录", "noProviders": "未配置 SSO 提供商。" } -} \ No newline at end of file +}