feat(gateway): handle Feishu meeting invitations
Change-Id: I8cf5638393dd9adb1d7be5e170ce5082b41f77fa
This commit is contained in:
@ -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
gateway/platforms/feishu_meeting_invite.py
Normal file
274
gateway/platforms/feishu_meeting_invite.py
Normal 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)
|
||||
@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
256
tests/gateway/test_feishu_meeting_invite.py
Normal file
256
tests/gateway/test_feishu_meeting_invite.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user