diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index a8901e831..629d5f091 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -5,9 +5,11 @@ import json import re import uuid +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import HTMLResponse, Response +from voluptuous import message from lark_oapi.core.utils import AESCipher from loguru import logger from sqlalchemy import select @@ -234,7 +236,7 @@ async def configure_channel( existing.app_secret = data.app_secret existing.encrypt_key = data.encrypt_key existing.verification_token = data.verification_token - existing.extra_config = data.extra_config or {} + existing.extra_config = {**(existing.extra_config or {}), **(data.extra_config or {})} existing.is_configured = True await db.flush() @@ -557,6 +559,15 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict): ) return {"code": 0, "msg": "non-user group message ignored"} + # 兜底:发送者就是机器人自己 → 忽略,防止回执/自我消息被当成用户消息回环处理 + if chat_type == "group" and sender_open_id: + bot_open_id = await feishu_service.get_bot_open_id( + config.app_id, config.app_secret + ) + if bot_open_id and sender_open_id == bot_open_id: + logger.info("[Feishu] Ignoring self-sent bot message") + return {"code": 0, "msg": "bot self message ignored"} + logger.info(f"[Feishu] Received {msg_type} message, chat_type={chat_type}, open_id={sender_open_id!r}, user_id_from_event={sender_user_id_from_event!r}") # ── Normalize post (rich text) → extract text + schedule image downloads ── @@ -628,8 +639,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict): # Rewrite as text message so existing handler processes it message["content"] = _json_post.dumps({"text": _final_content}) msg_type = "text" - logger.info(f"[Feishu] Normalized post → text='{_extracted_text[:100]}', images={len(_image_markers)}") - + if msg_type in ("file", "image"): attachment = await _accept_feishu_file_runtime( agent_id=agent_id, @@ -652,12 +662,110 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict): return {"code": 0, "msg": "unsupported message type"} content = json.loads(message.get("content", "{}")) - user_text = _restore_feishu_text_mentions( - content.get("text", ""), - message.get("mentions"), + raw_text = content.get("text", "") + + from app.services.agent_runtime.channel_activation import ( + prepare_llm_user_text, + resolve_effective_mode, ) + effective_mode, keywords = resolve_effective_mode(config.extra_config, chat_id) + + full_text = re.sub(r"\s+", " ", re.sub(r"@[^\s@]+", "", raw_text)).strip() + + bot_tokens: list[str] = [] + if chat_type == "group" and effective_mode in ("mention", "keyword"): + bot_open_id_for_text = await feishu_service.get_bot_open_id( + config.app_id, config.app_secret + ) + if bot_open_id_for_text: + for _m in (message.get("mentions") or []): + if (_m.get("id") or {}).get("open_id") == bot_open_id_for_text: + if _m.get("key"): + bot_tokens.append(_m["key"]) + if _m.get("name"): + bot_tokens.append(_m["name"]) + user_text = prepare_llm_user_text( + mode=effective_mode, + keywords=keywords, + raw_text=raw_text, + full_text=full_text, + bot_tokens=bot_tokens, + ) + + if chat_type == "group" and full_text: + cmd_text = re.sub(r"^@[^\s]+", "", full_text).strip() + cmd_match = re.match( + r"^/config\s+mode\s+([a-zA-Z]+)(?:\s+([\w一-龥,,、\s]+))?$", + cmd_text, + ) + if cmd_match: + _cmd_result = await _handle_group_config_command( + config=config, + chat_id=chat_id, + sender_open_id=sender_open_id, + match=cmd_match, + ) + if event_id: + _processed_events.add(event_id) + if len(_processed_events) > 1000: + _processed_events.clear() + return _cmd_result + + from app.services.agent_runtime.channel_activation import evaluate_group_activation + is_mentioned = False + should_process = True + skip_reason = None + # if chat_type == "group": + # # 飞书特有的 mention 检测(保留) + # if effective_mode in ("mention", "keyword"): + # bot_open_id = await feishu_service.get_bot_open_id( + # config.app_id, config.app_secret + # ) + # mentions = message.get("mentions", []) or [] + # is_mentioned = any( + # (m.get("id") or {}).get("open_id") == bot_open_id + # for m in mentions + # ) + if chat_type == "group": + if effective_mode in ("mention", "keyword"): + extra = config.extra_config or {} + + bot_open_id = extra.get("bot_open_id") + + if not bot_open_id: + bot_open_id = await feishu_service.get_bot_open_id( + config.app_id, config.app_secret + ) + + mentions = message.get("mentions", []) or [] + is_mentioned = any( + (m.get("id") or {}).get("open_id") == bot_open_id + for m in mentions + ) + + logger.info( + "[Feishu] mention check: effective_mode={!r} bot_open_id={!r} mentions={!r} is_mentioned={}", + effective_mode, + bot_open_id, + mentions, + is_mentioned, + ) + should_process, skip_reason = await evaluate_group_activation( + extra_config=config.extra_config, + chat_type=chat_type, + chat_id=chat_id, + user_text=full_text, + is_mentioned=is_mentioned, + ) + + if not should_process: + return {"code": 0, "msg": skip_reason} + if not user_text: - return {"code": 0, "msg": "empty message after stripping mentions"} + if not is_mentioned: + return {"code": 0, "msg": "empty message after stripping mentions"} + user_text = "你好,请问有什么可以帮你的?" + executable_content = f"[群聊中@了机器人] {user_text}" if is_mentioned else user_text display_content = re.sub( r"\[image_data:data:image/[^;]+;base64,[A-Za-z0-9+/=]+\]", @@ -675,16 +783,15 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict): sender_user_id=sender_user_id_from_event, chat_type=chat_type, chat_id=chat_id, - content=user_text, + content=executable_content, display_content=display_content, external_event_id=message.get("message_id") or event_id, ) except Exception as exc: from app.services.channel_user_service import ChannelUserResolutionError - if not isinstance(exc, ChannelUserResolutionError): raise - logger.warning(f"[Feishu] Sender resolution refused: {exc}") + reply_target = chat_id if chat_type == "group" else sender_open_id receive_id_type = "chat_id" if chat_type == "group" else "open_id" await feishu_service.send_message( @@ -705,6 +812,113 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict): return {"code": 0, "msg": "ok"} +async def _handle_group_config_command( + *, + config: ChannelConfig, + chat_id: str, + sender_open_id: str, + match: Any, +) -> dict: + """群主在群里发命令改本群激活配置。 + + 只更新 extra_config.group_overrides[chat_id],不碰凭证字段(避免把 + app_secret / encrypt_key 等隐私授权给群主)。命令格式: + /config mode silent + /config mode mention + /config mode always + /config mode keyword 价格,周报 + """ + import re as _re + + mode_raw = (match.group(1) or "").lower() + mode_map = { + "mention": "mention", + "keyword": "keyword", + "always": "always", + "silent": "silent", + } + mode = mode_map.get(mode_raw) + if mode is None: + await feishu_service.send_message( + config.app_id, + config.app_secret, + chat_id, + "text", + json.dumps( + { + "text": ( + f"无效模式:{mode_raw}。\n" + "可用模式:\n" + "- mention 仅@机器人时响应\n" + "- keyword 命中关键词时响应\n" + "- always 所有消息都响应\n" + "- silent 群聊内静默\n" + "用法:/config mode <模式> [关键词]" + ) + } + ), + receive_id_type="chat_id", + ) + return {"code": 0, "msg": "unknown_command"} + + # ① 群主校验(fail-closed:查不到 owner_id 一律拒绝) + owner_id = await feishu_service.get_group_owner_id( + config.app_id, config.app_secret, chat_id + ) + if not owner_id or owner_id != sender_open_id: + await feishu_service.send_message( + config.app_id, + config.app_secret, + chat_id, + "text", + json.dumps({"text": "仅群主可配置本群激活方式。"}), + receive_id_type="chat_id", + ) + return {"code": 0, "msg": "not_owner"} + + # ② 解析关键词 + keywords: list[str] = [] + if mode == "keyword" and match.group(2): + keywords = [ + k.strip() for k in _re.split(r"[,,、\s]+", match.group(2)) if k.strip() + ] + + # ③ 写入 group_overrides(读-改-写,只动这一个群) + async with _async_session() as db: + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == config.agent_id, + ChannelConfig.channel_type == "feishu", + ) + ) + db_config = result.scalar_one_or_none() + if db_config is None: + return {"code": 0, "msg": "channel_not_found"} + extra = dict(db_config.extra_config or {}) + overrides = dict(extra.get("group_overrides") or {}) + entry: dict = {"activation_mode": mode} + if keywords: + entry["keywords"] = keywords + overrides[chat_id] = entry + extra["group_overrides"] = overrides + db_config.extra_config = extra + await db.commit() + + # ④ 回执 + desc = mode + if keywords: + desc += f" keywords={', '.join(keywords)}" + await feishu_service.send_message( + config.app_id, + config.app_secret, + chat_id, + "text", + json.dumps({"text": f"已配置:本群激活方式 = {desc}"}), + receive_id_type="chat_id", + ) + return {"code": 0, "msg": "ok"} + + async def _accept_feishu_file_runtime( *, agent_id: uuid.UUID, diff --git a/backend/app/services/agent_runtime/channel_activation.py b/backend/app/services/agent_runtime/channel_activation.py new file mode 100644 index 000000000..d2e9d4a43 --- /dev/null +++ b/backend/app/services/agent_runtime/channel_activation.py @@ -0,0 +1,108 @@ +"""通用群聊激活逻辑(跨渠道复用)。 + +提供: +- resolve_effective_mode:解析生效模式与关键词(群级覆盖优先 → 通道级 → 默认 mention) +- extract_after_mention:从原始文本提取 @机器人 之后到下一个 @ 之前的内容 +- prepare_llm_user_text:按模式决定给 LLM 的内容(mention 取@后 / keyword 命中整句 / always 整句) +- evaluate_group_activation:群聊激活门禁判断(是否放行) +""" + +import re + + +def resolve_effective_mode( + extra_config: dict | None, + chat_id: str, +) -> tuple[str, list]: + """解析生效的激活模式与关键词(群级覆盖优先 → 通道级 → 默认 mention)。 + + 返回 (mode, keywords)。 + """ + extra = extra_config or {} + group_cfg = (extra.get("group_overrides") or {}).get(chat_id) or {} + mode = group_cfg.get("activation_mode", extra.get("activation_mode", "mention")) + keywords = group_cfg.get("keywords", extra.get("keywords", [])) + return mode, keywords or [] + + +def extract_after_mention(raw_text: str, bot_tokens: list[str]) -> str | None: + """从含 @ 的原始文本中,提取第一个机器人 @ 标记之后、到下一个 @ 之前的内容。 + + 例如 raw_text="@user_1 内容A @机器人 内容B @user_2 内容C", + bot_tokens=["@机器人", "@_user_1"] → 返回 "内容B"。 + + 返回提取到的内容(可能为空串,表示纯@无内容); + 找不到任何 bot token 时返回 None,调用方退化为整句。 + """ + if not raw_text or not bot_tokens: + return None + for token in bot_tokens: + if not token: + continue + at_token = token if token.startswith("@") else "@" + token + idx = raw_text.find(at_token) + if idx == -1: + continue + rest = raw_text[idx + len(at_token):] + nxt = re.search(r"@[^\s@]+", rest) + rest = rest[:nxt.start()] if nxt else rest + return rest.strip() + return None + + +def prepare_llm_user_text( + *, + mode: str, + keywords: list, + raw_text: str, + full_text: str, + bot_tokens: list[str], +) -> str: + """按激活模式决定给 LLM 的内容。 + + - mention:只取 @机器人 之后的召唤内容 + - keyword:关键词命中整句 → 整句;未命中但被@ → 取 @机器人 之后内容;都无 → 整句兜底 + - always:整句(silent 不会走到这里) + """ + if mode in ("mention", "keyword"): + extracted = extract_after_mention(raw_text, bot_tokens) + if mode == "mention": + return extracted if extracted is not None else full_text + # keyword + hit = bool(keywords) and any(k in full_text for k in keywords) + if hit: + return full_text + return extracted if extracted is not None else full_text + return full_text # always + + +async def evaluate_group_activation( + *, + extra_config: dict | None, + chat_type: str, + chat_id: str, + user_text: str, + is_mentioned: bool, +) -> tuple[bool, str | None]: + """通用群聊激活门禁判断。 + + 返回 (should_process, reason): + - should_process=True → 放行进 agent 管线 + - should_process=False → 静默,reason 是跳过原因(用于日志/响应 msg) + """ + if chat_type != "group": + return True, None # 私聊始终处理 + + mode, keywords = resolve_effective_mode(extra_config, chat_id) + + if mode == "silent": + return False, "silent_mode" + if mode == "mention": + return (is_mentioned, None if is_mentioned else "not_mentioned") + if mode == "keyword": + if is_mentioned: + return True, None # 被@也算召唤 + if keywords and not any(k in user_text for k in keywords): + return False, "keyword_not_matched" + return True, None + return True, None # always diff --git a/backend/app/services/feishu_service.py b/backend/app/services/feishu_service.py index fbdf40030..27bd4774a 100644 --- a/backend/app/services/feishu_service.py +++ b/backend/app/services/feishu_service.py @@ -1,6 +1,7 @@ """Feishu (Lark) OAuth and API integration service.""" import json +import time from collections import OrderedDict import httpx @@ -402,6 +403,81 @@ async def send_message( ) data = self._parse_api_response(resp, stage=stage) return data + _BOT_OPEN_ID_TTL_SECONDS = 6 * 60 * 60 # 6h + _bot_open_id_cache: dict[tuple[str, str], tuple[str, float]] = {} + + async def get_bot_open_id(self, app_id: str, app_secret: str) -> str | None: + if not app_id or not app_secret: + return None + now = time.time() + cached = self._bot_open_id_cache.get((app_id, app_secret)) + if cached: + open_id, ts = cached + if now - ts < self._BOT_OPEN_ID_TTL_SECONDS: + return open_id + + async with httpx.AsyncClient() as client: + token_resp = await client.post( + FEISHU_APP_TOKEN_URL, + json={"app_id": app_id, "app_secret": app_secret}, + ) + app_token = (token_resp.json() or {}).get("app_access_token", "") + if not app_token: + logger.warning("[Feishu] get_bot_open_id: no app_access_token") + return None + + resp = await client.get( + "https://open.feishu.cn/open-apis/bot/v3/info", + headers={"Authorization": f"Bearer {app_token}"}, + ) + data = resp.json() + logger.info( + f"[Feishu] get_bot_open_id response: " + f"status={resp.status_code} data={data!r}" + ) + if data.get("code") != 0: + logger.warning( + f"[Feishu] get_bot_open_id failed: code={data.get('code')} msg={data.get('msg')}" + ) + return None + open_id = ((data.get("bot") or {}).get("open_id") or "").strip() or None + + if open_id: + self._bot_open_id_cache[(app_id, app_secret)] = (open_id, now) + return open_id + + async def get_group_owner_id( + self, app_id: str, app_secret: str, chat_id: str + ) -> str | None: + """Return the group owner's open_id for a Feishu chat. + + Calls GET /open-apis/im/v1/chats/{chat_id}. Returns the owner's open_id, + or None when credentials/chat_id are missing or the API fails — callers + must treat None as "cannot verify" (fail-closed). + """ + if not app_id or not app_secret or not chat_id: + return None + async with httpx.AsyncClient() as client: + token_resp = await client.post( + FEISHU_APP_TOKEN_URL, + json={"app_id": app_id, "app_secret": app_secret}, + ) + app_token = (token_resp.json() or {}).get("app_access_token", "") + if not app_token: + logger.warning("[Feishu] get_group_owner_id: no app_access_token") + return None + resp = await client.get( + f"https://open.feishu.cn/open-apis/im/v1/chats/{chat_id}", + headers={"Authorization": f"Bearer {app_token}"}, + ) + data = resp.json() + if data.get("code") != 0: + logger.warning( + f"[Feishu] get_group_owner_id failed: code={data.get('code')} msg={data.get('msg')}" + ) + return None + owner_id = ((data.get("data") or {}).get("owner_id") or "").strip() or None + return owner_id async def patch_message( self, diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index be62d3cb0..2aef34981 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -73,6 +73,7 @@ interface ChannelDef { wsFields?: ChannelField[]; // Atlassian-specific test connection feature hasTestConnection?: boolean; + groupActivation?: boolean; } // ─── SVG Icons ────────────────────────────────────────── @@ -154,6 +155,7 @@ const CHANNEL_REGISTRY: ChannelDef[] = [ desc: 'Feishu / Lark', useChannelApi: true, connectionMode: true, + groupActivation: true, fields: [ { key: 'app_id', label: 'App ID', placeholder: 'cli_xxxxxxxxxxxxxxxx', required: true }, { key: 'app_secret', label: 'App Secret', type: 'password', required: true }, @@ -328,6 +330,13 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, discord: 'gateway', }); + const [activationModes, setActivationModes] = useState>({ + feishu: 'mention', + }); + const [activationKeywords, setActivationKeywords] = useState>({ + feishu: '', + }); + // Password visibility const [showPwds, setShowPwds] = useState>({}); const togglePwd = (fieldId: string) => setShowPwds(p => ({ ...p, [fieldId]: !p[fieldId] })); @@ -643,20 +652,42 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, // ─── Build save payload for a channel ─────────────── const buildPayload = (ch: ChannelDef, form: Record) => { if (ch.id === 'feishu') { + const keywords = (activationKeywords[ch.id] || '') + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); return { channel_type: 'feishu', app_id: form.app_id, app_secret: form.app_secret, encrypt_key: form.encrypt_key || undefined, - extra_config: { connection_mode: connectionModes.feishu || 'websocket' }, + extra_config: { + connection_mode: connectionModes[ch.id] || 'websocket', + ...(ch.groupActivation ? { + activation_mode: activationModes[ch.id] || 'mention', + ...(keywords.length ? { keywords } : {}), + } : {}), + }, }; } if (ch.id === 'wecom') { const connMode = connectionModes.wecom || 'websocket'; + const keywords = (activationKeywords[ch.id] || '') + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); if (connMode === 'websocket') { return { connection_mode: 'websocket', bot_id: form.bot_id, bot_secret: form.bot_secret }; } - return { ...form, connection_mode: 'webhook' }; + return { + ...form, + extra_config: { + ...(ch.groupActivation ? { + activation_mode: activationModes[ch.id] || 'mention', + ...(keywords.length ? { keywords } : {}), + } : {}), + }, + }; } if (ch.id === 'discord') { const connMode = connectionModes.discord || 'gateway'; @@ -834,6 +865,33 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, )} + {/* Feishu group activation mode (create mode) */} + {ch.groupActivation && ( +
+ + + {(values?.[`${ch.id}_activation_mode`] || 'mention') === 'keyword' && ( + onChange?.({ ...values, [`${ch.id}_keywords`]: e.target.value })} + placeholder={t('agent.settings.channel.keywordsPlaceholder')} + style={{ width: '100%', padding: '6px 8px', fontSize: '12px', borderRadius: '6px', border: '1px solid var(--border-default)', background: 'var(--bg-elevated)', color: 'var(--text-primary)', marginTop: '8px' }} + /> + )} +
{t('agent.settings.channel.activationHint')}
+
+ )} + {renderGuide(ch.guide, !!isWs, ch)} {formFields.map(field => ( @@ -1066,6 +1124,11 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, onClick={() => { // Populate form with existing config data const prefill: Record = {}; + // 通用回填激活模式(所有 groupActivation 渠道) + if (ch.groupActivation) { + setActivationModes(prev => ({ ...prev, [ch.id]: config.extra_config?.activation_mode || 'mention' })); + setActivationKeywords(prev => ({ ...prev, [ch.id]: Array.isArray(config.extra_config?.keywords) ? config.extra_config.keywords.join(', ') : (config.extra_config?.keywords || '') })); + } if (ch.id === 'feishu') { prefill.app_id = config.app_id || ''; prefill.app_secret = config.app_secret || ''; @@ -1074,6 +1137,8 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, } else if (ch.id === 'wecom') { const cm = config.extra_config?.connection_mode === 'websocket' ? 'websocket' : 'webhook'; setConnectionModes(prev => ({ ...prev, wecom: cm })); + setActivationModes(prev => ({ ...prev, [ch.id]: config.extra_config?.activation_mode || 'mention' })); + setActivationKeywords(prev => ({ ...prev, [ch.id]: Array.isArray(config.extra_config?.keywords) ? config.extra_config.keywords.join(', ') : (config.extra_config?.keywords || '') })); if (cm === 'websocket') { prefill.bot_id = config.extra_config?.bot_id || ''; prefill.bot_secret = config.extra_config?.bot_secret || ''; @@ -1176,6 +1241,33 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, )} + {/* Feishu group activation mode */} + {ch.groupActivation && ( +
+ + + {(activationModes[ch.id] || 'mention') === 'keyword' && ( + setActivationKeywords(prev => ({ ...prev, [ch.id]: e.target.value }))} + placeholder={t('agent.settings.channel.keywordsPlaceholder')} + style={{ width: '100%', padding: '6px 8px', fontSize: '12px', borderRadius: '6px', border: '1px solid var(--border-default)', background: 'var(--bg-elevated)', color: 'var(--text-primary)', marginTop: '8px' }} + /> + )} +
{t('agent.settings.channel.activationHint')}
+
+ )} + {renderGuide(ch.guide, !!isWs, ch)} {/* Form fields */} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 4c421feb1..4ba2328c5 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -549,7 +549,14 @@ "websocketConnected": "Connected via WebSocket (No callback URL needed)", "websocketDisconnected": "Configured for WebSocket, but currently disconnected", "websocketDisconnectedHint": "Reconnect by saving the WeCom WebSocket configuration again.", - "saveChannel": "Save Config" + "saveChannel": "Save Config", + "activationMode": "Group Activation Mode", + "activationMention": "Mention only (recommended)", + "activationKeyword": "Keyword trigger", + "activationAlways": "Always respond", + "activationSilent": "Silent in groups", + "keywordsPlaceholder": "Comma separated, e.g. 价格,周报", + "activationHint": "Controls when the bot responds to group messages. Private chats always get answered." }, "danger": { "title": "Danger Zone", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 905d1b2a1..7df14bec3 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -558,7 +558,14 @@ "websocketConnected": "已通过 WebSocket 连接(无需回调 URL)", "websocketDisconnected": "已配置为 WebSocket,但当前未连接", "websocketDisconnectedHint": "重新保存一次企业微信 WebSocket 配置即可重新连接。", - "saveChannel": "保存配置" + "saveChannel": "保存配置", + "activationMode": "群聊激活方式", + "activationMention": "仅 @ 机器人时响应(推荐)", + "activationKeyword": "命中关键词时响应", + "activationAlways": "所有消息都响应", + "activationSilent": "群聊内静默", + "keywordsPlaceholder": "用逗号分隔,例如:价格,周报", + "activationHint": "控制机器人在群聊中何时响应。私聊始终会回复。" }, "danger": { "title": "危险操作", diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 95cb4230f..991012183 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -11,6 +11,18 @@ import { buildOpenClawInstruction } from '../utils/openClawInstruction'; const STEPS = ['basicInfo', 'personality', 'skills', 'permissions', 'channel'] as const; const OPENCLAW_STEPS = ['basicInfo', 'permissions'] as const; +const groupActivationExtra = (channelValues: Record, channelId: string) => { + const mode = channelValues[`${channelId}_activation_mode`] || 'mention'; + const keywords = (channelValues[`${channelId}_keywords`] || '') + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + return { + activation_mode: mode, + ...(keywords.length ? { keywords } : {}), + }; +}; + export default function AgentCreate() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); @@ -103,7 +115,8 @@ export default function AgentCreate() { app_secret: channelValues.feishu_app_secret, encrypt_key: channelValues.feishu_encrypt_key || undefined, extra_config: { - connection_mode: channelValues.feishu_connection_mode || 'websocket' + connection_mode: channelValues.feishu_connection_mode || 'websocket', + ...groupActivationExtra(channelValues, 'feishu'), } }); } catch (err) {