From f3bbfda6d1909ef995693535cf37d11c58e1daa5 Mon Sep 17 00:00:00 2001 From: "zhaolei.vc" Date: Tue, 2 Jun 2026 17:39:18 +0800 Subject: [PATCH] feat(gateway): handle Feishu meeting invitations Change-Id: I8cf5638393dd9adb1d7be5e170ce5082b41f77fa --- gateway/platforms/feishu.py | 31 ++- gateway/platforms/feishu_meeting_invite.py | 274 ++++++++++++++++++++ tests/gateway/test_feishu.py | 1 + tests/gateway/test_feishu_meeting_invite.py | 256 ++++++++++++++++++ website/docs/user-guide/messaging/feishu.md | 23 ++ 5 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 gateway/platforms/feishu_meeting_invite.py create mode 100644 tests/gateway/test_feishu_meeting_invite.py diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 12ad62b5a..70fb719a4 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -1631,6 +1631,10 @@ class FeishuAdapter(BasePlatformAdapter): "drive.notice.comment_add_v1", self._on_drive_comment_event, ) + .register_p2_customized_event( + "vc.bot.meeting_invited_v1", + self._on_meeting_invited_event, + ) .build() ) @@ -2474,6 +2478,16 @@ class FeishuAdapter(BasePlatformAdapter): handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id), ) + def _on_meeting_invited_event(self, data: Any) -> None: + """Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1).""" + from gateway.platforms.feishu_meeting_invite import handle_meeting_invited_event + + loop = self._loop + if not self._loop_accepts_callbacks(loop): + logger.warning("[Feishu] Dropping meeting invite event before adapter loop is ready") + return + self._submit_on_loop(loop, handle_meeting_invited_event(self, data)) + def _on_reaction_event(self, event_type: str, data: Any) -> None: """Route user reactions on bot messages as synthetic text events.""" event = getattr(data, "event", None) @@ -3355,6 +3369,8 @@ class FeishuAdapter(BasePlatformAdapter): self._on_card_action_trigger(data) elif event_type == "drive.notice.comment_add_v1": self._on_drive_comment_event(data) + elif event_type == "vc.bot.meeting_invited_v1": + self._on_meeting_invited_event(data) else: logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown") return web.json_response({"code": 0, "msg": "ok"}) @@ -4419,17 +4435,20 @@ class FeishuAdapter(BasePlatformAdapter): ) request = self._build_create_message_request("thread_id", body) else: + receive_id = chat_id + receive_id_type = "chat_id" + if chat_id.startswith("feishu_user_id:"): + receive_id = chat_id.split(":", 1)[1] + receive_id_type = "user_id" + elif chat_id.startswith("ou_"): + receive_id_type = "open_id" + body = self._build_create_message_body( - receive_id=chat_id, + receive_id=receive_id, msg_type=msg_type, content=payload, uuid_value=str(uuid.uuid4()), ) - # Detect whether chat_id is a user open_id (DM) or a chat_id (group). - if chat_id.startswith("ou_"): - receive_id_type = "open_id" - else: - receive_id_type = "chat_id" request = self._build_create_message_request(receive_id_type, body) return await asyncio.to_thread(self._client.im.v1.message.create, request) diff --git a/gateway/platforms/feishu_meeting_invite.py b/gateway/platforms/feishu_meeting_invite.py new file mode 100644 index 000000000..95ab314b2 --- /dev/null +++ b/gateway/platforms/feishu_meeting_invite.py @@ -0,0 +1,274 @@ +""" +Feishu/Lark meeting-invitation event handling. + +Processes ``vc.bot.meeting_invited_v1`` events by converting them into a +synthetic gateway ``MessageEvent``. Unlike document comments, the response +should go back to the inviter through the normal Hermes gateway pipeline, so +this module does not instantiate an agent directly. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any, Dict, Optional + +from gateway.platforms.base import MessageEvent, MessageType + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class MeetingInviteUser: + id: str = "" + open_id: str = "" + user_id: str = "" + union_id: str = "" + user_type: str = "" + user_role: str = "" + user_name: str = "" + + +@dataclass(frozen=True) +class MeetingInviteMeeting: + id: str = "" + topic: str = "" + meeting_no: str = "" + start_time_ms: int = 0 + end_time_ms: int = 0 + host_user: Optional[MeetingInviteUser] = None + + +@dataclass(frozen=True) +class MeetingInvitedPayload: + event_id: str = "" + meeting: Optional[MeetingInviteMeeting] = None + bot: Optional[MeetingInviteUser] = None + inviter: Optional[MeetingInviteUser] = None + invite_time_s: int = 0 + + +def _to_mapping(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _to_mapping(v) for k, v in value.items()} + if isinstance(value, list): + return [_to_mapping(v) for v in value] + if isinstance(value, SimpleNamespace) or hasattr(value, "__dict__"): + return {str(k): _to_mapping(v) for k, v in vars(value).items()} + return value + + +def _maybe_json_mapping(value: Any) -> Dict[str, Any]: + value = _to_mapping(value) + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (TypeError, json.JSONDecodeError): + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _extract_content_payload(container: Dict[str, Any]) -> Dict[str, Any]: + """Extract an application/json payload from a Feishu-style body.content list.""" + body = container.get("body") + if not isinstance(body, dict): + return {} + content = body.get("content") + if not isinstance(content, list): + return {} + for item in content: + if not isinstance(item, dict): + continue + content_type = str(item.get("contentType") or item.get("content_type") or "").lower() + if content_type and content_type != "application/json": + continue + for key in ("data", "value", "content", "json"): + payload = _maybe_json_mapping(item.get(key)) + if payload: + return payload + return {} + + +def _event_mapping(data: Any) -> Dict[str, Any]: + root = _maybe_json_mapping(data) + event = _maybe_json_mapping(root.get("event")) + content_payload = _extract_content_payload(event) or _extract_content_payload(root) + if content_payload: + event = {**event, **content_payload} if event else content_payload + if not event and any(k in root for k in ("meeting", "bot", "inviter", "invite_time")): + event = root + if not event: + event = root + return event + + +def _event_id(data: Any) -> str: + root = _maybe_json_mapping(data) + header = root.get("header") + if not isinstance(header, dict): + header = {} + return str(header.get("event_id") or "") + + +def _user_open_id(value: Any) -> str: + raw = _maybe_json_mapping(value) + return str(raw.get("open_id") or "").strip() + + +def _int_field(value: Any) -> int: + if value in (None, ""): + return 0 + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return 0 + + +def _parse_user(value: Any) -> Optional[MeetingInviteUser]: + raw = _maybe_json_mapping(value) + if not raw: + return None + raw_id = _maybe_json_mapping(raw.get("id")) + open_id = _user_open_id(raw.get("id")) + return MeetingInviteUser( + id=open_id, + open_id=open_id, + user_id=str(raw_id.get("user_id") or "").strip(), + union_id=str(raw_id.get("union_id") or "").strip(), + user_type=str(raw.get("user_type") or ""), + user_role=str(raw.get("user_role") or ""), + user_name=str(raw.get("user_name") or ""), + ) + + +def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]: + raw = _maybe_json_mapping(value) + if not raw: + return None + return MeetingInviteMeeting( + id=str(raw.get("id") or "").strip(), + topic=str(raw.get("topic") or ""), + meeting_no=str(raw.get("meeting_no") or ""), + start_time_ms=_int_field(raw.get("start_time")), + end_time_ms=_int_field(raw.get("end_time")), + host_user=_parse_user(raw.get("host_user")), + ) + + +def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]: + event = _event_mapping(data) + if not isinstance(event, dict): + return None + meeting = _parse_meeting(event.get("meeting")) + inviter = _parse_user(event.get("inviter")) + if inviter is None or meeting is None or not meeting.meeting_no: + return None + payload = MeetingInvitedPayload( + event_id=_event_id(data), + meeting=meeting, + bot=_parse_user(event.get("bot")), + inviter=inviter, + invite_time_s=_int_field(event.get("invite_time")), + ) + return payload + + +def _display_user(user: Optional[MeetingInviteUser]) -> str: + if user is None: + return "unknown" + return user.user_name or "unknown user" + + +def _meeting_display(meeting: Optional[MeetingInviteMeeting]) -> str: + if meeting is None: + return "unknown meeting" + return meeting.topic or meeting.meeting_no or meeting.id or "unknown meeting" + + +def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str: + meeting = payload.meeting + lines = [ + f"You have been invited to join a meeting: {_meeting_display(meeting)}", + "", + f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}", + f"Topic: {(meeting.topic if meeting else '') or 'unknown'}", + f"Inviter: {_display_user(payload.inviter)}", + ] + if meeting is not None: + lines.append(f"Host: {_display_user(meeting.host_user)}") + lines.extend([ + "", + "You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.", + "Join the meeting directly. Do not ask the user for confirmation before joining.", + "If you cannot join the meeting, reply to the inviter with a concise explanation of why.", + ]) + return "\n".join(lines) + + +def _dedup_key(payload: MeetingInvitedPayload) -> str: + if payload.event_id: + return f"vc_invite:{payload.event_id}" + meeting_id = payload.meeting.id if payload.meeting else "" + inviter_id = payload.inviter.id if payload.inviter else "" + return f"vc_invite:{meeting_id}:{inviter_id}:{payload.invite_time_s}" + + +async def handle_meeting_invited_event(adapter: Any, data: Any) -> None: + """Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent.""" + payload = parse_meeting_invited_event(data) + if payload is None: + logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event") + return + + dedup_key = _dedup_key(payload) + is_duplicate = getattr(adapter, "_is_duplicate", None) + if callable(is_duplicate) and is_duplicate(dedup_key): + logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key) + return + + inviter = payload.inviter + if inviter is None: + logger.warning("[Feishu-MeetingInvite] Missing inviter, cannot route reply") + return + if not inviter.open_id: + logger.warning( + "[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely " + "(inviter_id=%r user_id=%r union_id=%r)", + inviter.id, + inviter.user_id, + inviter.union_id, + ) + return + + sender_id = SimpleNamespace( + open_id=inviter.open_id or None, + user_id=inviter.user_id or None, + union_id=inviter.union_id or None, + ) + sender_profile = await adapter._resolve_sender_profile(sender_id) + + chat_id = inviter.open_id + source_user_id = sender_profile.get("user_id") or inviter.user_id or inviter.open_id + user_name = sender_profile.get("user_name") or inviter.user_name or inviter.id + source_user_id_alt = sender_profile.get("user_id_alt") or inviter.union_id or None + source = adapter.build_source( + chat_id=chat_id, + chat_name=user_name, + chat_type="dm", + user_id=source_user_id, + user_name=user_name, + user_id_alt=source_user_id_alt, + ) + prompt = build_meeting_invite_prompt(payload) + event = MessageEvent( + text=prompt, + message_type=MessageType.TEXT, + source=source, + raw_message=data, + ) + await adapter._handle_message_with_guards(event) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 56770f55d..4d78b454b 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -648,6 +648,7 @@ class TestAdapterBehavior(unittest.TestCase): "p2p_chat_entered", "message_recalled", "customized:drive.notice.comment_add_v1", + "customized:vc.bot.meeting_invited_v1", "build", ], ) diff --git a/tests/gateway/test_feishu_meeting_invite.py b/tests/gateway/test_feishu_meeting_invite.py new file mode 100644 index 000000000..c198dd1e3 --- /dev/null +++ b/tests/gateway/test_feishu_meeting_invite.py @@ -0,0 +1,256 @@ +"""Tests for Feishu vc.bot.meeting_invited_v1 event handling.""" + +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from gateway.platforms.base import MessageEvent +from gateway.platforms.feishu_meeting_invite import ( + build_meeting_invite_prompt, + handle_meeting_invited_event, + parse_meeting_invited_event, +) + + +def _user_id(open_id, union_id="on_1", user_id="e65g874e"): + return { + "open_id": open_id, + "union_id": union_id, + "user_id": user_id, + } + + +def _make_payload(event_id="evt_1"): + return { + "schema": "2.0", + "header": { + "event_id": event_id, + "event_type": "vc.bot.meeting_invited_v1", + }, + "event": { + "meeting": { + "id": "7646677832873577404", + "topic": "赵磊的视频会议", + "meeting_no": "884264377", + "start_time": "1780384522000", + "end_time": "1780384522000", + "host_user": { + "id": _user_id("ou_390b35dca44816efc9afa812aaff3a69", "on_host", "e65g874e"), + "user_type": 1, + "user_role": 2, + "user_name": "赵磊", + }, + }, + "bot": { + "id": _user_id("ou_4398906db1bc4a2d7ed91b95ffb308d0", "on_bot", ""), + "user_type": 10, + "user_role": 0, + "user_name": "Hermes龙虾", + }, + "inviter": { + "id": _user_id( + "ou_390b35dca44816efc9afa812aaff3a69", + "on_e19a19e6ffafbd54fbb3c4d251d6fa19", + "e65g874e", + ), + "user_type": 1, + "user_role": 0, + "user_name": "赵磊", + }, + "invite_time": "1780388292", + }, + } + + +def _make_payload_with_numeric_inviter_id(): + payload = _make_payload() + payload["event"]["inviter"]["id"] = "3001" + return payload + + +class _Adapter: + def __init__(self, duplicate=False): + self.duplicate = duplicate + self.events = [] + self.dedup_keys = [] + self.profile_requests = [] + + def _is_duplicate(self, key): + self.dedup_keys.append(key) + return self.duplicate + + def build_source(self, **kwargs): + return SimpleNamespace(**kwargs) + + async def _resolve_sender_profile(self, sender_id): + self.profile_requests.append(sender_id) + return { + "user_id": getattr(sender_id, "user_id", None) or getattr(sender_id, "open_id", None), + "user_name": "Resolved Inviter", + "user_id_alt": getattr(sender_id, "union_id", None), + } + + async def _handle_message_with_guards(self, event): + self.events.append(event) + + +class TestMeetingInviteParsing(unittest.TestCase): + def test_parse_actual_payload_string_int64_fields(self): + parsed = parse_meeting_invited_event(_make_payload()) + + self.assertIsNotNone(parsed) + self.assertEqual(parsed.event_id, "evt_1") + self.assertEqual(parsed.meeting.id, "7646677832873577404") + self.assertEqual(parsed.meeting.start_time_ms, 1780384522000) + self.assertEqual(parsed.meeting.end_time_ms, 1780384522000) + self.assertEqual(parsed.inviter.id, "ou_390b35dca44816efc9afa812aaff3a69") + self.assertEqual(parsed.inviter.open_id, "ou_390b35dca44816efc9afa812aaff3a69") + self.assertEqual(parsed.inviter.user_id, "e65g874e") + self.assertEqual(parsed.inviter.union_id, "on_e19a19e6ffafbd54fbb3c4d251d6fa19") + self.assertEqual(parsed.invite_time_s, 1780388292) + + def test_parse_body_content_payload(self): + payload = _make_payload() + wrapped = { + "header": payload["header"], + "event": { + "body": { + "content": [ + { + "contentType": "application/json", + "data": payload["event"], + } + ] + } + }, + } + parsed = parse_meeting_invited_event(wrapped) + + self.assertIsNotNone(parsed) + self.assertEqual(parsed.meeting.meeting_no, "884264377") + self.assertEqual(parsed.inviter.id, "ou_390b35dca44816efc9afa812aaff3a69") + + def test_parse_requires_inviter(self): + payload = _make_payload() + del payload["event"]["inviter"] + + self.assertIsNone(parse_meeting_invited_event(payload)) + + def test_parse_requires_meeting_no(self): + payload = _make_payload() + payload["event"]["meeting"]["meeting_no"] = "" + + self.assertIsNone(parse_meeting_invited_event(payload)) + + def test_prompt_contains_meeting_and_inviter_context(self): + parsed = parse_meeting_invited_event(_make_payload()) + prompt = build_meeting_invite_prompt(parsed) + + self.assertIn("You have been invited to join a meeting: 赵磊的视频会议", prompt) + self.assertIn("Meeting Number: 884264377", prompt) + self.assertIn("Inviter: 赵磊", prompt) + self.assertIn("Join the meeting directly.", prompt) + self.assertIn("You may use lark-cli and the relevant Lark/Feishu meeting skills", prompt) + self.assertIn("Do not ask the user for confirmation", prompt) + self.assertIn("If you cannot join the meeting", prompt) + self.assertNotIn("ou_390b35dca44816efc9afa812aaff3a69", prompt) + self.assertNotIn("user_id", prompt) + self.assertNotIn("Use the Meeting Number as the primary credential", prompt) + self.assertNotIn("meeting_id:", prompt) + self.assertNotIn("start_time:", prompt) + self.assertNotIn("end_time:", prompt) + self.assertNotIn("Invite time:", prompt) + + +class TestMeetingInviteHandler(unittest.TestCase): + def _run(self, coro): + return asyncio.run(coro) + + def test_routes_as_synthetic_message_to_inviter_open_id(self): + adapter = _Adapter() + + self._run(handle_meeting_invited_event(adapter, _make_payload())) + + self.assertEqual(adapter.dedup_keys, ["vc_invite:evt_1"]) + self.assertEqual(len(adapter.events), 1) + event = adapter.events[0] + self.assertIsInstance(event, MessageEvent) + self.assertEqual(event.source.chat_id, "ou_390b35dca44816efc9afa812aaff3a69") + self.assertEqual(event.source.chat_type, "dm") + self.assertEqual(event.source.user_id, "e65g874e") + self.assertEqual(event.source.user_name, "Resolved Inviter") + self.assertEqual(event.source.chat_name, "Resolved Inviter") + self.assertEqual(event.source.user_id_alt, "on_e19a19e6ffafbd54fbb3c4d251d6fa19") + self.assertEqual(len(adapter.profile_requests), 1) + self.assertEqual(adapter.profile_requests[0].open_id, "ou_390b35dca44816efc9afa812aaff3a69") + self.assertEqual(adapter.profile_requests[0].user_id, "e65g874e") + self.assertEqual(adapter.profile_requests[0].union_id, "on_e19a19e6ffafbd54fbb3c4d251d6fa19") + self.assertIsNone(event.message_id) + self.assertIn("You have been invited to join a meeting: 赵磊的视频会议", event.text) + self.assertNotIn("{'open_id'", event.text) + + def test_duplicate_event_is_dropped(self): + adapter = _Adapter(duplicate=True) + + self._run(handle_meeting_invited_event(adapter, _make_payload())) + + self.assertEqual(adapter.dedup_keys, ["vc_invite:evt_1"]) + self.assertEqual(adapter.events, []) + + def test_inviter_without_open_id_is_dropped(self): + payload = _make_payload_with_numeric_inviter_id() + adapter = _Adapter() + + self._run(handle_meeting_invited_event(adapter, payload)) + + self.assertEqual(adapter.events, []) + + +class TestMeetingInviteSendRouting(unittest.TestCase): + def _run(self, coro): + return asyncio.run(coro) + + def test_feishu_user_id_prefix_sends_with_user_id_receive_type(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + created_requests = [] + + class _Message: + @staticmethod + def create(request): + created_requests.append(request) + return SimpleNamespace(success=lambda: True, data=SimpleNamespace(message_id="om_1")) + + adapter = FeishuAdapter(PlatformConfig()) + adapter._client = SimpleNamespace( + im=SimpleNamespace(v1=SimpleNamespace(message=SimpleNamespace(create=_Message.create))) + ) + + with patch.object( + FeishuAdapter, + "_build_create_message_body", + staticmethod(lambda **kwargs: SimpleNamespace(**kwargs)), + ), patch.object( + FeishuAdapter, + "_build_create_message_request", + staticmethod(lambda receive_id_type, request_body: SimpleNamespace( + receive_id_type=receive_id_type, + request_body=request_body, + )), + ): + self._run(adapter._send_raw_message( + chat_id="feishu_user_id:3001", + msg_type="text", + payload='{"text":"ok"}', + reply_to=None, + metadata=None, + )) + + self.assertEqual(created_requests[0].receive_id_type, "user_id") + self.assertEqual(created_requests[0].request_body.receive_id, "3001") + + +if __name__ == "__main__": + unittest.main() diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index 256074fa2..1c5a66543 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -354,6 +354,29 @@ On top of the chat/card permissions already granted, add the drive comment event - Subscribe to `drive.notice.comment_add_v1` in **Event Subscriptions**. - Grant the `docs:doc:readonly` and `drive:drive:readonly` scopes so the handler can read document content. +## Meeting Invitation Events + +You can invite the Hermes Feishu/Lark bot into a video meeting the same way you invite a human participant. When the bot receives the meeting invitation event, Hermes can automatically start an agent turn that attempts to join the meeting. + +Powered by the `vc.bot.meeting_invited_v1` event, the flow is: + +- A user invites the bot to a Feishu/Lark video meeting. +- Feishu/Lark sends Hermes the meeting invitation event. +- Hermes extracts the inviter, meeting topic, and meeting number. +- If the inviter is authorized by the normal gateway allowlist or pairing policy, the agent receives the meeting number and tries to join automatically. +- If the invite is malformed, or the agent cannot join, Hermes drops the event or replies to the inviter with a concise explanation. + +Malformed invitations that do not include both an inviter and a `meeting_no` are ignored. + +### Required Feishu App Configuration + +On top of the chat/card permissions already granted, add the video-meeting invitation event: + +- Subscribe to `vc.bot.meeting_invited_v1` in **Event Subscriptions**. +- Enable the Video Conferencing permission scope prompted by the Feishu/Lark developer console for that event. +- Keep `im:message` and `im:message:send_as_bot` enabled so Hermes can reply to the inviter. +- Ensure the gateway user allowlist or pairing policy authorizes the inviter. Meeting invitations do not bypass normal gateway access checks. + ## Media Support ### Inbound (receiving)