hermes-agent/plugins/platforms/feishu/feishu_meeting_invite.py
Teknium 5600105478 refactor(gateway): migrate slack/dingtalk/whatsapp/matrix/feishu/telegram/wecom/email/sms adapters to bundled plugins
Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging
adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite,
wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained
bundled plugins under plugins/platforms/<x>/, discovered via the platform
registry. Strips the per-platform core touchpoints from gateway/run.py,
gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and
tools/send_message_tool.py.

Carries forward the migration fixes (explicit enabled:false honored,
get_connected_platforms forces discovery, plugin is_connected via
gateway.get_env_value, logs --component gateway matches plugins.platforms.*,
matrix hidden on Windows).

Additionally ports config keys main added since the PR base: the matrix
plugin's _apply_yaml_config now also covers allowed_users,
ignore_user_patterns, process_notices, and session_scope (the inline
gateway/config.py matrix block gained these in the 1340 commits the PR sat
open; they would otherwise have been silently dropped on deletion).
2026-06-20 10:26:45 -07:00

212 lines
7.2 KiB
Python

"""
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:
open_id: str = ""
user_id: str = ""
union_id: 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
inviter: Optional[MeetingInviteUser] = None
invite_time_s: int = 0
def _as_dict(value: Any) -> Dict[str, Any]:
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
value = vars(value)
if isinstance(value, dict):
return {str(k): v for k, v in value.items()}
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
content = _as_dict(container.get("body")).get("content")
if not isinstance(content, list):
return {}
for item in content:
item = _as_dict(item)
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
if ctype and ctype != "application/json":
continue
for key in ("data", "value", "content", "json"):
payload = _as_dict(item.get(key))
if payload:
return payload
return {}
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 = _as_dict(value)
if not raw:
return None
raw_id = _as_dict(raw.get("id"))
return MeetingInviteUser(
open_id=str(raw_id.get("open_id") or "").strip(),
user_id=str(raw_id.get("user_id") or "").strip(),
union_id=str(raw_id.get("union_id") or "").strip(),
user_name=str(raw.get("user_name") or ""),
)
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
raw = _as_dict(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]:
root = _as_dict(data)
event = _as_dict(root.get("event"))
event = event or root
content = _content_payload(event) or _content_payload(root)
if content:
event = {**event, **content}
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
return MeetingInvitedPayload(
event_id=str(_as_dict(root.get("header")).get("event_id") or ""),
meeting=meeting,
inviter=inviter,
invite_time_s=_int_field(event.get("invite_time")),
)
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
meeting = payload.meeting
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
host_name = (meeting.host_user.user_name if meeting and meeting.host_user else "") or "unknown"
display = (meeting.topic or meeting.meeting_no or meeting.id) if meeting else "unknown meeting"
return "\n".join(
[
f"You have been invited to join a meeting: {display or 'unknown meeting'}",
"",
f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}",
f"Topic: {(meeting.topic if meeting else '') or 'unknown'}",
f"Inviter: {inviter_name}",
f"Host: {host_name}",
"",
"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.",
]
)
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.open_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 or not inviter.open_id:
logger.warning(
"[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely "
"(user_id=%r union_id=%r)",
inviter.user_id if inviter else None,
inviter.union_id if inviter else None,
)
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)
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
source = adapter.build_source(
chat_id=inviter.open_id,
chat_name=user_name,
chat_type="dm",
user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
user_name=user_name,
user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None,
)
event = MessageEvent(
text=build_meeting_invite_prompt(payload),
message_type=MessageType.TEXT,
source=source,
raw_message=data,
)
await adapter._handle_message_with_guards(event)