feat(gateway): handle Feishu meeting invitations

Change-Id: I8cf5638393dd9adb1d7be5e170ce5082b41f77fa
This commit is contained in:
zhaolei.vc
2026-06-04 06:15:23 -07:00
committed by Teknium
parent 86c64cfb5b
commit f3bbfda6d1
5 changed files with 579 additions and 6 deletions
+25 -6
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)
+274
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)