feat(gateway): handle Feishu meeting invitations

Change-Id: I8cf5638393dd9adb1d7be5e170ce5082b41f77fa
This commit is contained in:
zhaolei.vc
2026-06-02 17:39:18 +08:00
committed by Teknium
parent 86c64cfb5b
commit f3bbfda6d1
5 changed files with 579 additions and 6 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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",
],
)

View File

@ -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()

View File

@ -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)