diff --git a/backend/app/api/agent_groups.py b/backend/app/api/agent_groups.py new file mode 100644 index 00000000..9f3e77d8 --- /dev/null +++ b/backend/app/api/agent_groups.py @@ -0,0 +1,97 @@ +"""Agent Group Relationships API.""" + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.agent import Agent +from app.models.org import AgentGroup +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/agents", tags=["agent-groups"]) + + +class AgentGroupCreate(BaseModel): + group_name: str = Field(min_length=1, max_length=100) + chat_id: str = Field(min_length=1, max_length=200) + channel: str = Field(default="feishu") + description: str = "" + + +class AgentGroupOut(BaseModel): + id: uuid.UUID + agent_id: uuid.UUID + group_name: str + chat_id: str + channel: str + description: str + created_at: datetime + + model_config = {"from_attributes": True} + + +@router.get("/{agent_id}/relationships/groups", response_model=list[AgentGroupOut]) +async def list_groups( + agent_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(AgentGroup) + .where(AgentGroup.agent_id == agent_id) + .order_by(AgentGroup.created_at.desc()) + ) + groups = result.scalars().all() + return [AgentGroupOut.model_validate(g) for g in groups] + + +@router.put("/{agent_id}/relationships/groups", response_model=list[AgentGroupOut]) +async def update_groups( + agent_id: uuid.UUID, + groups: list[AgentGroupCreate], + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + await db.execute( + delete(AgentGroup).where(AgentGroup.agent_id == agent_id) + ) + new_groups = [] + for g in groups: + new_group = AgentGroup( + agent_id=agent_id, + group_name=g.group_name, + chat_id=g.chat_id, + channel=g.channel, + description=g.description, + ) + db.add(new_group) + new_groups.append(new_group) + await db.commit() + for g in new_groups: + await db.refresh(g) + return [AgentGroupOut.model_validate(g) for g in new_groups] + + +@router.delete("/{agent_id}/relationships/groups/{group_id}") +async def delete_group( + agent_id: uuid.UUID, + group_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + delete(AgentGroup).where( + AgentGroup.id == group_id, + AgentGroup.agent_id == agent_id, + ) + ) + await db.commit() + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Group not found") + return {"status": "ok"} diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index 8182562b..af00cad9 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -518,6 +518,9 @@ async def send_message( agent = await _get_agent_by_key(x_api_key, db) agent.openclaw_last_seen = datetime.now(timezone.utc) + # Initialize broadcast results + broadcast_results = [] + target_name = body.target.strip() content = body.content.strip() channel_hint = (body.channel or "").strip().lower() @@ -544,10 +547,69 @@ async def send_message( ) db.add(gw_msg) await db.commit() + + # Broadcast to groups if destinations specified + if body.destinations: + from app.models.channel_config import ChannelConfig + from app.services.feishu_service import feishu_service + import json as _json + + config_result = await db.execute( + select(ChannelConfig).where(ChannelConfig.agent_id == agent.id) + ) + config = config_result.scalar_one_or_none() + + if config and config.extra_config: + broadcast_groups = config.extra_config.get("broadcast_groups", []) + + for dest in body.destinations: + matched_group = None + for group in broadcast_groups: + if (group.get("channel") == dest.channel and + group.get("name") == dest.group): + matched_group = group + break + + if matched_group: + chat_id = matched_group.get("chat_id") + status = "failed" + + try: + if dest.channel == "feishu": + resp = await feishu_service.send_message( + config.app_id, config.app_secret, + receive_id=chat_id, + msg_type="text", + content=_json.dumps({"text": content}, ensure_ascii=False), + receive_id_type="chat_id" + ) + status = "sent" if resp.get("code") == 0 else "failed" + elif dest.channel == "wecom": + status = "not_implemented" + elif dest.channel == "dingtalk": + status = "not_implemented" + except Exception as e: + logger.error(f"[Gateway] Broadcast error: {e}") + status = "error" + + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "chat_id": chat_id, + "status": status + }) + else: + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "status": "not_found" + }) + return { "status": "accepted", "target": target_agent.name, "type": "openclaw_agent", + "broadcast": broadcast_results, "message": f"Message sent to {target_agent.name}. Reply will appear in your next poll.", } else: @@ -567,13 +629,133 @@ async def send_message( )) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) + + # Broadcast to groups if destinations specified + if body.destinations: + from app.models.channel_config import ChannelConfig + from app.services.feishu_service import feishu_service + import json as _json + + config_result = await db.execute( + select(ChannelConfig).where(ChannelConfig.agent_id == agent.id) + ) + config = config_result.scalar_one_or_none() + + if config and config.extra_config: + broadcast_groups = config.extra_config.get("broadcast_groups", []) + + for dest in body.destinations: + matched_group = None + for group in broadcast_groups: + if (group.get("channel") == dest.channel and + group.get("name") == dest.group): + matched_group = group + break + + if matched_group: + chat_id = matched_group.get("chat_id") + status = "failed" + + try: + if dest.channel == "feishu": + resp = await feishu_service.send_message( + config.app_id, config.app_secret, + receive_id=chat_id, + msg_type="text", + content=_json.dumps({"text": content}, ensure_ascii=False), + receive_id_type="chat_id" + ) + status = "sent" if resp.get("code") == 0 else "failed" + elif dest.channel == "wecom": + status = "not_implemented" + elif dest.channel == "dingtalk": + status = "not_implemented" + except Exception as e: + logger.error(f"[Gateway] Broadcast error: {e}") + status = "error" + + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "chat_id": chat_id, + "status": status + }) + else: + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "status": "not_found" + }) + return { "status": "accepted", "target": target_agent.name, "type": "agent", + "broadcast": broadcast_results, "message": f"Message sent to {target_agent.name}. Reply will appear in your next poll.", } + + # 1.5. Try to find target as a Group (via AgentGroup relationships) + from app.models.org import AgentGroup + group_result = await db.execute( + select(AgentGroup).where( + AgentGroup.agent_id == agent.id, + AgentGroup.group_name.ilike(f"%{target_name}%") + ) + ) + target_group = group_result.scalars().first() + + if target_group: + from app.models.channel_config import ChannelConfig + from app.services.feishu_service import feishu_service + import json as _json + + config_result = await db.execute( + select(ChannelConfig).where(ChannelConfig.agent_id == agent.id) + ) + config = config_result.scalar_one_or_none() + + if not config: + await db.commit() + raise HTTPException(status_code=400, detail="No channel configured for this agent") + + status = "failed" + resp = None + try: + if target_group.channel == "feishu": + resp = await feishu_service.send_message( + config.app_id, config.app_secret, + receive_id=target_group.chat_id, + msg_type="text", + content=_json.dumps({"text": content}, ensure_ascii=False), + receive_id_type="chat_id" + ) + status = "sent" if resp.get("code") == 0 else "failed" + elif target_group.channel == "wecom": + status = "not_implemented" + elif target_group.channel == "dingtalk": + status = "not_implemented" + except Exception as e: + logger.error(f"[Gateway] Group send error: {e}") + status = "error" + + await db.commit() + + if status == "sent": + return { + "status": "sent", + "target": target_group.group_name, + "type": "group", + "channel": target_group.channel, + "message": f"Message sent to group {target_group.group_name}.", + } + else: + raise HTTPException( + status_code=502, + detail=f"Failed to send to group {target_group.group_name}: {resp.get('msg') if resp else 'unknown error'} (code {resp.get('code') if resp else 'N/A'})" + ) + # 2. Try to find target as a human (via relationships) from app.models.org import AgentRelationship from sqlalchemy.orm import selectinload @@ -646,11 +828,69 @@ async def send_message( await db.commit() if resp and resp.get("code") == 0: + # Broadcast to groups if destinations specified + if body.destinations: + from app.models.channel_config import ChannelConfig + from app.services.feishu_service import feishu_service + import json as _json + + config_result = await db.execute( + select(ChannelConfig).where(ChannelConfig.agent_id == agent.id) + ) + config = config_result.scalar_one_or_none() + + if config and config.extra_config: + broadcast_groups = config.extra_config.get("broadcast_groups", []) + + for dest in body.destinations: + matched_group = None + for group in broadcast_groups: + if (group.get("channel") == dest.channel and + group.get("name") == dest.group): + matched_group = group + break + + if matched_group: + chat_id = matched_group.get("chat_id") + status = "failed" + + try: + if dest.channel == "feishu": + resp_bc = await feishu_service.send_message( + config.app_id, config.app_secret, + receive_id=chat_id, + msg_type="text", + content=_json.dumps({"text": content}, ensure_ascii=False), + receive_id_type="chat_id" + ) + status = "sent" if resp_bc.get("code") == 0 else "failed" + elif dest.channel == "wecom": + status = "not_implemented" + elif dest.channel == "dingtalk": + status = "not_implemented" + except Exception as e: + logger.error(f"[Gateway] Broadcast error: {e}") + status = "error" + + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "chat_id": chat_id, + "status": status + }) + else: + broadcast_results.append({ + "channel": dest.channel, + "group": dest.group, + "status": "not_found" + }) + return { "status": "sent", "target": target_member.name, "type": "human", "channel": "feishu", + "broadcast": broadcast_results, } else: raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index 83c46dba..b585a03a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -279,9 +279,11 @@ def _bg_task_error(t): from app.api.webhooks import router as webhooks_router from app.api.notification import router as notification_router from app.api.gateway import router as gateway_router +from app.api.agent_groups import router as agent_groups_router from app.api.admin import router as admin_router from app.api.pages import router as pages_router, public_router as pages_public_router +app.include_router(agent_groups_router, prefix=settings.API_PREFIX) app.include_router(auth_router, prefix=settings.API_PREFIX) app.include_router(agents_router, prefix=settings.API_PREFIX) app.include_router(tasks_router, prefix=settings.API_PREFIX) diff --git a/backend/app/models/org.py b/backend/app/models/org.py index 017e3318..a543075c 100644 --- a/backend/app/models/org.py +++ b/backend/app/models/org.py @@ -76,6 +76,22 @@ class AgentRelationship(Base): member: Mapped["OrgMember"] = relationship() + + +class AgentGroup(Base): + """Group relationship for an agent - allows broadcasting to chat groups.""" + + __tablename__ = "agent_groups" + + 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", ondelete="CASCADE"), nullable=False) + group_name: Mapped[str] = mapped_column(String(100), nullable=False) + chat_id: Mapped[str] = mapped_column(String(200), nullable=False) + channel: Mapped[str] = mapped_column(String(20), nullable=False, default="feishu") # feishu | wecom | dingtalk + description: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class AgentAgentRelationship(Base): """Relationship between two agents (digital employees).""" diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 35172338..68f9efab 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -448,7 +448,15 @@ class GatewayReportRequest(BaseModel): result: str = Field(min_length=1) + + +class MessageDestination(BaseModel): + """Destination for broadcasting a message to a channel/group.""" + channel: str # feishu | wecom | dingtalk + group: str # Group name (matches config name) + class GatewaySendMessageRequest(BaseModel): target: str # Name of target person or agent content: str = Field(min_length=1) channel: str | None = None # Optional: "feishu", "agent", etc. Auto-detected if omitted. + destinations: list[MessageDestination] | None = None # Optional: broadcast destinations diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index f7347ca3..9947977b 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -2889,15 +2889,19 @@ async def _save_outgoing_to_feishu_session(open_id: str): async def _send_channel_message(agent_id: uuid.UUID, args: dict) -> str: """Send message via the recipient's configured channel (Feishu/DingTalk/WeCom). - 1. Find target user from relationships (AgentRelationship -> OrgMember) - 2. Determine user's provider type (via OrgMember.provider_id -> IdentityProvider) - 3. Find corresponding channel config (ChannelConfig) - 4. Send via the appropriate channel + 1. Check if target is a group in AgentGroup, send directly to group chat_id + 2. Otherwise find target user from relationships (AgentRelationship -> OrgMember) + 3. Determine user's provider type (via OrgMember.provider_id -> IdentityProvider) + 4. Find corresponding channel config (ChannelConfig) + 5. Send via the appropriate channel """ from sqlalchemy import select from sqlalchemy.orm import selectinload - from app.models.org import AgentRelationship, OrgMember + from app.models.org import AgentRelationship, OrgMember, AgentGroup from app.models.identity import IdentityProvider + from app.models.channel_config import ChannelConfig + from app.services.feishu_service import feishu_service + import json as _json member_name = (args.get("member_name") or "").strip() message_text = (args.get("message") or "").strip() @@ -2910,6 +2914,47 @@ async def _send_channel_message(agent_id: uuid.UUID, args: dict) -> str: try: async with async_session() as db: + # 0. Check if target is a group in AgentGroup + group_result = await db.execute( + select(AgentGroup).where( + AgentGroup.agent_id == agent_id, + AgentGroup.group_name.ilike(f"%{member_name}%") + ) + ) + target_group = group_result.scalars().first() + + if target_group: + # Send to group directly via feishu service + config_result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == target_group.channel, + ) + ) + config = config_result.scalar_one_or_none() + + if not config: + return f"❌ No {target_group.channel} channel configured for this agent" + + try: + if target_group.channel == "feishu": + resp = await feishu_service.send_message( + config.app_id, config.app_secret, + receive_id=target_group.chat_id, + msg_type="text", + content=_json.dumps({"text": message_text}, ensure_ascii=False), + receive_id_type="chat_id" + ) + if resp.get("code") == 0: + return f"✅ Message sent to group {target_group.group_name}" + else: + return f"❌ Failed to send to group: {resp.get('msg', 'unknown error')}" + else: + return f"❌ Group sending not yet supported for {target_group.channel}" + except Exception as e: + logger.error(f"[ChannelMessage] Group send error: {e}") + return f"❌ Group send error: {str(e)[:100]}" + # 1. Find target member from relationships with provider info (only active members) result = await db.execute( select(AgentRelationship, OrgMember, IdentityProvider) diff --git a/backend/app/services/org_sync_adapter.py b/backend/app/services/org_sync_adapter.py index b4300796..79004501 100644 --- a/backend/app/services/org_sync_adapter.py +++ b/backend/app/services/org_sync_adapter.py @@ -647,8 +647,149 @@ async def fetch_children(parent_id: str): logger.info(f"Feishu fetched {len(all_depts)} departments total.") return all_depts - async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: - """Fetch users in a department.""" + async def sync_org_structure(self, db: AsyncSession) -> dict[str, Any]: + """Override to use global user list API so we can get users regardless of department hierarchy.""" + errors = [] + dept_count = 0 + member_count = 0 + user_count = 0 + profile_count = 0 + sync_start = datetime.now() + + provider = await self._ensure_provider(db) + + try: + # Fetch and sync departments + departments = await self.fetch_departments() + for dept in departments: + try: + async with db.begin_nested(): + await self._upsert_department(db, provider, dept) + dept_count += 1 + except Exception as e: + errors.append(f"Department {dept.external_id}: {str(e)}") + logger.error(f"[OrgSync] Failed to sync department {dept.external_id}: {e}") + + # Fetch ALL users using global user list API (works even without department access) + all_users = await self._fetch_all_users() + logger.info(f"Feishu fetched {len(all_users)} total users globally.") + + for user in all_users: + try: + async with db.begin_nested(): + # Use first department from user's department_ids, fallback to "0" + dept_ext_id = user.department_ids[0] if user.department_ids else "0" + + # Ensure department exists - if not found, create it on the fly + dept_result = await db.execute( + select(OrgDepartment).where( + OrgDepartment.external_id == dept_ext_id, + OrgDepartment.provider_id == provider.id, + ) + ) + dept = dept_result.scalar_one_or_none() + if not dept: + # Check if department exists but was marked deleted + del_result = await db.execute( + select(OrgDepartment).where( + OrgDepartment.external_id == dept_ext_id, + OrgDepartment.provider_id == provider.id, + OrgDepartment.status == "deleted", + ) + ) + dept = del_result.scalar_one_or_none() + fetched_dept_name = None + if dept: + # Reactivate deleted department + dept.status = "active" + dept.synced_at = datetime.now() + if fetched_dept_name: + dept.name = fetched_dept_name + await db.flush() + logger.info(f"[OrgSync] Reactivated deleted department: {dept.external_id} -> {fetched_dept_name or dept.name}") + # Try to fetch real name for reactivated dept + try: + token = await self.get_access_token() + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://open.feishu.cn/open-apis/contact/v3/departments/{dept.external_id}", + params={"department_id_type": "open_department_id"}, + headers={"Authorization": f"Bearer {token}"}, + ) + data = resp.json() + if data.get("code") == 0: + fetched_dept_name = data.get("data", {}).get("department", {}).get("name") + except Exception: + pass + + if not dept: + # Fetch department details from Feishu API + dept_name = fetched_dept_name or f"部门{dept_ext_id[:8]}" + try: + token = await self.get_access_token() + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://open.feishu.cn/open-apis/contact/v3/departments/{dept_ext_id}", + params={"department_id_type": "open_department_id"}, + headers={"Authorization": f"Bearer {token}"}, + ) + data = resp.json() + if data.get("code") == 0: + dept_name = data.get("data", {}).get("department", {}).get("name", dept_name) + except Exception as e: + logger.warning(f"[OrgSync] Failed to fetch dept name for {dept_ext_id}: {e}") + + dept = OrgDepartment( + external_id=dept_ext_id, + provider_id=provider.id, + name=dept_name, + tenant_id=self.tenant_id, + synced_at=datetime.now(), + ) + db.add(dept) + await db.flush() + logger.warning(f"[OrgSync] Auto-created missing department: {dept_ext_id} - {dept_name}") + # Add to departments list so reconciliation doesn't delete it + departments.append(dept) + + stats = await self._upsert_member(db, provider, user, dept_ext_id) + if stats.get("user_created"): + user_count += 1 + if stats.get("profile_synced"): + profile_count += 1 + member_count += 1 + except Exception as e: + logger.error(f"[OrgSync] Failed to sync member {user.external_id} ({user.name}): {e}") + errors.append(f"Member {user.external_id}: {str(e)}") + + # Update provider metadata + if self.provider: + config = (self.provider.config or {}).copy() + config["last_synced_at"] = datetime.now().isoformat() + self.provider.config = config + await db.flush() + await self._reconcile(db, provider.id, sync_start) + await db.flush() + await self._update_member_counts(db, provider.id) + await db.flush() + + except Exception as e: + import traceback + logger.error(f"[OrgSync] Critical error during sync: {e}\n{traceback.format_exc()}") + errors.append(f"Critical: {str(e)}") + + return { + "departments": dept_count, + "members": member_count, + "users_created": user_count, + "profiles_synced": profile_count, + "errors": errors, + "provider": self.provider_type, + "synced_at": datetime.now().isoformat() + } + + async def _fetch_all_users(self) -> list[ExternalUser]: + """Fetch all users from Feishu using global users API.""" token = await self.get_access_token() users: list[ExternalUser] = [] page_token = "" @@ -656,41 +797,39 @@ async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: async with httpx.AsyncClient() as client: while True: params = { - "department_id": department_external_id, - "department_id_type": "open_department_id", - "user_id_type": "user_id", # Return stable user_ids for mapping "page_size": "50", + "user_id_type": "user_id", + "sort_type": "NameOrder", } if page_token: params["page_token"] = page_token resp = await client.get( - self.FEISHU_USERS_URL, + "https://open.feishu.cn/open-apis/contact/v3/users", params=params, headers={"Authorization": f"Bearer {token}"}, ) data = resp.json() if data.get("code") != 0: - logger.error(f"Feishu fetch users error for dept {department_external_id}: {data}") + logger.error(f"Feishu fetch all users error: {data}") break res_data = data.get("data", {}) items = res_data.get("items", []) or [] for item in items: - # Collect all departments the user belongs to for better mapping resolution raw_dept_ids = item.get("department_ids", []) - department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else [department_external_id] - + department_ids = [str(did) for did in raw_dept_ids] if raw_dept_ids else ["0"] + user = ExternalUser( - external_id=item.get("user_id", "") or item.get("open_id", ""), + external_id=item.get("user_id", "") or item.get("open_id", ""), open_id=item.get("open_id", ""), unionid=item.get("union_id", ""), name=item.get("name", ""), email=item.get("email", ""), avatar_url=item.get("avatar_url", ""), title=item.get("title", ""), - department_external_id=department_external_id, + department_external_id=department_ids[0] if department_ids else "0", department_ids=department_ids, mobile=item.get("mobile", ""), status="active" if item.get("status", {}).get("is_activated") else "inactive", @@ -699,11 +838,16 @@ async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: users.append(user) page_token = res_data.get("page_token", "") - if not page_token: + has_more = res_data.get("has_more", False) + if not has_more or not page_token: break return users + async def fetch_users(self, department_external_id: str) -> list[ExternalUser]: + # Dummy implementation - not used since we override sync_org_structure + return [] + class DingTalkOrgSyncAdapter(BaseOrgSyncAdapter): """DingTalk organization sync adapter.""" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..66fba5cc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +pypinyin diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index d2fdc498..92c80406 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -284,6 +284,13 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, discord: 'gateway', }); + // Broadcast groups state + const [broadcastGroups, setBroadcastGroups] = useState>([]); + const addBroadcastGroup = () => setBroadcastGroups(prev => [...prev, {channel: 'feishu', name: '', chat_id: ''}]); + const removeBroadcastGroup = (index: number) => setBroadcastGroups(prev => prev.filter((_, i) => i !== index)); + const updateBroadcastGroup = (index: number, field: string, value: string) => + setBroadcastGroups(prev => prev.map((g, i) => i === index ? {...g, [field]: value} : g)); + // Password visibility const [showPwds, setShowPwds] = useState>({}); const togglePwd = (fieldId: string) => setShowPwds(p => ({ ...p, [fieldId]: !p[fieldId] })); @@ -460,7 +467,10 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, 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.feishu || 'websocket', + broadcast_groups: broadcastGroups.filter(g => g.name && g.chat_id), + }, }; } if (ch.id === 'wecom') { @@ -839,6 +849,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, prefill.app_secret = config.app_secret || ''; prefill.encrypt_key = config.encrypt_key || ''; setConnectionModes(prev => ({ ...prev, feishu: config.extra_config?.connection_mode || 'websocket' })); + setBroadcastGroups(config.extra_config?.broadcast_groups || []); } else if (ch.id === 'wecom') { const cm = config.extra_config?.connection_mode === 'websocket' ? 'websocket' : 'webhook'; setConnectionModes(prev => ({ ...prev, wecom: cm })); @@ -917,6 +928,47 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, renderField(field, ch.id, form[field.key] || '', (val) => setFormField(ch.id, field.key, val)) )} + {/* Broadcast Groups Configuration (Feishu only) */} + {ch.id === 'feishu' && ( +
+
+ 📢 群组广播配置 + +
+
+ 配置后,Agent发送消息时会自动同步到指定的飞书群 +
+ {broadcastGroups.map((group, index) => ( +
+ + updateBroadcastGroup(index, 'name', e.target.value)} + placeholder="群组名称" + style={{ flex: 1, fontSize: '12px', padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--border-default)' }} /> + updateBroadcastGroup(index, 'chat_id', e.target.value)} + placeholder="Chat ID" + style={{ flex: 1, fontSize: '12px', padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--border-default)' }} /> + +
+ ))} + {broadcastGroups.length === 0 && ( +
+ 暂未配置群组广播 +
+ )} +
+ )} + {/* Atlassian extra hints */} {ch.id === 'atlassian' && ( <> diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 0a89e51f..8bc8b8dd 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -593,6 +593,32 @@ function RelationshipEditor({ agentId, readOnly = false }: { agentId: string; re const [editAgentRelation, setEditAgentRelation] = useState(''); const [editAgentDescription, setEditAgentDescription] = useState(''); + // Group relationships state + const [addingGroup, setAddingGroup] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [newGroupChatId, setNewGroupChatId] = useState(''); + const [newGroupChannel, setNewGroupChannel] = useState('feishu'); + const [newGroupDesc, setNewGroupDesc] = useState(''); + + const { data: agentGroups = [], refetch: refetchGroups } = useQuery({ + queryKey: ['agent-groups', agentId], + queryFn: () => fetchAuth(`/agents/${agentId}/relationships/groups`), + }); + + const addGroupRelationship = async () => { + if (!newGroupName || !newGroupChatId) return; + const existing = agentGroups.map((g: any) => ({ group_name: g.group_name, chat_id: g.chat_id, channel: g.channel, description: g.description })); + existing.push({ group_name: newGroupName, chat_id: newGroupChatId, channel: newGroupChannel, description: newGroupDesc }); + await fetchAuth(`/agents/${agentId}/relationships/groups`, { method: 'PUT', body: JSON.stringify(existing) }); + setAddingGroup(false); setNewGroupName(''); setNewGroupChatId(''); setNewGroupChannel('feishu'); setNewGroupDesc(''); + refetchGroups(); + }; + + const removeGroupRelationship = async (groupId: string) => { + await fetchAuth(`/agents/${agentId}/relationships/groups/${groupId}`, { method: 'DELETE' }); + refetchGroups(); + }; + const { data: relationships = [], refetch } = useQuery({ queryKey: ['relationships', agentId], queryFn: () => fetchAuth(`/agents/${agentId}/relationships/`), @@ -819,6 +845,64 @@ function RelationshipEditor({ agentId, readOnly = false }: { agentId: string; re )} + + {/* ── Group Relationships ── */} +
+

+ 📢 群组广播 + — Agent可向以下群组发送广播消息 +

+

+ 配置Agent的关系网络中的群组,Agent发送消息时可直接发送到这些群。 +

+ {agentGroups.length > 0 && ( +
+ {agentGroups.map((g: any) => ( +
+
+
G
+
+
+ {g.group_name} + + {g.channel === 'feishu' ? '飞书' : g.channel === 'wecom' ? '企微' : g.channel === 'dingtalk' ? '钉钉' : g.channel} + +
+
Chat ID: {g.chat_id}
+ {g.description &&
{g.description}
} +
+ {!readOnly && ( + + )} +
+
+ ))} +
+ )} + {!readOnly && !addingGroup && ( + + )} + {!readOnly && addingGroup && ( +
+
+ setNewGroupName(e.target.value)} style={{ flex: 1, fontSize: '12px' }} /> + +
+ setNewGroupChatId(e.target.value)} style={{ fontSize: '12px', marginBottom: '8px', width: '100%' }} /> +