2026-02-04 14:07:45 +08:00
|
|
|
|
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
|
2026-07-29 21:37:11 +08:00
|
|
|
|
# pyright: reportMissingModuleSource=false, reportMissingTypeStubs=false
|
2026-02-04 14:07:45 +08:00
|
|
|
|
|
2026-06-10 17:55:10 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
import asyncio
|
2026-03-31 12:52:32 +08:00
|
|
|
|
import importlib.util
|
2026-02-04 14:07:45 +08:00
|
|
|
|
import json
|
2026-02-19 17:33:08 +00:00
|
|
|
|
import os
|
2026-02-07 09:46:53 +00:00
|
|
|
|
import re
|
2026-02-04 14:07:45 +08:00
|
|
|
|
import threading
|
2026-03-24 15:57:14 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import uuid
|
2026-02-05 06:01:02 +00:00
|
|
|
|
from collections import OrderedDict
|
2026-05-01 01:42:31 +08:00
|
|
|
|
from contextlib import suppress
|
2026-03-24 15:57:14 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-07-13 13:11:46 +08:00
|
|
|
|
from datetime import UTC, datetime
|
2026-07-29 21:37:11 +08:00
|
|
|
|
from functools import partial
|
2026-07-19 23:30:49 +08:00
|
|
|
|
from pathlib import Path
|
2026-07-29 21:37:11 +08:00
|
|
|
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
2026-02-04 14:07:45 +08:00
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
from rich.console import Console
|
|
|
|
|
|
from rich.markup import escape
|
|
|
|
|
|
from rich.panel import Panel
|
|
|
|
|
|
from rich.text import Text
|
2026-02-04 14:07:45 +08:00
|
|
|
|
|
|
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
2026-06-30 00:03:07 +08:00
|
|
|
|
from nanobot.bus.outbound_events import ProgressEvent
|
2026-02-04 14:07:45 +08:00
|
|
|
|
from nanobot.bus.queue import MessageBus
|
2026-07-19 23:30:49 +08:00
|
|
|
|
from nanobot.channels.base import BaseChannel
|
|
|
|
|
|
from nanobot.channels.contracts import ChannelInstanceSpec
|
|
|
|
|
|
from nanobot.channels.feishu.config import FeishuConfig, feishu_default_config
|
|
|
|
|
|
from nanobot.channels.feishu.instances import (
|
2026-07-13 13:11:46 +08:00
|
|
|
|
DEFAULT_INSTANCE_ID,
|
2026-07-19 23:30:49 +08:00
|
|
|
|
feishu_app_identity_key,
|
2026-07-13 13:11:46 +08:00
|
|
|
|
feishu_instance_specs,
|
|
|
|
|
|
runtime_channel_name,
|
|
|
|
|
|
update_feishu_instance_preserving_shape,
|
|
|
|
|
|
upsert_feishu_instance,
|
|
|
|
|
|
)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
from nanobot.channels.feishu.websocket import get_feishu_ws_runner
|
2026-07-06 01:23:44 +08:00
|
|
|
|
from nanobot.command.router import normalize_command_text
|
2026-03-08 02:58:25 +00:00
|
|
|
|
from nanobot.config.paths import get_media_dir
|
2026-07-13 13:11:46 +08:00
|
|
|
|
from nanobot.pairing import clear_channel
|
2026-05-14 23:43:06 +08:00
|
|
|
|
from nanobot.utils.helpers import safe_filename
|
2026-05-06 21:11:26 +08:00
|
|
|
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
2026-03-04 19:31:39 +01:00
|
|
|
|
|
2026-06-10 17:55:10 +08:00
|
|
|
|
if TYPE_CHECKING:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
from lark_oapi.api.im.v1.model import ( # pyright: ignore[reportMissingTypeStubs]
|
|
|
|
|
|
MentionEvent,
|
|
|
|
|
|
P2ImMessageReceiveV1,
|
|
|
|
|
|
)
|
2026-06-10 17:55:10 +08:00
|
|
|
|
|
2026-03-04 19:31:39 +01:00
|
|
|
|
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE = Console()
|
2026-07-19 23:30:49 +08:00
|
|
|
|
_LARK_RUNTIME_LOCK = threading.Lock()
|
2026-02-05 06:01:02 +00:00
|
|
|
|
|
2026-06-10 17:55:10 +08:00
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
def _identity_timestamp() -> str:
|
|
|
|
|
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _as_json_object(value: Any) -> dict[str, Any] | None:
|
|
|
|
|
|
"""Narrow untyped SDK/JSON objects at the channel boundary."""
|
|
|
|
|
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _as_json_list(value: Any) -> list[Any] | None:
|
|
|
|
|
|
"""Narrow untyped SDK/JSON arrays at the channel boundary."""
|
|
|
|
|
|
return cast(list[Any], value) if isinstance(value, list) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ignore_event(_: Any) -> None:
|
|
|
|
|
|
"""Consume SDK events that intentionally have no channel action."""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 17:55:10 +08:00
|
|
|
|
def _load_lark_runtime() -> tuple[Any, str, str]:
|
|
|
|
|
|
"""Import the heavy Feishu SDK lazily.
|
|
|
|
|
|
|
|
|
|
|
|
lark_oapi imports a large generated API surface at module import time, so
|
|
|
|
|
|
keep it out of channel discovery and constructor paths.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
2026-07-19 23:30:49 +08:00
|
|
|
|
# The SDK creates a module-global event loop while importing its WebSocket
|
|
|
|
|
|
# client. Multiple Feishu instances start concurrently, so serialize this
|
|
|
|
|
|
# one-time import and cleanup rather than allowing two worker threads to
|
|
|
|
|
|
# close the same loop.
|
|
|
|
|
|
with _LARK_RUNTIME_LOCK:
|
|
|
|
|
|
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
|
2026-07-29 21:37:11 +08:00
|
|
|
|
import lark_oapi as lark # pyright: ignore[reportMissingTypeStubs]
|
|
|
|
|
|
import lark_oapi.ws.client as lark_ws_client # pyright: ignore[reportMissingTypeStubs]
|
|
|
|
|
|
from lark_oapi.core.const import ( # pyright: ignore[reportMissingTypeStubs]
|
|
|
|
|
|
FEISHU_DOMAIN,
|
|
|
|
|
|
LARK_DOMAIN,
|
|
|
|
|
|
)
|
2026-06-10 17:55:10 +08:00
|
|
|
|
|
|
|
|
|
|
if (
|
2026-07-19 23:30:49 +08:00
|
|
|
|
not ws_client_already_imported
|
|
|
|
|
|
and threading.current_thread() is not threading.main_thread()
|
2026-06-10 17:55:10 +08:00
|
|
|
|
):
|
2026-07-19 23:30:49 +08:00
|
|
|
|
import_loop = getattr(lark_ws_client, "loop", None)
|
|
|
|
|
|
if (
|
|
|
|
|
|
import_loop is not None
|
|
|
|
|
|
and not import_loop.is_running()
|
|
|
|
|
|
and not import_loop.is_closed()
|
|
|
|
|
|
):
|
|
|
|
|
|
import_loop.close()
|
|
|
|
|
|
lark_ws_client.loop = None
|
|
|
|
|
|
with suppress(Exception):
|
|
|
|
|
|
asyncio.set_event_loop(None)
|
2026-06-10 17:55:10 +08:00
|
|
|
|
|
2026-07-19 23:30:49 +08:00
|
|
|
|
return lark, FEISHU_DOMAIN, LARK_DOMAIN
|
2026-06-10 17:55:10 +08:00
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
|
|
|
|
|
|
def fetch_feishu_app_identity(
|
|
|
|
|
|
app_id: str,
|
|
|
|
|
|
app_secret: str,
|
|
|
|
|
|
domain: str = "feishu",
|
|
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
"""Fetch the user-facing Feishu/Lark app identity for display.
|
|
|
|
|
|
|
|
|
|
|
|
This is best-effort metadata for WebUI presentation. Callers should treat
|
|
|
|
|
|
an empty result as a normal fallback path.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not FEISHU_AVAILABLE or not app_id or not app_secret:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
lark, feishu_domain, lark_domain = _load_lark_runtime()
|
2026-07-29 21:37:11 +08:00
|
|
|
|
from lark_oapi.api.application.v6.model.get_application_request import ( # pyright: ignore[reportMissingTypeStubs]
|
2026-07-13 13:11:46 +08:00
|
|
|
|
GetApplicationRequest,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
sdk_domain = lark_domain if domain == "lark" else feishu_domain
|
|
|
|
|
|
client = (
|
|
|
|
|
|
lark.Client.builder()
|
|
|
|
|
|
.app_id(app_id)
|
|
|
|
|
|
.app_secret(app_secret)
|
|
|
|
|
|
.domain(sdk_domain)
|
|
|
|
|
|
.timeout(5)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
|
|
|
|
|
request = GetApplicationRequest.builder().app_id(app_id).lang("zh_cn").build()
|
|
|
|
|
|
response = client.application.v6.application.get(request)
|
|
|
|
|
|
if hasattr(response, "success") and not response.success():
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
app = getattr(getattr(response, "data", None), "app", None)
|
|
|
|
|
|
if app is None:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
identity: dict[str, str] = {}
|
|
|
|
|
|
display_name = str(getattr(app, "app_name", "") or "").strip()
|
|
|
|
|
|
avatar_url = str(getattr(app, "avatar_url", "") or "").strip()
|
|
|
|
|
|
if display_name:
|
|
|
|
|
|
identity["displayName"] = display_name
|
|
|
|
|
|
if avatar_url:
|
|
|
|
|
|
identity["avatarUrl"] = avatar_url
|
|
|
|
|
|
if identity:
|
|
|
|
|
|
identity["identityFetchedAt"] = _identity_timestamp()
|
|
|
|
|
|
return identity
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 06:01:02 +00:00
|
|
|
|
# Message type display mapping
|
|
|
|
|
|
MSG_TYPE_MAP = {
|
|
|
|
|
|
"image": "[image]",
|
|
|
|
|
|
"audio": "[audio]",
|
|
|
|
|
|
"file": "[file]",
|
|
|
|
|
|
"sticker": "[sticker]",
|
|
|
|
|
|
}
|
2026-02-04 14:07:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _extract_share_card_content(content_json: dict[str, Any], msg_type: str) -> str:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
"""Extract text representation from share cards and interactive messages."""
|
2026-07-29 21:37:11 +08:00
|
|
|
|
parts: list[str] = []
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if msg_type == "share_chat":
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif msg_type == "share_user":
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif msg_type == "interactive":
|
|
|
|
|
|
parts.extend(_extract_interactive_content(content_json))
|
|
|
|
|
|
elif msg_type == "share_calendar_event":
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif msg_type == "system":
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append("[system message]")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif msg_type == "merge_forward":
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append("[merged forward messages]")
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
return "\n".join(parts) if parts else f"[{msg_type}]"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
"""Recursively extract text and links from interactive card content."""
|
2026-07-29 21:37:11 +08:00
|
|
|
|
parts: list[str] = []
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
|
try:
|
|
|
|
|
|
content = json.loads(content)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
return [content] if content.strip() else []
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if not isinstance(content, dict):
|
|
|
|
|
|
return parts
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-06-14 22:00:03 +08:00
|
|
|
|
# user_dsl: original card definition (richest source for rendered cards)
|
|
|
|
|
|
user_dsl = content.get("user_dsl")
|
|
|
|
|
|
if isinstance(user_dsl, str) and user_dsl.strip():
|
|
|
|
|
|
try:
|
|
|
|
|
|
dsl = json.loads(user_dsl)
|
2026-07-29 21:37:11 +08:00
|
|
|
|
dsl_object = _as_json_object(dsl)
|
|
|
|
|
|
if dsl_object is not None:
|
|
|
|
|
|
parts.extend(_extract_interactive_content(dsl_object))
|
2026-06-14 22:00:03 +08:00
|
|
|
|
if parts:
|
|
|
|
|
|
return parts
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if "title" in content:
|
|
|
|
|
|
title = content["title"]
|
2026-07-29 21:37:11 +08:00
|
|
|
|
title_object = _as_json_object(title)
|
|
|
|
|
|
if title_object is not None:
|
|
|
|
|
|
title_content = title_object.get("content", "") or title_object.get("text", "")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if title_content:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"title: {title_content}")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif isinstance(title, str):
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"title: {title}")
|
|
|
|
|
|
|
2026-06-14 22:00:03 +08:00
|
|
|
|
# Top-level elements: flat list or nested list format
|
|
|
|
|
|
elements = content.get("elements")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elements_list = _as_json_list(elements)
|
|
|
|
|
|
if elements_list is not None:
|
|
|
|
|
|
if elements_list and isinstance(elements_list[0], list):
|
2026-06-14 22:00:03 +08:00
|
|
|
|
# Nested list: [[{tag:"text",text:"..."}], ...]
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for row in elements_list:
|
|
|
|
|
|
row_list = _as_json_list(row)
|
|
|
|
|
|
if row_list is not None:
|
|
|
|
|
|
for element in row_list:
|
2026-06-14 22:00:03 +08:00
|
|
|
|
parts.extend(_extract_element_content(element))
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Flat list: [{tag:"markdown",content:"..."}, ...]
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for element in elements_list:
|
2026-06-14 22:00:03 +08:00
|
|
|
|
parts.extend(_extract_element_content(element))
|
|
|
|
|
|
|
|
|
|
|
|
# Body elements (schema 2.0)
|
|
|
|
|
|
body = content.get("body", {})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
body_object = _as_json_object(body)
|
|
|
|
|
|
if body_object is not None:
|
|
|
|
|
|
body_elements = _as_json_list(body_object.get("elements"))
|
|
|
|
|
|
if body_elements is not None:
|
2026-06-14 22:00:03 +08:00
|
|
|
|
for element in body_elements:
|
|
|
|
|
|
parts.extend(_extract_element_content(element))
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
card = content.get("card", {})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
card_object = _as_json_object(card)
|
|
|
|
|
|
if card_object:
|
|
|
|
|
|
parts.extend(_extract_interactive_content(card_object))
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
header = content.get("header", {})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
header_object = _as_json_object(header)
|
|
|
|
|
|
if header_object is not None:
|
|
|
|
|
|
header_title = _as_json_object(header_object.get("title", {}))
|
|
|
|
|
|
if header_title is not None:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
header_text = header_title.get("content", "") or header_title.get("text", "")
|
|
|
|
|
|
if header_text:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"title: {header_text}")
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
return parts
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _extract_element_content(element: Any) -> list[str]:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
"""Extract content from a single card element."""
|
2026-07-29 21:37:11 +08:00
|
|
|
|
parts: list[str] = []
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
element_object = _as_json_object(element)
|
|
|
|
|
|
if element_object is None:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
return parts
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
element = element_object
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
tag = element.get("tag", "")
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-21 06:30:26 +00:00
|
|
|
|
if tag in ("markdown", "lark_md"):
|
2026-02-21 14:08:25 +08:00
|
|
|
|
content = element.get("content", "")
|
|
|
|
|
|
if content:
|
|
|
|
|
|
parts.append(content)
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-06-14 22:00:03 +08:00
|
|
|
|
elif tag == "text":
|
|
|
|
|
|
text = element.get("text", "")
|
|
|
|
|
|
if isinstance(text, str) and text.strip():
|
|
|
|
|
|
parts.append(text)
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "div":
|
|
|
|
|
|
text = element.get("text", {})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
text_object = _as_json_object(text)
|
|
|
|
|
|
if text_object is not None:
|
|
|
|
|
|
text_content = text_object.get("content", "") or text_object.get("text", "")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if text_content:
|
|
|
|
|
|
parts.append(text_content)
|
|
|
|
|
|
elif isinstance(text, str):
|
|
|
|
|
|
parts.append(text)
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for field in _as_json_list(element.get("fields")) or []:
|
|
|
|
|
|
field_object = _as_json_object(field)
|
|
|
|
|
|
if field_object is not None:
|
|
|
|
|
|
field_text = _as_json_object(field_object.get("text", {}))
|
|
|
|
|
|
if field_text is not None:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
c = field_text.get("content", "")
|
|
|
|
|
|
if c:
|
|
|
|
|
|
parts.append(c)
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "a":
|
|
|
|
|
|
href = element.get("href", "")
|
|
|
|
|
|
text = element.get("text", "")
|
|
|
|
|
|
if href:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"link: {href}")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if text:
|
|
|
|
|
|
parts.append(text)
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "button":
|
|
|
|
|
|
text = element.get("text", {})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
text_object = _as_json_object(text)
|
|
|
|
|
|
if text_object is not None:
|
|
|
|
|
|
c = text_object.get("content", "")
|
2026-02-21 06:30:26 +00:00
|
|
|
|
if c:
|
|
|
|
|
|
parts.append(c)
|
2026-07-29 21:37:11 +08:00
|
|
|
|
multi_url: Any = element.get("multi_url") or {}
|
|
|
|
|
|
multi_url_object = _as_json_object(multi_url)
|
2026-07-25 21:47:05 -07:00
|
|
|
|
url = element.get("url", "") or (
|
2026-07-29 21:37:11 +08:00
|
|
|
|
multi_url_object.get("url", "") if multi_url_object is not None else ""
|
2026-07-25 21:47:05 -07:00
|
|
|
|
)
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if url:
|
2026-02-21 06:30:26 +00:00
|
|
|
|
parts.append(f"link: {url}")
|
|
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "img":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
alt = _as_json_object(element.get("alt", {}))
|
|
|
|
|
|
parts.append(alt.get("content", "[image]") if alt is not None else "[image]")
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "note":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for ne in _as_json_list(element.get("elements")) or []:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
parts.extend(_extract_element_content(ne))
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "column_set":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for col in _as_json_list(element.get("columns")) or []:
|
|
|
|
|
|
col_object = _as_json_object(col)
|
|
|
|
|
|
if col_object is None:
|
2026-07-25 21:47:05 -07:00
|
|
|
|
continue
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for ce in _as_json_list(col_object.get("elements")) or []:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
parts.extend(_extract_element_content(ce))
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
elif tag == "plain_text":
|
|
|
|
|
|
content = element.get("content", "")
|
|
|
|
|
|
if content:
|
|
|
|
|
|
parts.append(content)
|
2026-02-21 06:30:26 +00:00
|
|
|
|
|
2026-06-18 11:26:43 +08:00
|
|
|
|
elif tag == "table":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
columns: list[tuple[str, str]] = []
|
|
|
|
|
|
for column in _as_json_list(element.get("columns")) or []:
|
|
|
|
|
|
column_object = _as_json_object(column)
|
|
|
|
|
|
if column_object is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = column_object.get("name")
|
|
|
|
|
|
if isinstance(name, str) and name:
|
|
|
|
|
|
columns.append((name, str(column_object.get("display_name") or name)))
|
|
|
|
|
|
rows = _as_json_list(element.get("rows")) or []
|
2026-06-18 11:37:09 +08:00
|
|
|
|
if columns:
|
|
|
|
|
|
parts.append(" | ".join(header for _, header in columns))
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if rows:
|
2026-06-18 11:37:09 +08:00
|
|
|
|
for row in rows:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
row_object = _as_json_object(row)
|
|
|
|
|
|
if row_object is None:
|
2026-06-18 11:26:43 +08:00
|
|
|
|
continue
|
2026-07-29 21:37:11 +08:00
|
|
|
|
values: list[str] = []
|
2026-06-18 11:37:09 +08:00
|
|
|
|
for name, _ in columns:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
value = row_object.get(name)
|
2026-06-18 11:37:09 +08:00
|
|
|
|
if isinstance(value, list):
|
2026-07-29 21:37:11 +08:00
|
|
|
|
value = " ".join(
|
|
|
|
|
|
str(item).strip()
|
|
|
|
|
|
for item in cast(list[Any], value)
|
|
|
|
|
|
if item is not None
|
|
|
|
|
|
)
|
2026-06-18 11:37:09 +08:00
|
|
|
|
values.append("" if value is None else str(value).strip())
|
|
|
|
|
|
row_text = " | ".join(values).strip()
|
|
|
|
|
|
if row_text:
|
|
|
|
|
|
parts.append(row_text)
|
2026-06-18 11:26:43 +08:00
|
|
|
|
|
2026-02-21 06:30:26 +00:00
|
|
|
|
else:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for ne in _as_json_list(element.get("elements")) or []:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
parts.extend(_extract_element_content(ne))
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-21 14:08:25 +08:00
|
|
|
|
return parts
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]:
|
2026-03-01 06:36:29 +00:00
|
|
|
|
"""Extract text and image keys from Feishu post (rich text) message.
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-01 06:36:29 +00:00
|
|
|
|
Handles three payload shapes:
|
|
|
|
|
|
- Direct: {"title": "...", "content": [[...]]}
|
|
|
|
|
|
- Localized: {"zh_cn": {"title": "...", "content": [...]}}
|
|
|
|
|
|
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
|
2026-02-14 14:37:23 +08:00
|
|
|
|
"""
|
2026-03-01 06:36:29 +00:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _parse_block(block: dict[str, Any]) -> tuple[str | None, list[str]]:
|
|
|
|
|
|
content = _as_json_list(block.get("content"))
|
|
|
|
|
|
if content is None:
|
2026-02-24 13:42:07 +08:00
|
|
|
|
return None, []
|
2026-07-29 21:37:11 +08:00
|
|
|
|
texts: list[str] = []
|
|
|
|
|
|
images: list[str] = []
|
2026-07-25 21:49:46 -07:00
|
|
|
|
title = block.get("title")
|
|
|
|
|
|
if isinstance(title, str) and title:
|
2026-03-01 06:36:29 +00:00
|
|
|
|
texts.append(title)
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for row in content:
|
|
|
|
|
|
row_items = _as_json_list(row)
|
|
|
|
|
|
if row_items is None:
|
2026-02-14 12:14:31 +08:00
|
|
|
|
continue
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for el in row_items:
|
|
|
|
|
|
element = _as_json_object(el)
|
|
|
|
|
|
if element is None:
|
2026-03-01 06:36:29 +00:00
|
|
|
|
continue
|
2026-07-29 21:37:11 +08:00
|
|
|
|
tag = element.get("tag")
|
2026-03-01 06:36:29 +00:00
|
|
|
|
if tag in ("text", "a"):
|
2026-07-29 21:37:11 +08:00
|
|
|
|
text = element.get("text", "")
|
2026-07-25 21:49:46 -07:00
|
|
|
|
if isinstance(text, str):
|
|
|
|
|
|
texts.append(text)
|
2026-03-01 06:36:29 +00:00
|
|
|
|
elif tag == "at":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
user = element.get("user_name", "user")
|
2026-07-25 21:49:46 -07:00
|
|
|
|
texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
|
2026-03-19 13:05:44 +08:00
|
|
|
|
elif tag == "code_block":
|
2026-07-29 21:37:11 +08:00
|
|
|
|
lang = element.get("language", "")
|
|
|
|
|
|
code_text = element.get("text", "")
|
2026-07-25 21:49:46 -07:00
|
|
|
|
if not isinstance(lang, str):
|
|
|
|
|
|
lang = ""
|
|
|
|
|
|
if not isinstance(code_text, str):
|
|
|
|
|
|
code_text = ""
|
2026-03-19 13:05:44 +08:00
|
|
|
|
texts.append(f"\n```{lang}\n{code_text}\n```\n")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elif tag == "img" and isinstance((key := element.get("image_key")), str):
|
2026-03-01 06:36:29 +00:00
|
|
|
|
images.append(key)
|
|
|
|
|
|
return (" ".join(texts).strip() or None), images
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-01 06:36:29 +00:00
|
|
|
|
# Unwrap optional {"post": ...} envelope
|
|
|
|
|
|
root = content_json
|
2026-07-29 21:37:11 +08:00
|
|
|
|
post = _as_json_object(root.get("post"))
|
|
|
|
|
|
if post is not None:
|
|
|
|
|
|
root = post
|
2026-03-01 02:17:10 +08:00
|
|
|
|
|
2026-03-01 06:36:29 +00:00
|
|
|
|
# Direct format
|
|
|
|
|
|
if "content" in root:
|
|
|
|
|
|
text, imgs = _parse_block(root)
|
|
|
|
|
|
if text or imgs:
|
|
|
|
|
|
return text or "", imgs
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-01 06:36:29 +00:00
|
|
|
|
# Localized: prefer known locales, then fall back to any dict child
|
|
|
|
|
|
for key in ("zh_cn", "en_us", "ja_jp"):
|
|
|
|
|
|
if key in root:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
block = _as_json_object(root[key])
|
|
|
|
|
|
if block is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
text, imgs = _parse_block(block)
|
2026-03-01 06:36:29 +00:00
|
|
|
|
if text or imgs:
|
|
|
|
|
|
return text or "", imgs
|
|
|
|
|
|
for val in root.values():
|
2026-07-29 21:37:11 +08:00
|
|
|
|
block = _as_json_object(val)
|
|
|
|
|
|
if block is not None:
|
|
|
|
|
|
text, imgs = _parse_block(block)
|
2026-03-01 06:36:29 +00:00
|
|
|
|
if text or imgs:
|
|
|
|
|
|
return text or "", imgs
|
2026-03-01 06:30:10 +00:00
|
|
|
|
|
2026-02-24 13:42:07 +08:00
|
|
|
|
return "", []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 00:51:13 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# QR scan-to-create onboarding
|
|
|
|
|
|
#
|
|
|
|
|
|
# Device-code flow: user scans a QR code with the Feishu/Lark mobile app and
|
|
|
|
|
|
# the platform creates a fully configured bot application automatically.
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
_ONBOARD_ACCOUNTS_URLS = {
|
|
|
|
|
|
"feishu": "https://accounts.feishu.cn",
|
|
|
|
|
|
"lark": "https://accounts.larksuite.com",
|
|
|
|
|
|
}
|
|
|
|
|
|
_REGISTRATION_PATH = "/oauth/v1/app/registration"
|
|
|
|
|
|
_ONBOARD_REQUEST_TIMEOUT_S = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
class _RegistrationStart(TypedDict):
|
|
|
|
|
|
device_code: str
|
|
|
|
|
|
qr_url: str
|
|
|
|
|
|
interval: int
|
|
|
|
|
|
expire_in: int
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 00:51:13 +08:00
|
|
|
|
def _accounts_base_url(domain: str) -> str:
|
|
|
|
|
|
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _post_registration(base_url: str, body: dict[str, str]) -> dict[str, Any]:
|
2026-06-18 00:51:13 +08:00
|
|
|
|
"""POST form-encoded data to the registration endpoint, return parsed JSON.
|
|
|
|
|
|
|
|
|
|
|
|
The registration endpoint returns JSON even on HTTP errors (e.g. poll
|
|
|
|
|
|
returns authorization_pending as a 400). We always parse the body.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
url = f"{base_url}{_REGISTRATION_PATH}"
|
|
|
|
|
|
resp = httpx.post(
|
|
|
|
|
|
url,
|
|
|
|
|
|
data=body,
|
|
|
|
|
|
timeout=_ONBOARD_REQUEST_TIMEOUT_S,
|
|
|
|
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
parsed = resp.json()
|
|
|
|
|
|
return _as_json_object(parsed) or {}
|
2026-06-18 00:51:13 +08:00
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _init_registration(domain: str = "feishu") -> None:
|
|
|
|
|
|
"""Verify the environment supports client_secret auth. Raises RuntimeError if not."""
|
|
|
|
|
|
base_url = _accounts_base_url(domain)
|
|
|
|
|
|
res = _post_registration(base_url, {"action": "init"})
|
2026-07-29 21:37:11 +08:00
|
|
|
|
methods = _as_json_list(res.get("supported_auth_methods")) or []
|
2026-06-18 00:51:13 +08:00
|
|
|
|
if "client_secret" not in methods:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Feishu / Lark registration does not support client_secret auth. "
|
|
|
|
|
|
f"Supported: {methods}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _begin_registration(domain: str = "feishu") -> _RegistrationStart:
|
2026-06-18 14:13:53 +08:00
|
|
|
|
"""Start the device-code flow. Returns device_code, qr_url, interval, expire_in."""
|
2026-06-18 00:51:13 +08:00
|
|
|
|
base_url = _accounts_base_url(domain)
|
|
|
|
|
|
res = _post_registration(base_url, {
|
|
|
|
|
|
"action": "begin",
|
|
|
|
|
|
"archetype": "PersonalAgent",
|
|
|
|
|
|
"auth_method": "client_secret",
|
|
|
|
|
|
"request_user_info": "open_id",
|
|
|
|
|
|
})
|
|
|
|
|
|
device_code = res.get("device_code")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if not isinstance(device_code, str) or not device_code:
|
2026-06-18 00:51:13 +08:00
|
|
|
|
raise RuntimeError("Feishu / Lark registration did not return a device_code")
|
|
|
|
|
|
qr_url = res.get("verification_uri_complete", "")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if not isinstance(qr_url, str) or not qr_url:
|
2026-06-18 13:35:17 +08:00
|
|
|
|
raise RuntimeError("Feishu / Lark registration did not return a login URL")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
interval = res.get("interval")
|
|
|
|
|
|
expire_in = res.get("expire_in")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"device_code": device_code,
|
|
|
|
|
|
"qr_url": qr_url,
|
2026-07-29 21:37:11 +08:00
|
|
|
|
"interval": interval if isinstance(interval, int) else 5,
|
|
|
|
|
|
"expire_in": expire_in if isinstance(expire_in, int) else 600,
|
2026-06-18 00:51:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _poll_registration(
|
|
|
|
|
|
*,
|
|
|
|
|
|
device_code: str,
|
|
|
|
|
|
interval: int,
|
|
|
|
|
|
expire_in: int,
|
|
|
|
|
|
domain: str = "feishu",
|
2026-07-29 21:37:11 +08:00
|
|
|
|
) -> dict[str, Any] | None:
|
2026-06-18 00:51:13 +08:00
|
|
|
|
"""Poll until the user scans the QR code, or timeout/denial.
|
|
|
|
|
|
|
2026-06-18 14:13:53 +08:00
|
|
|
|
Returns dict with app_id, app_secret, domain on success, None on failure.
|
2026-06-18 00:51:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
deadline = time.monotonic() + expire_in
|
|
|
|
|
|
current_domain = domain
|
|
|
|
|
|
|
|
|
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
|
try:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
res = poll_registration_once(device_code=device_code, domain=current_domain)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
current_domain = res.get("domain", current_domain)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if res.get("status") == "succeeded":
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return {
|
2026-07-13 13:11:46 +08:00
|
|
|
|
"app_id": res["app_id"],
|
|
|
|
|
|
"app_secret": res["app_secret"],
|
|
|
|
|
|
"domain": res.get("domain", current_domain),
|
2026-06-18 00:51:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if res.get("status") == "failed":
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("[yellow]Authorization was cancelled or expired.[/yellow]")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# authorization_pending or unknown — keep polling
|
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("[yellow]Authorization timed out.[/yellow]")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
def poll_registration_once(
|
|
|
|
|
|
*,
|
|
|
|
|
|
device_code: str,
|
|
|
|
|
|
domain: str = "feishu",
|
2026-07-29 21:37:11 +08:00
|
|
|
|
) -> dict[str, Any]:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
"""Poll the Feishu/Lark device-code flow once.
|
|
|
|
|
|
|
|
|
|
|
|
This non-blocking shape is used by WebUI. The CLI keeps using
|
|
|
|
|
|
``_poll_registration`` to wait in the terminal.
|
|
|
|
|
|
"""
|
|
|
|
|
|
current_domain = domain
|
|
|
|
|
|
base_url = _accounts_base_url(current_domain)
|
|
|
|
|
|
res = _post_registration(base_url, {
|
|
|
|
|
|
"action": "poll",
|
|
|
|
|
|
"device_code": device_code,
|
|
|
|
|
|
"tp": "ob_app",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
user_info = _as_json_object(res.get("user_info")) or {}
|
2026-07-13 13:11:46 +08:00
|
|
|
|
tenant_brand = user_info.get("tenant_brand")
|
|
|
|
|
|
if tenant_brand == "lark":
|
|
|
|
|
|
current_domain = "lark"
|
|
|
|
|
|
|
|
|
|
|
|
if res.get("client_id") and res.get("client_secret"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "succeeded",
|
|
|
|
|
|
"app_id": res["client_id"],
|
|
|
|
|
|
"app_secret": res["client_secret"],
|
|
|
|
|
|
"domain": current_domain,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
error = res.get("error", "")
|
|
|
|
|
|
if error in ("access_denied", "expired_token"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "failed",
|
|
|
|
|
|
"error": error,
|
|
|
|
|
|
"domain": current_domain,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "pending",
|
|
|
|
|
|
"domain": current_domain,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _saved_feishu_instance_identity_key(
|
|
|
|
|
|
feishu_cfg: Any,
|
|
|
|
|
|
defaults: dict[str, Any],
|
|
|
|
|
|
instance_id: str,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
|
|
|
|
|
if spec.instance_id == instance_id:
|
2026-07-19 23:30:49 +08:00
|
|
|
|
return feishu_app_identity_key(
|
2026-07-13 13:11:46 +08:00
|
|
|
|
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
|
|
|
|
|
|
str(spec.config.get("domain") or "feishu"),
|
|
|
|
|
|
)
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 23:30:49 +08:00
|
|
|
|
def _saved_feishu_instance_for_identity(
|
|
|
|
|
|
feishu_cfg: Any,
|
|
|
|
|
|
defaults: dict[str, Any],
|
|
|
|
|
|
app_id: str,
|
|
|
|
|
|
domain: str,
|
|
|
|
|
|
) -> ChannelInstanceSpec | None:
|
|
|
|
|
|
identity_key = feishu_app_identity_key(app_id, domain)
|
|
|
|
|
|
if not identity_key:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
|
|
|
|
|
saved_identity = feishu_app_identity_key(
|
|
|
|
|
|
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
|
|
|
|
|
|
str(spec.config.get("domain") or "feishu"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if saved_identity == identity_key:
|
|
|
|
|
|
return spec
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
def sync_saved_feishu_identity_boundary(
|
|
|
|
|
|
*,
|
|
|
|
|
|
instance_id: str,
|
|
|
|
|
|
app_id: str,
|
|
|
|
|
|
domain: str,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""Persist the Feishu app identity marker and clear access if it changed.
|
|
|
|
|
|
|
|
|
|
|
|
WebUI connect normally handles this at save time. This startup check catches
|
|
|
|
|
|
manual config edits so approved users do not accidentally carry over to a
|
|
|
|
|
|
different Feishu/Lark app in the same local instance slot.
|
|
|
|
|
|
"""
|
2026-07-19 23:30:49 +08:00
|
|
|
|
current_identity_key = feishu_app_identity_key(app_id, domain)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if not current_identity_key:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
from nanobot.config.loader import load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
full_config = load_config()
|
2026-07-29 21:37:11 +08:00
|
|
|
|
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
|
2026-07-13 13:11:46 +08:00
|
|
|
|
|
2026-07-19 23:30:49 +08:00
|
|
|
|
defaults = feishu_default_config()
|
2026-07-13 13:11:46 +08:00
|
|
|
|
previous_identity_key = ""
|
|
|
|
|
|
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
|
|
|
|
|
if spec.instance_id == instance_id:
|
|
|
|
|
|
previous_identity_key = str(
|
|
|
|
|
|
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key)
|
|
|
|
|
|
values: dict[str, Any] = {"identityKey": current_identity_key}
|
|
|
|
|
|
if access_cleared:
|
|
|
|
|
|
values["allowFrom"] = []
|
|
|
|
|
|
values["allow_from"] = []
|
|
|
|
|
|
clear_channel(runtime_channel_name("feishu", instance_id))
|
|
|
|
|
|
|
|
|
|
|
|
if not previous_identity_key or access_cleared:
|
|
|
|
|
|
feishu_cfg = update_feishu_instance_preserving_shape(
|
|
|
|
|
|
feishu_cfg,
|
|
|
|
|
|
defaults,
|
|
|
|
|
|
instance_id,
|
|
|
|
|
|
values,
|
|
|
|
|
|
)
|
|
|
|
|
|
setattr(full_config.channels, "feishu", feishu_cfg)
|
|
|
|
|
|
save_config(full_config)
|
|
|
|
|
|
|
|
|
|
|
|
return access_cleared
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_registration_result(
|
2026-07-29 21:37:11 +08:00
|
|
|
|
result: dict[str, Any],
|
2026-07-13 13:11:46 +08:00
|
|
|
|
*,
|
|
|
|
|
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
|
|
|
|
|
name: str | None = None,
|
2026-07-19 23:30:49 +08:00
|
|
|
|
) -> str:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
"""Persist a successful Feishu/Lark registration result to config.json."""
|
|
|
|
|
|
from nanobot.config.loader import load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
full_config = load_config()
|
2026-07-29 21:37:11 +08:00
|
|
|
|
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
|
2026-07-19 23:30:49 +08:00
|
|
|
|
defaults = feishu_default_config()
|
2026-07-13 13:11:46 +08:00
|
|
|
|
app_id = str(result["app_id"]).strip()
|
|
|
|
|
|
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
|
|
|
|
|
domain = "lark" if domain == "lark" else "feishu"
|
2026-07-19 23:30:49 +08:00
|
|
|
|
existing = _saved_feishu_instance_for_identity(feishu_cfg, defaults, app_id, domain)
|
|
|
|
|
|
effective_instance_id = existing.instance_id if existing is not None else instance_id
|
|
|
|
|
|
previous_identity_key = _saved_feishu_instance_identity_key(
|
|
|
|
|
|
feishu_cfg,
|
|
|
|
|
|
defaults,
|
|
|
|
|
|
effective_instance_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
next_identity_key = feishu_app_identity_key(app_id, domain)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
|
|
|
|
|
|
identity: dict[str, str] = {}
|
|
|
|
|
|
with suppress(Exception):
|
|
|
|
|
|
identity = fetch_feishu_app_identity(
|
|
|
|
|
|
app_id,
|
|
|
|
|
|
str(result["app_secret"]),
|
|
|
|
|
|
domain,
|
|
|
|
|
|
)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
default_name = (
|
|
|
|
|
|
"nanobot"
|
|
|
|
|
|
if effective_instance_id == DEFAULT_INSTANCE_ID
|
|
|
|
|
|
else f"nanobot {effective_instance_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_name = existing.config.get("name") if existing is not None else None
|
|
|
|
|
|
saved_name = (
|
|
|
|
|
|
existing_name
|
|
|
|
|
|
if existing is not None and existing.instance_id != instance_id
|
|
|
|
|
|
else name
|
|
|
|
|
|
)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
values = {
|
2026-07-19 23:30:49 +08:00
|
|
|
|
"name": str(saved_name or default_name),
|
2026-07-13 13:11:46 +08:00
|
|
|
|
"appId": app_id,
|
|
|
|
|
|
"appSecret": result["app_secret"],
|
|
|
|
|
|
"domain": domain,
|
|
|
|
|
|
"identityKey": next_identity_key,
|
|
|
|
|
|
"enabled": True,
|
|
|
|
|
|
**identity,
|
|
|
|
|
|
}
|
|
|
|
|
|
if identity_changed:
|
|
|
|
|
|
values["allowFrom"] = []
|
|
|
|
|
|
values["allow_from"] = []
|
2026-07-19 23:30:49 +08:00
|
|
|
|
clear_channel(runtime_channel_name("feishu", effective_instance_id))
|
2026-07-13 13:11:46 +08:00
|
|
|
|
feishu_cfg = upsert_feishu_instance(
|
|
|
|
|
|
feishu_cfg,
|
|
|
|
|
|
defaults,
|
2026-07-19 23:30:49 +08:00
|
|
|
|
effective_instance_id,
|
2026-07-13 13:11:46 +08:00
|
|
|
|
values,
|
|
|
|
|
|
)
|
|
|
|
|
|
setattr(full_config.channels, "feishu", feishu_cfg)
|
|
|
|
|
|
save_config(full_config)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
return effective_instance_id
|
2026-07-13 13:11:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 23:30:49 +08:00
|
|
|
|
def refresh_saved_feishu_identities(
|
|
|
|
|
|
config: Any | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
config_path: Path | None = None,
|
|
|
|
|
|
instance_id: str | None = None,
|
|
|
|
|
|
) -> bool:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
"""Backfill missing Feishu assistant display identity in saved config.
|
|
|
|
|
|
|
|
|
|
|
|
Existing users may already have working App ID/Secret credentials from
|
|
|
|
|
|
older builds. Fetch identity only when an instance has credentials but no
|
|
|
|
|
|
identity metadata at all, then persist the attempt so Settings does not hit
|
|
|
|
|
|
Feishu on every render.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not FEISHU_AVAILABLE:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
from nanobot.config.loader import load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
full_config = config or load_config()
|
|
|
|
|
|
feishu_cfg = getattr(full_config.channels, "feishu", None)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
defaults = feishu_default_config()
|
2026-07-13 13:11:46 +08:00
|
|
|
|
specs = feishu_instance_specs(feishu_cfg, defaults)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
if instance_id:
|
|
|
|
|
|
specs = [spec for spec in specs if spec.instance_id == instance_id]
|
2026-07-13 13:11:46 +08:00
|
|
|
|
updated = False
|
|
|
|
|
|
|
|
|
|
|
|
for spec in specs:
|
|
|
|
|
|
instance = spec.config
|
|
|
|
|
|
if (
|
|
|
|
|
|
instance.get("displayName")
|
|
|
|
|
|
or instance.get("avatarUrl")
|
|
|
|
|
|
or instance.get("identityFetchedAt")
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
app_id = str(instance.get("appId") or instance.get("app_id") or "").strip()
|
|
|
|
|
|
app_secret = str(instance.get("appSecret") or instance.get("app_secret") or "").strip()
|
|
|
|
|
|
if not app_id or not app_secret:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
identity = fetch_feishu_app_identity(
|
|
|
|
|
|
app_id,
|
|
|
|
|
|
app_secret,
|
|
|
|
|
|
str(instance.get("domain") or "feishu"),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not identity:
|
|
|
|
|
|
identity = {"identityFetchedAt": _identity_timestamp()}
|
|
|
|
|
|
|
|
|
|
|
|
feishu_cfg = update_feishu_instance_preserving_shape(
|
|
|
|
|
|
feishu_cfg,
|
|
|
|
|
|
defaults,
|
|
|
|
|
|
spec.instance_id,
|
|
|
|
|
|
identity,
|
|
|
|
|
|
)
|
|
|
|
|
|
updated = True
|
|
|
|
|
|
|
|
|
|
|
|
if not updated:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
setattr(full_config.channels, "feishu", feishu_cfg)
|
2026-07-19 23:30:49 +08:00
|
|
|
|
save_config(full_config, config_path)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 00:51:13 +08:00
|
|
|
|
def qr_register(
|
|
|
|
|
|
*,
|
|
|
|
|
|
initial_domain: str = "feishu",
|
2026-07-29 21:37:11 +08:00
|
|
|
|
) -> dict[str, Any] | None:
|
2026-06-18 00:51:13 +08:00
|
|
|
|
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
|
|
|
|
|
|
|
|
|
|
|
Returns on success:
|
|
|
|
|
|
{
|
|
|
|
|
|
"app_id": str,
|
|
|
|
|
|
"app_secret": str,
|
|
|
|
|
|
"domain": "feishu" | "lark",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Returns None on expected failures (network, auth denied, timeout).
|
|
|
|
|
|
Unexpected errors (bugs, protocol regressions) propagate to the caller.
|
|
|
|
|
|
"""
|
2026-06-18 14:47:44 +08:00
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-06-18 00:51:13 +08:00
|
|
|
|
try:
|
2026-06-18 14:13:53 +08:00
|
|
|
|
return _qr_register_inner(initial_domain=initial_domain)
|
2026-06-18 14:47:44 +08:00
|
|
|
|
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print(
|
|
|
|
|
|
f"[yellow]Unable to start Feishu/Lark login:[/yellow] {escape(str(exc))}"
|
|
|
|
|
|
)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _print_qr_code(url: str) -> None:
|
|
|
|
|
|
"""Print QR code as ASCII art if qrcode package is available, otherwise print URL."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
import qrcode as qr_lib
|
|
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("\n[bold]Scan with Feishu or Lark[/bold]\n")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
qr = qr_lib.QRCode(border=1)
|
|
|
|
|
|
qr.add_data(url)
|
|
|
|
|
|
qr.make(fit=True)
|
|
|
|
|
|
qr.print_ascii(invert=True)
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print()
|
2026-06-18 00:51:13 +08:00
|
|
|
|
except ImportError:
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print()
|
|
|
|
|
|
_LOGIN_CONSOLE.print(Panel.fit(Text(url), title="Open with Feishu or Lark", border_style="cyan"))
|
|
|
|
|
|
_LOGIN_CONSOLE.print()
|
2026-06-18 00:51:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _qr_register_inner(
|
|
|
|
|
|
*,
|
|
|
|
|
|
initial_domain: str,
|
2026-07-29 21:37:11 +08:00
|
|
|
|
) -> dict[str, Any] | None:
|
2026-06-18 14:13:53 +08:00
|
|
|
|
"""Run init → begin → poll. Raises on network/protocol errors."""
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
_init_registration(initial_domain)
|
|
|
|
|
|
begin = _begin_registration(initial_domain)
|
|
|
|
|
|
|
|
|
|
|
|
_print_qr_code(begin["qr_url"])
|
|
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
with _LOGIN_CONSOLE.status("Waiting for authorization in Feishu/Lark...", spinner="dots"):
|
|
|
|
|
|
return _poll_registration(
|
|
|
|
|
|
device_code=begin["device_code"],
|
|
|
|
|
|
interval=begin["interval"],
|
|
|
|
|
|
expire_in=begin["expire_in"],
|
|
|
|
|
|
domain=initial_domain,
|
|
|
|
|
|
)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 15:57:14 +08:00
|
|
|
|
_STREAM_ELEMENT_ID = "streaming_md"
|
2026-07-06 01:23:44 +08:00
|
|
|
|
_NEW_SESSION_DIVIDER_CONTENT = json.dumps({
|
|
|
|
|
|
"type": "divider",
|
|
|
|
|
|
"params": {"divider_text": {"text": "New session started."}},
|
|
|
|
|
|
})
|
2026-03-24 15:57:14 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class _FeishuStreamBuf:
|
|
|
|
|
|
"""Per-chat streaming accumulator using CardKit streaming API."""
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-03-24 15:57:14 +08:00
|
|
|
|
text: str = ""
|
|
|
|
|
|
card_id: str | None = None
|
|
|
|
|
|
sequence: int = 0
|
|
|
|
|
|
last_edit: float = 0.0
|
2026-03-13 15:26:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
class FeishuChannel(BaseChannel):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Feishu/Lark channel using WebSocket long connection.
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
Uses WebSocket to receive events - no public IP or webhook required.
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
Requires:
|
|
|
|
|
|
- App ID and App Secret from Feishu Open Platform
|
|
|
|
|
|
- Bot capability enabled
|
|
|
|
|
|
- Event subscription enabled (im.message.receive_v1)
|
|
|
|
|
|
"""
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
name = "feishu"
|
2026-03-11 14:23:19 +00:00
|
|
|
|
display_name = "Feishu"
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-24 15:57:14 +08:00
|
|
|
|
_STREAM_EDIT_INTERVAL = 0.5 # throttle between CardKit streaming updates
|
|
|
|
|
|
|
2026-03-13 15:26:55 +00:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def default_config(cls) -> dict[str, Any]:
|
2026-07-19 23:30:49 +08:00
|
|
|
|
return feishu_default_config()
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def refresh_feature_metadata(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
config_path: Path,
|
|
|
|
|
|
*,
|
|
|
|
|
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
from nanobot.config.loader import load_config
|
|
|
|
|
|
|
|
|
|
|
|
return refresh_saved_feishu_identities(
|
|
|
|
|
|
load_config(config_path),
|
|
|
|
|
|
config_path=config_path,
|
|
|
|
|
|
instance_id=instance_id,
|
|
|
|
|
|
)
|
2026-03-13 15:26:55 +00:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: Any, bus: MessageBus):
|
|
|
|
|
|
if isinstance(config, dict):
|
|
|
|
|
|
config = FeishuConfig.model_validate(config)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
super().__init__(config, bus)
|
|
|
|
|
|
self.config: FeishuConfig = config
|
2026-06-10 17:55:10 +08:00
|
|
|
|
self._client: Any = None
|
2026-02-04 14:07:45 +08:00
|
|
|
|
self._ws_client: Any = None
|
2026-07-13 13:11:46 +08:00
|
|
|
|
self._ws_runner = get_feishu_ws_runner()
|
2026-02-05 06:01:02 +00:00
|
|
|
|
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
|
2026-02-04 14:07:45 +08:00
|
|
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
2026-03-24 15:57:14 +08:00
|
|
|
|
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
2026-04-01 23:23:23 +08:00
|
|
|
|
self._bot_open_id: str | None = None
|
2026-07-29 21:37:11 +08:00
|
|
|
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
2026-04-19 21:39:50 +08:00
|
|
|
|
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-06-18 00:51:13 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# QR login — writes credentials directly to config.json
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def login(self, force: bool = False) -> bool:
|
|
|
|
|
|
"""Perform QR code scan-to-create login for Feishu/Lark.
|
|
|
|
|
|
|
|
|
|
|
|
Uses the Feishu device-code registration flow to create a new bot
|
|
|
|
|
|
application automatically. Opens a URL for the user to authorize
|
|
|
|
|
|
with the Feishu or Lark mobile app.
|
|
|
|
|
|
|
|
|
|
|
|
On success, writes ``appId``, ``appSecret``, and ``domain`` to
|
|
|
|
|
|
``channels.feishu`` in ``config.json`` and sets ``enabled: true``.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
force: If True, clear existing credentials and force re-authentication.
|
|
|
|
|
|
|
|
|
|
|
|
Returns True on success.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if force:
|
|
|
|
|
|
self.config.app_id = ""
|
|
|
|
|
|
self.config.app_secret = ""
|
|
|
|
|
|
|
|
|
|
|
|
if self.config.app_id and self.config.app_secret:
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("[green]Feishu/Lark is already authenticated.[/green]")
|
|
|
|
|
|
_LOGIN_CONSOLE.print("Use --force to re-authenticate with a new bot.\n")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("Authorize with the mobile app. nanobot will save the new bot credentials.\n")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
|
|
|
|
|
|
result = qr_register(initial_domain=self.config.domain or "feishu")
|
|
|
|
|
|
if not result:
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print(
|
|
|
|
|
|
"[yellow]Login was not completed.[/yellow] "
|
|
|
|
|
|
"Run 'nanobot channels login feishu --force' to retry."
|
|
|
|
|
|
)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
self.config.app_id = result["app_id"]
|
|
|
|
|
|
self.config.app_secret = result["app_secret"]
|
|
|
|
|
|
self.config.domain = result.get("domain", "feishu")
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
save_registration_result(
|
|
|
|
|
|
result,
|
|
|
|
|
|
instance_id=self.config.instance_id,
|
|
|
|
|
|
name=self.config.name,
|
|
|
|
|
|
)
|
2026-06-18 00:51:13 +08:00
|
|
|
|
|
2026-06-18 17:09:07 +08:00
|
|
|
|
_LOGIN_CONSOLE.print("\n[green]Feishu/Lark login complete.[/green]")
|
|
|
|
|
|
_LOGIN_CONSOLE.print(f"App ID: {escape(result['app_id'])}")
|
|
|
|
|
|
_LOGIN_CONSOLE.print(f"Domain: {escape(self.config.domain)}")
|
2026-06-18 00:51:13 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-03-07 15:02:06 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
|
|
|
|
|
|
"""Register an event handler only when the SDK supports it."""
|
|
|
|
|
|
method = getattr(builder, method_name, None)
|
|
|
|
|
|
return method(handler) if callable(method) else builder
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
async def start(self) -> None:
|
|
|
|
|
|
"""Start the Feishu bot with WebSocket long connection."""
|
|
|
|
|
|
if not FEISHU_AVAILABLE:
|
2026-07-03 18:17:52 +08:00
|
|
|
|
self.logger.error("SDK not installed. Run: nanobot plugins enable feishu")
|
2026-02-04 14:07:45 +08:00
|
|
|
|
return
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
if not self.config.app_id or not self.config.app_secret:
|
2026-06-18 00:51:13 +08:00
|
|
|
|
self.logger.error(
|
|
|
|
|
|
"app_id and app_secret not configured. "
|
|
|
|
|
|
"Run 'nanobot channels login feishu' to set up via QR code."
|
|
|
|
|
|
)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
return
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if sync_saved_feishu_identity_boundary(
|
|
|
|
|
|
instance_id=self.config.instance_id,
|
|
|
|
|
|
app_id=self.config.app_id,
|
|
|
|
|
|
domain=self.config.domain,
|
|
|
|
|
|
):
|
2026-07-19 23:30:49 +08:00
|
|
|
|
self.config.identity_key = feishu_app_identity_key(self.config.app_id, self.config.domain)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
self.config.allow_from = []
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
|
"Feishu app identity changed for {}; cleared paired users for this assistant",
|
|
|
|
|
|
self.name,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-10 17:55:10 +08:00
|
|
|
|
lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-05-06 21:11:26 +08:00
|
|
|
|
redirect_lib_logging("Lark")
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
self._running = True
|
2026-02-05 06:01:02 +00:00
|
|
|
|
self._loop = asyncio.get_running_loop()
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
# Create Lark client for sending messages
|
2026-06-10 17:55:10 +08:00
|
|
|
|
domain = lark_domain if self.config.domain == "lark" else feishu_domain
|
2026-03-30 18:11:01 +08:00
|
|
|
|
self._client = (
|
|
|
|
|
|
lark.Client.builder()
|
|
|
|
|
|
.app_id(self.config.app_id)
|
|
|
|
|
|
.app_secret(self.config.app_secret)
|
2026-04-06 12:03:56 -07:00
|
|
|
|
.domain(domain)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
.log_level(lark.LogLevel.INFO)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-03-07 15:02:06 +00:00
|
|
|
|
builder = lark.EventDispatcherHandler.builder(
|
2026-02-04 14:07:45 +08:00
|
|
|
|
self.config.encrypt_key or "",
|
|
|
|
|
|
self.config.verification_token or "",
|
2026-03-30 18:11:01 +08:00
|
|
|
|
).register_p2_im_message_receive_v1(self._on_message_sync)
|
2026-03-07 15:02:06 +00:00
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder, "register_p2_im_message_reaction_created_v1", self._on_reaction_created
|
|
|
|
|
|
)
|
2026-03-31 12:52:32 +08:00
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder, "register_p2_im_message_reaction_deleted_v1", self._on_reaction_deleted
|
|
|
|
|
|
)
|
2026-03-07 15:02:06 +00:00
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder, "register_p2_im_message_message_read_v1", self._on_message_read
|
|
|
|
|
|
)
|
|
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder,
|
|
|
|
|
|
"register_p2_im_chat_access_event_bot_p2p_chat_entered_v1",
|
|
|
|
|
|
self._on_bot_p2p_chat_entered,
|
|
|
|
|
|
)
|
2026-05-14 10:56:22 +08:00
|
|
|
|
# Silence "processor not found" errors when bots are added/removed from groups.
|
|
|
|
|
|
# These events carry no actionable data for the agent.
|
|
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder,
|
|
|
|
|
|
"register_p2_im_chat_member_bot_added_v1",
|
2026-07-29 21:37:11 +08:00
|
|
|
|
_ignore_event,
|
2026-05-14 10:56:22 +08:00
|
|
|
|
)
|
|
|
|
|
|
builder = self._register_optional_event(
|
|
|
|
|
|
builder,
|
|
|
|
|
|
"register_p2_im_chat_member_bot_deleted_v1",
|
2026-07-29 21:37:11 +08:00
|
|
|
|
_ignore_event,
|
2026-05-14 10:56:22 +08:00
|
|
|
|
)
|
2026-03-07 15:02:06 +00:00
|
|
|
|
event_handler = builder.build()
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
# Create WebSocket client for long connection
|
|
|
|
|
|
self._ws_client = lark.ws.Client(
|
|
|
|
|
|
self.config.app_id,
|
|
|
|
|
|
self.config.app_secret,
|
2026-04-06 12:03:56 -07:00
|
|
|
|
domain=domain,
|
2026-02-04 14:07:45 +08:00
|
|
|
|
event_handler=event_handler,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
log_level=lark.LogLevel.INFO,
|
2026-02-04 14:07:45 +08:00
|
|
|
|
)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
await self._ws_runner.start_client(self.name, self._ws_client)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-04-01 23:23:23 +08:00
|
|
|
|
# Fetch bot's own open_id for accurate @mention matching
|
|
|
|
|
|
self._bot_open_id = await asyncio.get_running_loop().run_in_executor(
|
|
|
|
|
|
None, self._fetch_bot_open_id
|
|
|
|
|
|
)
|
|
|
|
|
|
if self._bot_open_id:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.info("bot open_id: {}", self._bot_open_id)
|
2026-04-01 23:23:23 +08:00
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
|
2026-04-01 23:23:23 +08:00
|
|
|
|
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.info("bot started with WebSocket long connection")
|
|
|
|
|
|
self.logger.info("No public IP required - using WebSocket to receive events")
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
# Keep running until stopped
|
|
|
|
|
|
while self._running:
|
|
|
|
|
|
await asyncio.sleep(1)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
async def stop(self) -> None:
|
2026-03-01 00:30:03 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Stop the Feishu bot.
|
|
|
|
|
|
|
|
|
|
|
|
Notice: lark.ws.Client does not expose stop method, simply exiting the program will close the client.
|
|
|
|
|
|
|
|
|
|
|
|
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
|
|
|
|
|
|
"""
|
2026-02-04 14:07:45 +08:00
|
|
|
|
self._running = False
|
2026-07-13 13:11:46 +08:00
|
|
|
|
await self._ws_runner.stop_client(self.name)
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.info("bot stopped")
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-04-01 23:23:23 +08:00
|
|
|
|
def _fetch_bot_open_id(self) -> str | None:
|
|
|
|
|
|
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
|
|
|
|
|
|
try:
|
2026-04-06 11:39:23 +00:00
|
|
|
|
import lark_oapi as lark
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
|
|
|
|
|
request = (
|
2026-03-31 12:52:32 +08:00
|
|
|
|
lark.BaseRequest.builder()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
.http_method(lark.HttpMethod.GET)
|
|
|
|
|
|
.uri("/open-apis/bot/v3/info")
|
2026-03-31 12:52:32 +08:00
|
|
|
|
.token_types({lark.AccessTokenType.APP})
|
2026-04-06 11:39:23 +00:00
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-04-06 11:39:23 +00:00
|
|
|
|
response = self._client.request(request)
|
|
|
|
|
|
if response.success():
|
|
|
|
|
|
import json
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
data = _as_json_object(json.loads(response.raw.content)) or {}
|
|
|
|
|
|
wrapped = _as_json_object(data.get("data")) or data
|
|
|
|
|
|
bot = _as_json_object(wrapped.get("bot")) or _as_json_object(data.get("bot")) or {}
|
|
|
|
|
|
open_id = bot.get("open_id")
|
|
|
|
|
|
return open_id if isinstance(open_id, str) else None
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
|
2026-04-01 23:23:23 +08:00
|
|
|
|
return None
|
|
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Error fetching bot info: {}", e)
|
2026-04-01 23:23:23 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_mentions(text: str, mentions: list[MentionEvent] | None) -> str:
|
|
|
|
|
|
"""Replace @_user_n placeholders with actual user info from mentions.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
text: The message text containing @_user_n placeholders
|
|
|
|
|
|
mentions: List of mention objects from Feishu message
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Text with placeholders replaced by @姓名 (open_id)
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not mentions or not text:
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
for mention in mentions:
|
|
|
|
|
|
key = mention.key or None
|
2026-06-04 10:51:41 +08:00
|
|
|
|
if not key:
|
|
|
|
|
|
continue
|
2026-06-05 14:12:10 +08:00
|
|
|
|
# Feishu placeholders are numbered keys like @_user_1. Keep
|
|
|
|
|
|
# punctuation-adjacent mentions valid without matching @_user_10.
|
|
|
|
|
|
pattern = rf"{re.escape(key)}(?![A-Za-z0-9_])"
|
2026-06-04 10:51:41 +08:00
|
|
|
|
if not re.search(pattern, text):
|
2026-03-30 18:11:01 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-03-31 12:52:32 +08:00
|
|
|
|
user_id_obj = mention.id or None
|
|
|
|
|
|
if not user_id_obj:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-03-31 12:52:32 +08:00
|
|
|
|
open_id = user_id_obj.open_id
|
|
|
|
|
|
user_id = user_id_obj.user_id
|
2026-03-30 18:11:01 +08:00
|
|
|
|
name = mention.name or key
|
|
|
|
|
|
|
|
|
|
|
|
# Format: @姓名 (open_id, user_id: xxx)
|
|
|
|
|
|
if open_id and user_id:
|
|
|
|
|
|
replacement = f"@{name} ({open_id}, user id: {user_id})"
|
|
|
|
|
|
elif open_id:
|
|
|
|
|
|
replacement = f"@{name} ({open_id})"
|
|
|
|
|
|
else:
|
|
|
|
|
|
replacement = f"@{name}"
|
|
|
|
|
|
|
2026-06-04 10:51:41 +08:00
|
|
|
|
text = re.sub(pattern, replacement, text)
|
|
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
def _is_bot_mention_event(self, mention: Any) -> bool:
|
|
|
|
|
|
mid = getattr(mention, "id", None)
|
|
|
|
|
|
if not mid:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
mention_open_id = getattr(mid, "open_id", None) or ""
|
|
|
|
|
|
bot_open_id = getattr(self, "_bot_open_id", None) or ""
|
|
|
|
|
|
if bot_open_id:
|
|
|
|
|
|
return mention_open_id == bot_open_id
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback heuristic when bot open_id is unavailable.
|
|
|
|
|
|
return not getattr(mid, "user_id", None) and mention_open_id.startswith("ou_")
|
|
|
|
|
|
|
|
|
|
|
|
def _strip_leading_bot_mention(
|
|
|
|
|
|
self, text: str, mentions: list[MentionEvent] | None
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Remove a required leading bot mention before slash command routing."""
|
|
|
|
|
|
if not mentions or not text:
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
candidate = text.lstrip()
|
|
|
|
|
|
for mention in mentions:
|
|
|
|
|
|
key = getattr(mention, "key", None) or ""
|
2026-06-05 14:12:10 +08:00
|
|
|
|
if not key or not re.match(rf"{re.escape(key)}(?![A-Za-z0-9_])", candidate):
|
2026-06-04 10:51:41 +08:00
|
|
|
|
continue
|
|
|
|
|
|
if not self._is_bot_mention_event(mention):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
stripped = candidate[len(key) :].strip()
|
|
|
|
|
|
return stripped or text
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
2026-03-12 04:45:57 +00:00
|
|
|
|
def _is_bot_mentioned(self, message: Any) -> bool:
|
|
|
|
|
|
"""Check if the bot is @mentioned in the message."""
|
2026-03-09 17:54:02 +08:00
|
|
|
|
raw_content = message.content or ""
|
|
|
|
|
|
if "@_all" in raw_content:
|
|
|
|
|
|
return True
|
2026-03-12 04:45:57 +00:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
for mention in cast(list[Any], getattr(message, "mentions", None) or []):
|
2026-06-04 10:51:41 +08:00
|
|
|
|
if self._is_bot_mention_event(mention):
|
|
|
|
|
|
return True
|
2026-03-09 17:54:02 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-12 04:45:57 +00:00
|
|
|
|
def _is_group_message_for_bot(self, message: Any) -> bool:
|
|
|
|
|
|
"""Allow group messages when policy is open or bot is @mentioned."""
|
|
|
|
|
|
if self.config.group_policy == "open":
|
|
|
|
|
|
return True
|
|
|
|
|
|
return self._is_bot_mentioned(message)
|
2026-03-09 17:54:02 +08:00
|
|
|
|
|
2026-04-03 21:07:41 +08:00
|
|
|
|
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> str | None:
|
2026-02-05 06:01:02 +00:00
|
|
|
|
"""Sync helper for adding reaction (runs in thread pool)."""
|
2026-03-30 18:11:01 +08:00
|
|
|
|
from lark_oapi.api.im.v1 import (
|
|
|
|
|
|
CreateMessageReactionRequest,
|
|
|
|
|
|
CreateMessageReactionRequestBody,
|
|
|
|
|
|
Emoji,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
CreateMessageReactionRequest.builder()
|
|
|
|
|
|
.message_id(message_id)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
.request_body(
|
|
|
|
|
|
CreateMessageReactionRequestBody.builder()
|
|
|
|
|
|
.reaction_type(Emoji.builder().emoji_type(emoji_type).build())
|
|
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
response = self._client.im.v1.message_reaction.create(request)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to add reaction: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
return None
|
2026-02-04 14:07:45 +08:00
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Added {} reaction to message {}", emoji_type, message_id)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
return response.data.reaction_id if response.data else None
|
2026-02-04 14:07:45 +08:00
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Error adding reaction: {}", e)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
return None
|
2026-02-05 06:01:02 +00:00
|
|
|
|
|
2026-04-03 21:07:41 +08:00
|
|
|
|
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
"""Add a reaction emoji to a message.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the reaction_id on success, None on failure.
|
|
|
|
|
|
When called via a tracked background task, the returned reaction_id
|
|
|
|
|
|
is stored in ``_reaction_ids`` for later cleanup by ``send_delta``.
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-05 06:01:02 +00:00
|
|
|
|
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
|
|
|
|
|
"""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
if not self._client:
|
2026-04-03 21:07:41 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
return await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type)
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_reaction_sync(self, message_id: str, reaction_id: str) -> None:
|
|
|
|
|
|
"""Sync helper for removing reaction (runs in thread pool)."""
|
|
|
|
|
|
from lark_oapi.api.im.v1 import DeleteMessageReactionRequest
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-04-03 21:07:41 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
DeleteMessageReactionRequest.builder()
|
|
|
|
|
|
.message_id(message_id)
|
|
|
|
|
|
.reaction_id(reaction_id)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
|
|
|
|
|
|
response = self._client.im.v1.message_reaction.delete(request)
|
|
|
|
|
|
if response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to remove reaction: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Error removing reaction: {}", e)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
|
|
|
|
|
|
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Remove a reaction emoji from a message (non-blocking).
|
|
|
|
|
|
|
|
|
|
|
|
Used to clear the "processing" indicator after bot replies.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self._client or not reaction_id:
|
2026-02-05 06:01:02 +00:00
|
|
|
|
return
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-02-05 06:01:02 +00:00
|
|
|
|
loop = asyncio.get_running_loop()
|
2026-04-03 21:07:41 +08:00
|
|
|
|
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _on_background_task_done(self, task: asyncio.Task[Any]) -> None:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
"""Callback: remove from tracking set and log unhandled exceptions."""
|
|
|
|
|
|
self._background_tasks.discard(task)
|
|
|
|
|
|
if task.cancelled():
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
task.result()
|
|
|
|
|
|
except Exception as exc:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Background task failed: {}", exc)
|
2026-04-19 21:39:50 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _on_reaction_added(self, message_id: str, task: asyncio.Task[Any]) -> None:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
"""Callback: store reaction_id after background add-reaction completes."""
|
|
|
|
|
|
if task.cancelled():
|
|
|
|
|
|
return
|
2026-05-01 01:42:31 +08:00
|
|
|
|
# Failures already logged by _on_background_task_done.
|
|
|
|
|
|
with suppress(Exception):
|
2026-04-19 21:39:50 +08:00
|
|
|
|
reaction_id = task.result()
|
|
|
|
|
|
if reaction_id:
|
|
|
|
|
|
self._reaction_ids[message_id] = reaction_id
|
|
|
|
|
|
# Trim cache to prevent unbounded growth
|
|
|
|
|
|
if len(self._reaction_ids) > 500:
|
|
|
|
|
|
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
|
|
|
|
|
|
2026-04-26 08:07:30 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _stream_key(chat_id: str, metadata: dict[str, Any] | None = None) -> str:
|
|
|
|
|
|
"""Scope streaming buffers to the inbound message when available."""
|
|
|
|
|
|
meta = metadata or {}
|
|
|
|
|
|
return meta.get("message_id") or chat_id
|
|
|
|
|
|
|
2026-02-07 09:46:53 +00:00
|
|
|
|
# Regex to match markdown tables (header + separator + data rows)
|
|
|
|
|
|
_TABLE_RE = re.compile(
|
|
|
|
|
|
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
|
|
|
|
|
re.MULTILINE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-13 15:31:30 +08:00
|
|
|
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
|
|
|
|
|
|
|
2026-03-17 08:40:39 +00:00
|
|
|
|
# Markdown formatting patterns that should be stripped from plain-text
|
|
|
|
|
|
# surfaces like table cells and heading text.
|
2026-03-10 12:12:47 +08:00
|
|
|
|
_MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
2026-03-17 08:40:39 +00:00
|
|
|
|
_MD_BOLD_UNDERSCORE_RE = re.compile(r"__(.+?)__")
|
2026-03-10 12:12:47 +08:00
|
|
|
|
_MD_ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
|
|
|
|
|
|
_MD_STRIKE_RE = re.compile(r"~~(.+?)~~")
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _strip_md_formatting(cls, text: str) -> str:
|
|
|
|
|
|
"""Strip markdown formatting markers from text for plain display.
|
|
|
|
|
|
|
|
|
|
|
|
Feishu table cells do not support markdown rendering, so we remove
|
|
|
|
|
|
the formatting markers to keep the text readable.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Remove bold markers
|
|
|
|
|
|
text = cls._MD_BOLD_RE.sub(r"\1", text)
|
2026-03-17 08:40:39 +00:00
|
|
|
|
text = cls._MD_BOLD_UNDERSCORE_RE.sub(r"\1", text)
|
2026-03-10 12:12:47 +08:00
|
|
|
|
# Remove italic markers
|
|
|
|
|
|
text = cls._MD_ITALIC_RE.sub(r"\1", text)
|
|
|
|
|
|
# Remove strikethrough markers
|
|
|
|
|
|
text = cls._MD_STRIKE_RE.sub(r"\1", text)
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _parse_md_table(cls, table_text: str) -> dict[str, Any] | None:
|
2026-02-07 09:46:53 +00:00
|
|
|
|
"""Parse a markdown table into a Feishu table element."""
|
2026-02-28 20:55:43 +08:00
|
|
|
|
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
|
2026-02-07 09:46:53 +00:00
|
|
|
|
if len(lines) < 3:
|
|
|
|
|
|
return None
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-28 20:55:43 +08:00
|
|
|
|
def split(_line: str) -> list[str]:
|
|
|
|
|
|
return [c.strip() for c in _line.strip("|").split("|")]
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-03-10 12:12:47 +08:00
|
|
|
|
headers = [cls._strip_md_formatting(h) for h in split(lines[0])]
|
|
|
|
|
|
rows = [[cls._strip_md_formatting(c) for c in split(_line)] for _line in lines[2:]]
|
2026-03-30 18:11:01 +08:00
|
|
|
|
columns = [
|
|
|
|
|
|
{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"}
|
|
|
|
|
|
for i, h in enumerate(headers)
|
|
|
|
|
|
]
|
2026-02-07 09:46:53 +00:00
|
|
|
|
return {
|
|
|
|
|
|
"tag": "table",
|
|
|
|
|
|
"page_size": len(rows) + 1,
|
|
|
|
|
|
"columns": columns,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"rows": [
|
|
|
|
|
|
{f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows
|
|
|
|
|
|
],
|
2026-02-07 09:46:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _build_card_elements(self, content: str) -> list[dict[str, Any]]:
|
2026-02-13 15:31:30 +08:00
|
|
|
|
"""Split content into div/markdown + table elements for Feishu card."""
|
2026-07-22 00:31:25 -07:00
|
|
|
|
protected = content
|
|
|
|
|
|
code_blocks: list[str] = []
|
|
|
|
|
|
for m in self._CODE_BLOCK_RE.finditer(content):
|
|
|
|
|
|
code_blocks.append(m.group(1))
|
|
|
|
|
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elements: list[dict[str, Any]] = []
|
|
|
|
|
|
last_end = 0
|
2026-07-22 00:31:25 -07:00
|
|
|
|
for m in self._TABLE_RE.finditer(protected):
|
|
|
|
|
|
before = protected[last_end : m.start()]
|
2026-02-13 15:31:30 +08:00
|
|
|
|
if before.strip():
|
|
|
|
|
|
elements.extend(self._split_headings(before))
|
2026-03-30 18:11:01 +08:00
|
|
|
|
elements.append(
|
|
|
|
|
|
self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}
|
|
|
|
|
|
)
|
2026-02-07 09:46:53 +00:00
|
|
|
|
last_end = m.end()
|
2026-07-22 00:31:25 -07:00
|
|
|
|
remaining = protected[last_end:]
|
2026-02-13 15:31:30 +08:00
|
|
|
|
if remaining.strip():
|
|
|
|
|
|
elements.extend(self._split_headings(remaining))
|
2026-07-22 00:31:25 -07:00
|
|
|
|
|
|
|
|
|
|
for i, cb in enumerate(code_blocks):
|
|
|
|
|
|
for el in elements:
|
|
|
|
|
|
if el.get("tag") == "markdown":
|
|
|
|
|
|
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
|
|
|
|
|
|
|
2026-02-07 09:46:53 +00:00
|
|
|
|
return elements or [{"tag": "markdown", "content": content}]
|
|
|
|
|
|
|
2026-03-01 15:13:44 +01:00
|
|
|
|
@staticmethod
|
2026-03-30 18:11:01 +08:00
|
|
|
|
def _split_elements_by_table_limit(
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elements: list[dict[str, Any]], max_tables: int = 1
|
|
|
|
|
|
) -> list[list[dict[str, Any]]]:
|
2026-03-01 15:13:44 +01:00
|
|
|
|
"""Split card elements into groups with at most *max_tables* table elements each.
|
|
|
|
|
|
|
|
|
|
|
|
Feishu cards have a hard limit of one table per card (API error 11310).
|
|
|
|
|
|
When the rendered content contains multiple markdown tables each table is
|
|
|
|
|
|
placed in a separate card message so every table reaches the user.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not elements:
|
|
|
|
|
|
return [[]]
|
2026-07-29 21:37:11 +08:00
|
|
|
|
groups: list[list[dict[str, Any]]] = []
|
|
|
|
|
|
current: list[dict[str, Any]] = []
|
2026-03-01 15:13:44 +01:00
|
|
|
|
table_count = 0
|
|
|
|
|
|
for el in elements:
|
|
|
|
|
|
if el.get("tag") == "table":
|
|
|
|
|
|
if table_count >= max_tables:
|
|
|
|
|
|
if current:
|
|
|
|
|
|
groups.append(current)
|
|
|
|
|
|
current = []
|
|
|
|
|
|
table_count = 0
|
|
|
|
|
|
current.append(el)
|
|
|
|
|
|
table_count += 1
|
2026-02-04 14:07:45 +08:00
|
|
|
|
else:
|
2026-03-01 15:13:44 +01:00
|
|
|
|
current.append(el)
|
|
|
|
|
|
if current:
|
|
|
|
|
|
groups.append(current)
|
|
|
|
|
|
return groups or [[]]
|
|
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
def _split_headings(self, content: str) -> list[dict[str, Any]]:
|
2026-02-13 15:31:30 +08:00
|
|
|
|
"""Split content by headings, converting headings to div elements."""
|
|
|
|
|
|
protected = content
|
2026-07-29 21:37:11 +08:00
|
|
|
|
code_blocks: list[str] = []
|
2026-02-13 15:31:30 +08:00
|
|
|
|
for m in self._CODE_BLOCK_RE.finditer(content):
|
|
|
|
|
|
code_blocks.append(m.group(1))
|
2026-03-30 18:11:01 +08:00
|
|
|
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
2026-02-13 15:31:30 +08:00
|
|
|
|
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elements: list[dict[str, Any]] = []
|
2026-02-13 15:31:30 +08:00
|
|
|
|
last_end = 0
|
|
|
|
|
|
for m in self._HEADING_RE.finditer(protected):
|
2026-03-30 18:11:01 +08:00
|
|
|
|
before = protected[last_end : m.start()].strip()
|
2026-02-13 15:31:30 +08:00
|
|
|
|
if before:
|
|
|
|
|
|
elements.append({"tag": "markdown", "content": before})
|
2026-03-17 08:40:39 +00:00
|
|
|
|
text = self._strip_md_formatting(m.group(2).strip())
|
|
|
|
|
|
display_text = f"**{text}**" if text else ""
|
2026-03-30 18:11:01 +08:00
|
|
|
|
elements.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "div",
|
|
|
|
|
|
"text": {
|
|
|
|
|
|
"tag": "lark_md",
|
|
|
|
|
|
"content": display_text,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-02-13 15:31:30 +08:00
|
|
|
|
last_end = m.end()
|
|
|
|
|
|
remaining = protected[last_end:].strip()
|
2026-02-07 09:46:53 +00:00
|
|
|
|
if remaining:
|
|
|
|
|
|
elements.append({"tag": "markdown", "content": remaining})
|
2026-02-13 15:31:30 +08:00
|
|
|
|
|
|
|
|
|
|
for i, cb in enumerate(code_blocks):
|
|
|
|
|
|
for el in elements:
|
|
|
|
|
|
if el.get("tag") == "markdown":
|
|
|
|
|
|
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
|
|
|
|
|
|
|
2026-02-07 09:46:53 +00:00
|
|
|
|
return elements or [{"tag": "markdown", "content": content}]
|
|
|
|
|
|
|
2026-03-06 10:11:53 +08:00
|
|
|
|
# ── Smart format detection ──────────────────────────────────────────
|
|
|
|
|
|
# Patterns that indicate "complex" markdown needing card rendering
|
|
|
|
|
|
_COMPLEX_MD_RE = re.compile(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
r"```" # fenced code block
|
2026-03-06 10:11:53 +08:00
|
|
|
|
r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
r"|^#{1,6}\s+", # headings
|
|
|
|
|
|
re.MULTILINE,
|
2026-03-06 10:11:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Simple markdown patterns (bold, italic, strikethrough)
|
|
|
|
|
|
_SIMPLE_MD_RE = re.compile(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
r"\*\*.+?\*\*" # **bold**
|
|
|
|
|
|
r"|__.+?__" # __bold__
|
2026-03-06 10:11:53 +08:00
|
|
|
|
r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (single *)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
r"|~~.+?~~", # ~~strikethrough~~
|
|
|
|
|
|
re.DOTALL,
|
2026-03-06 10:11:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Markdown link: [text](url)
|
|
|
|
|
|
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\)]+)\)")
|
|
|
|
|
|
|
|
|
|
|
|
# Unordered list items
|
|
|
|
|
|
_LIST_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
# Ordered list items
|
|
|
|
|
|
_OLIST_RE = re.compile(r"^[\s]*\d+\.\s+", re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
# Max length for plain text format
|
|
|
|
|
|
_TEXT_MAX_LEN = 200
|
|
|
|
|
|
|
|
|
|
|
|
# Max length for post (rich text) format; beyond this, use card
|
|
|
|
|
|
_POST_MAX_LEN = 2000
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _detect_msg_format(cls, content: str) -> str:
|
|
|
|
|
|
"""Determine the optimal Feishu message format for *content*.
|
|
|
|
|
|
|
|
|
|
|
|
Returns one of:
|
|
|
|
|
|
- ``"text"`` – plain text, short and no markdown
|
|
|
|
|
|
- ``"post"`` – rich text (links only, moderate length)
|
|
|
|
|
|
- ``"interactive"`` – card with full markdown rendering
|
|
|
|
|
|
"""
|
|
|
|
|
|
stripped = content.strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Complex markdown (code blocks, tables, headings) → always card
|
|
|
|
|
|
if cls._COMPLEX_MD_RE.search(stripped):
|
|
|
|
|
|
return "interactive"
|
|
|
|
|
|
|
|
|
|
|
|
# Long content → card (better readability with card layout)
|
|
|
|
|
|
if len(stripped) > cls._POST_MAX_LEN:
|
|
|
|
|
|
return "interactive"
|
|
|
|
|
|
|
|
|
|
|
|
# Has bold/italic/strikethrough → card (post format can't render these)
|
|
|
|
|
|
if cls._SIMPLE_MD_RE.search(stripped):
|
|
|
|
|
|
return "interactive"
|
|
|
|
|
|
|
|
|
|
|
|
# Has list items → card (post format can't render list bullets well)
|
|
|
|
|
|
if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped):
|
|
|
|
|
|
return "interactive"
|
|
|
|
|
|
|
|
|
|
|
|
# Has links → post format (supports <a> tags)
|
|
|
|
|
|
if cls._MD_LINK_RE.search(stripped):
|
|
|
|
|
|
return "post"
|
|
|
|
|
|
|
|
|
|
|
|
# Short plain text → text format
|
|
|
|
|
|
if len(stripped) <= cls._TEXT_MAX_LEN:
|
|
|
|
|
|
return "text"
|
|
|
|
|
|
|
|
|
|
|
|
# Medium plain text without any formatting → post format
|
|
|
|
|
|
return "post"
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _markdown_to_post(cls, content: str) -> str:
|
|
|
|
|
|
"""Convert markdown content to Feishu post message JSON.
|
|
|
|
|
|
|
|
|
|
|
|
Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags.
|
|
|
|
|
|
Each line becomes a paragraph (row) in the post body.
|
|
|
|
|
|
"""
|
|
|
|
|
|
lines = content.strip().split("\n")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
paragraphs: list[list[dict[str, Any]]] = []
|
2026-03-06 10:11:53 +08:00
|
|
|
|
|
|
|
|
|
|
for line in lines:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
elements: list[dict[str, Any]] = []
|
2026-03-06 10:11:53 +08:00
|
|
|
|
last_end = 0
|
|
|
|
|
|
|
|
|
|
|
|
for m in cls._MD_LINK_RE.finditer(line):
|
|
|
|
|
|
# Text before this link
|
2026-03-30 18:11:01 +08:00
|
|
|
|
before = line[last_end : m.start()]
|
2026-03-06 10:11:53 +08:00
|
|
|
|
if before:
|
|
|
|
|
|
elements.append({"tag": "text", "text": before})
|
2026-03-30 18:11:01 +08:00
|
|
|
|
elements.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"tag": "a",
|
|
|
|
|
|
"text": m.group(1),
|
|
|
|
|
|
"href": m.group(2),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-03-06 10:11:53 +08:00
|
|
|
|
last_end = m.end()
|
|
|
|
|
|
|
|
|
|
|
|
# Remaining text after last link
|
|
|
|
|
|
remaining = line[last_end:]
|
|
|
|
|
|
if remaining:
|
|
|
|
|
|
elements.append({"tag": "text", "text": remaining})
|
|
|
|
|
|
|
|
|
|
|
|
# Empty line → empty paragraph for spacing
|
|
|
|
|
|
if not elements:
|
|
|
|
|
|
elements.append({"tag": "text", "text": ""})
|
|
|
|
|
|
|
|
|
|
|
|
paragraphs.append(elements)
|
|
|
|
|
|
|
|
|
|
|
|
post_body = {
|
|
|
|
|
|
"zh_cn": {
|
|
|
|
|
|
"content": paragraphs,
|
2026-02-07 09:46:53 +00:00
|
|
|
|
}
|
2026-03-06 10:11:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
return json.dumps(post_body, ensure_ascii=False)
|
|
|
|
|
|
|
2026-02-19 16:31:00 +08:00
|
|
|
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
|
|
|
|
|
_AUDIO_EXTS = {".opus"}
|
2026-03-06 01:54:00 +08:00
|
|
|
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi"}
|
2026-02-19 16:31:00 +08:00
|
|
|
|
_FILE_TYPE_MAP = {
|
2026-03-30 18:11:01 +08:00
|
|
|
|
".opus": "opus",
|
|
|
|
|
|
".mp4": "mp4",
|
|
|
|
|
|
".pdf": "pdf",
|
|
|
|
|
|
".doc": "doc",
|
|
|
|
|
|
".docx": "doc",
|
|
|
|
|
|
".xls": "xls",
|
|
|
|
|
|
".xlsx": "xls",
|
|
|
|
|
|
".ppt": "ppt",
|
|
|
|
|
|
".pptx": "ppt",
|
2026-02-19 16:31:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _upload_image_sync(self, file_path: str) -> str | None:
|
|
|
|
|
|
"""Upload an image to Feishu and return the image_key."""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-19 16:31:00 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with open(file_path, "rb") as f:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
CreateImageRequest.builder()
|
2026-02-19 16:31:00 +08:00
|
|
|
|
.request_body(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
CreateImageRequestBody.builder().image_type("message").image(f).build()
|
|
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
response = self._client.im.v1.image.create(request)
|
|
|
|
|
|
if response.success():
|
|
|
|
|
|
image_key = response.data.image_key
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return image_key
|
|
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to upload image: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return None
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error uploading image {}", file_path)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _upload_file_sync(self, file_path: str) -> str | None:
|
|
|
|
|
|
"""Upload a file to Feishu and return the file_key."""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-19 16:31:00 +08:00
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
|
|
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
|
|
|
|
|
file_name = os.path.basename(file_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(file_path, "rb") as f:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
CreateFileRequest.builder()
|
2026-02-19 16:31:00 +08:00
|
|
|
|
.request_body(
|
|
|
|
|
|
CreateFileRequestBody.builder()
|
|
|
|
|
|
.file_type(file_type)
|
|
|
|
|
|
.file_name(file_name)
|
|
|
|
|
|
.file(f)
|
|
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
response = self._client.im.v1.file.create(request)
|
|
|
|
|
|
if response.success():
|
|
|
|
|
|
file_key = response.data.file_key
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Uploaded file {}: {}", file_name, file_key)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return file_key
|
|
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to upload file: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return None
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error uploading file {}", file_path)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
def _download_image_sync(
|
|
|
|
|
|
self, message_id: str, image_key: str
|
|
|
|
|
|
) -> tuple[bytes | None, str | None]:
|
2026-02-21 14:08:25 +08:00
|
|
|
|
"""Download an image from Feishu message by message_id and image_key."""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
from lark_oapi.api.im.v1 import GetMessageResourceRequest
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-21 12:56:57 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
GetMessageResourceRequest.builder()
|
|
|
|
|
|
.message_id(message_id)
|
|
|
|
|
|
.file_key(image_key)
|
|
|
|
|
|
.type("image")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-02-21 14:08:25 +08:00
|
|
|
|
response = self._client.im.v1.message_resource.get(request)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
if response.success():
|
2026-02-21 14:08:25 +08:00
|
|
|
|
file_data = response.file
|
|
|
|
|
|
# GetMessageResourceRequest returns BytesIO, need to read bytes
|
2026-03-30 18:11:01 +08:00
|
|
|
|
if hasattr(file_data, "read"):
|
2026-02-21 14:08:25 +08:00
|
|
|
|
file_data = file_data.read()
|
|
|
|
|
|
return file_data, response.file_name
|
2026-02-21 12:56:57 +08:00
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to download image: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
return None, None
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error downloading image {}", image_key)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
return None, None
|
|
|
|
|
|
|
2026-02-22 18:16:45 +00:00
|
|
|
|
def _download_file_sync(
|
|
|
|
|
|
self, message_id: str, file_key: str, resource_type: str = "file"
|
|
|
|
|
|
) -> tuple[bytes | None, str | None]:
|
|
|
|
|
|
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
from lark_oapi.api.im.v1 import GetMessageResourceRequest
|
2026-03-04 20:04:00 +01:00
|
|
|
|
|
2026-04-04 01:36:44 +08:00
|
|
|
|
# Feishu resource download API only accepts 'image' or 'file' as type.
|
|
|
|
|
|
# Both 'audio' and 'media' (video) messages use type='file' for download.
|
|
|
|
|
|
if resource_type in ("audio", "media"):
|
2026-03-04 20:04:00 +01:00
|
|
|
|
resource_type = "file"
|
|
|
|
|
|
|
2026-02-21 12:56:57 +08:00
|
|
|
|
try:
|
2026-02-22 18:16:45 +00:00
|
|
|
|
request = (
|
|
|
|
|
|
GetMessageResourceRequest.builder()
|
|
|
|
|
|
.message_id(message_id)
|
|
|
|
|
|
.file_key(file_key)
|
|
|
|
|
|
.type(resource_type)
|
2026-02-22 17:15:00 +08:00
|
|
|
|
.build()
|
2026-02-22 18:16:45 +00:00
|
|
|
|
)
|
2026-02-22 17:15:00 +08:00
|
|
|
|
response = self._client.im.v1.message_resource.get(request)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
if response.success():
|
2026-02-22 17:15:00 +08:00
|
|
|
|
file_data = response.file
|
2026-02-22 18:16:45 +00:00
|
|
|
|
if hasattr(file_data, "read"):
|
2026-02-22 17:15:00 +08:00
|
|
|
|
file_data = file_data.read()
|
|
|
|
|
|
return file_data, response.file_name
|
2026-02-21 12:56:57 +08:00
|
|
|
|
else:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to download {}: code={}, msg={}",
|
|
|
|
|
|
resource_type,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
|
|
|
|
|
)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
return None, None
|
2026-02-22 18:16:45 +00:00
|
|
|
|
except Exception:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.exception("Error downloading {} {}", resource_type, file_key)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
return None, None
|
|
|
|
|
|
|
2026-05-14 23:43:06 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _safe_media_filename(filename: str | None, fallback: str) -> str:
|
|
|
|
|
|
"""Return a local-only filename for downloaded Feishu media."""
|
|
|
|
|
|
candidate = filename or fallback
|
|
|
|
|
|
# Feishu/Lark filenames come from message metadata. Treat both POSIX
|
|
|
|
|
|
# and Windows separators as path boundaries before applying the shared
|
|
|
|
|
|
# filename sanitizer so downloads cannot escape the channel media dir.
|
|
|
|
|
|
candidate = os.path.basename(candidate.replace("\\", "/"))
|
|
|
|
|
|
candidate = safe_filename(candidate)
|
|
|
|
|
|
if candidate in ("", ".", ".."):
|
|
|
|
|
|
return safe_filename(fallback) or uuid.uuid4().hex
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
2026-02-21 12:56:57 +08:00
|
|
|
|
async def _download_and_save_media(
|
2026-07-29 21:37:11 +08:00
|
|
|
|
self, msg_type: str, content_json: dict[str, Any], message_id: str | None = None
|
2026-02-21 12:56:57 +08:00
|
|
|
|
) -> tuple[str | None, str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Download media from Feishu and save to local disk.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(file_path, content_text) - file_path is None if download failed
|
|
|
|
|
|
"""
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
2026-03-08 02:58:25 +00:00
|
|
|
|
media_dir = get_media_dir("feishu")
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
data, filename = None, None
|
2026-05-14 23:43:06 +08:00
|
|
|
|
fallback_filename = uuid.uuid4().hex
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
if msg_type == "image":
|
|
|
|
|
|
image_key = content_json.get("image_key")
|
2026-02-21 14:08:25 +08:00
|
|
|
|
if image_key and message_id:
|
2026-05-14 23:43:06 +08:00
|
|
|
|
fallback_filename = f"{image_key[:16]}.jpg"
|
2026-02-21 12:56:57 +08:00
|
|
|
|
data, filename = await loop.run_in_executor(
|
2026-02-21 14:08:25 +08:00
|
|
|
|
None, self._download_image_sync, message_id, image_key
|
2026-02-21 12:56:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
if not filename:
|
2026-05-14 23:43:06 +08:00
|
|
|
|
filename = fallback_filename
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-02-22 17:37:33 +08:00
|
|
|
|
elif msg_type in ("audio", "file", "media"):
|
2026-02-21 12:56:57 +08:00
|
|
|
|
file_key = content_json.get("file_key")
|
2026-04-09 09:18:33 +08:00
|
|
|
|
if not file_key:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("{} message missing file_key: {}", msg_type, content_json)
|
2026-04-09 09:18:33 +08:00
|
|
|
|
return None, f"[{msg_type}: missing file_key]"
|
|
|
|
|
|
if not message_id:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("{} message missing message_id", msg_type)
|
2026-04-09 09:18:33 +08:00
|
|
|
|
return None, f"[{msg_type}: missing message_id]"
|
|
|
|
|
|
|
2026-05-14 23:43:06 +08:00
|
|
|
|
fallback_filename = file_key[:16]
|
2026-04-09 09:18:33 +08:00
|
|
|
|
data, filename = await loop.run_in_executor(
|
|
|
|
|
|
None, self._download_file_sync, message_id, file_key, msg_type
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not data:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("{} download failed: file_key={}", msg_type, file_key)
|
2026-04-09 09:18:33 +08:00
|
|
|
|
return None, f"[{msg_type}: download failed]"
|
|
|
|
|
|
|
|
|
|
|
|
if not filename:
|
2026-05-14 23:43:06 +08:00
|
|
|
|
filename = fallback_filename
|
|
|
|
|
|
|
2026-04-09 09:18:33 +08:00
|
|
|
|
# Feishu voice messages are opus in OGG container.
|
|
|
|
|
|
# Use .ogg extension for better Whisper compatibility.
|
|
|
|
|
|
if msg_type == "audio":
|
|
|
|
|
|
if not any(filename.endswith(ext) for ext in (".opus", ".ogg", ".oga")):
|
|
|
|
|
|
filename = f"{filename}.ogg"
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
if data and filename:
|
2026-05-14 23:43:06 +08:00
|
|
|
|
filename = self._safe_media_filename(filename, fallback_filename)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
file_path = media_dir / filename
|
|
|
|
|
|
file_path.write_bytes(data)
|
2026-05-05 13:46:08 +08:00
|
|
|
|
path_str = str(file_path)
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Downloaded {} to {}", msg_type, path_str)
|
2026-05-05 13:46:08 +08:00
|
|
|
|
return path_str, f"[{msg_type}: {path_str}]"
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
return None, f"[{msg_type}: download failed]"
|
|
|
|
|
|
|
2026-03-13 15:02:57 +08:00
|
|
|
|
_REPLY_CONTEXT_MAX_LEN = 200
|
|
|
|
|
|
|
|
|
|
|
|
def _get_message_content_sync(self, message_id: str) -> str | None:
|
|
|
|
|
|
"""Fetch the text content of a Feishu message by ID (synchronous).
|
|
|
|
|
|
|
|
|
|
|
|
Returns a "[Reply to: ...]" context string, or None on failure.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from lark_oapi.api.im.v1 import GetMessageRequest
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-03-13 15:02:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
request = GetMessageRequest.builder().message_id(message_id).build()
|
|
|
|
|
|
response = self._client.im.v1.message.get(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug(
|
|
|
|
|
|
"could not fetch parent message {}: code={}, msg={}",
|
2026-03-30 18:11:01 +08:00
|
|
|
|
message_id,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
2026-03-13 15:02:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
items = getattr(response.data, "items", None)
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
return None
|
|
|
|
|
|
msg_obj = items[0]
|
|
|
|
|
|
raw_content = getattr(msg_obj, "body", None)
|
|
|
|
|
|
raw_content = getattr(raw_content, "content", None) if raw_content else None
|
|
|
|
|
|
if not raw_content:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
content_json = json.loads(raw_content)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
msg_type = getattr(msg_obj, "msg_type", "")
|
|
|
|
|
|
if msg_type == "text":
|
|
|
|
|
|
text = content_json.get("text", "").strip()
|
|
|
|
|
|
elif msg_type == "post":
|
|
|
|
|
|
text, _ = _extract_post_content(content_json)
|
|
|
|
|
|
text = text.strip()
|
|
|
|
|
|
else:
|
|
|
|
|
|
text = ""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if len(text) > self._REPLY_CONTEXT_MAX_LEN:
|
|
|
|
|
|
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
|
|
|
|
|
return f"[Reply to: {text}]"
|
|
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("error fetching parent message {}: {}", message_id, e)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2026-04-16 00:26:01 +08:00
|
|
|
|
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
|
|
|
|
|
|
"""Reply to an existing Feishu message using the Reply API (synchronous).
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
reply_in_thread: If True, reply as a thread/topic message
|
|
|
|
|
|
in the Feishu client.
|
|
|
|
|
|
"""
|
2026-03-13 15:02:57 +08:00
|
|
|
|
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-03-13 15:02:57 +08:00
|
|
|
|
try:
|
2026-04-16 00:26:01 +08:00
|
|
|
|
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
|
|
|
|
|
|
if reply_in_thread:
|
|
|
|
|
|
body_builder = body_builder.reply_in_thread(True)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
ReplyMessageRequest.builder()
|
|
|
|
|
|
.message_id(parent_message_id)
|
2026-04-16 00:26:01 +08:00
|
|
|
|
.request_body(body_builder.build())
|
2026-03-30 18:11:01 +08:00
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
response = self._client.im.v1.message.reply(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
|
|
|
|
|
"Failed to reply to message {}: code={}, msg={}, log_id={}",
|
2026-03-30 18:11:01 +08:00
|
|
|
|
parent_message_id,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
|
|
|
|
|
response.get_log_id(),
|
2026-03-13 15:02:57 +08:00
|
|
|
|
)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if msg_type == "interactive":
|
|
|
|
|
|
return self._reply_interactive_fallback_sync(
|
|
|
|
|
|
parent_message_id,
|
|
|
|
|
|
content,
|
|
|
|
|
|
reply_in_thread=reply_in_thread,
|
|
|
|
|
|
)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
return False
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("reply sent to message {}", parent_message_id)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
return True
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error replying to message {}", parent_message_id)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if msg_type == "interactive":
|
|
|
|
|
|
return self._reply_interactive_fallback_sync(
|
|
|
|
|
|
parent_message_id,
|
|
|
|
|
|
content,
|
|
|
|
|
|
reply_in_thread=reply_in_thread,
|
|
|
|
|
|
)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-07-13 13:11:46 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _interactive_content_to_text(content: str) -> str | None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(content)
|
|
|
|
|
|
except (TypeError, json.JSONDecodeError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
parts = [part.strip() for part in _extract_interactive_content(payload) if part.strip()]
|
|
|
|
|
|
text = "\n".join(parts).strip()
|
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _fallback_text_chunks(text: str, limit: int = 3500) -> list[str]:
|
|
|
|
|
|
text = text.strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return []
|
|
|
|
|
|
chunks: list[str] = []
|
|
|
|
|
|
remaining = text
|
|
|
|
|
|
while remaining:
|
|
|
|
|
|
if len(remaining) <= limit:
|
|
|
|
|
|
chunks.append(remaining)
|
|
|
|
|
|
break
|
|
|
|
|
|
split_at = remaining.rfind("\n", 0, limit)
|
|
|
|
|
|
if split_at < limit // 2:
|
|
|
|
|
|
split_at = limit
|
|
|
|
|
|
chunks.append(remaining[:split_at].strip())
|
|
|
|
|
|
remaining = remaining[split_at:].strip()
|
|
|
|
|
|
return [chunk for chunk in chunks if chunk]
|
|
|
|
|
|
|
|
|
|
|
|
def _reply_interactive_fallback_sync(
|
|
|
|
|
|
self,
|
|
|
|
|
|
parent_message_id: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
reply_in_thread: bool = False,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
text = self._interactive_content_to_text(content)
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
sent = False
|
|
|
|
|
|
for chunk in self._fallback_text_chunks(text):
|
|
|
|
|
|
body = json.dumps({"text": chunk}, ensure_ascii=False)
|
|
|
|
|
|
sent = self._reply_message_sync(
|
|
|
|
|
|
parent_message_id,
|
|
|
|
|
|
"text",
|
|
|
|
|
|
body,
|
|
|
|
|
|
reply_in_thread=reply_in_thread,
|
|
|
|
|
|
) or sent
|
|
|
|
|
|
if sent:
|
|
|
|
|
|
self.logger.warning("Sent Feishu interactive reply as text fallback")
|
|
|
|
|
|
return sent
|
|
|
|
|
|
|
|
|
|
|
|
def _send_interactive_fallback_sync(
|
|
|
|
|
|
self,
|
|
|
|
|
|
receive_id_type: str,
|
|
|
|
|
|
receive_id: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
) -> str | None:
|
|
|
|
|
|
text = self._interactive_content_to_text(content)
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return None
|
|
|
|
|
|
last_message_id: str | None = None
|
|
|
|
|
|
for chunk in self._fallback_text_chunks(text):
|
|
|
|
|
|
body = json.dumps({"text": chunk}, ensure_ascii=False)
|
|
|
|
|
|
message_id = self._send_message_sync(receive_id_type, receive_id, "text", body)
|
|
|
|
|
|
if message_id:
|
|
|
|
|
|
last_message_id = message_id
|
|
|
|
|
|
if last_message_id:
|
|
|
|
|
|
self.logger.warning("Sent Feishu interactive message as text fallback")
|
|
|
|
|
|
return last_message_id
|
|
|
|
|
|
|
2026-04-30 11:18:34 +08:00
|
|
|
|
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
|
|
|
|
|
|
"""Return whether a group reply should create a Feishu thread/topic."""
|
|
|
|
|
|
return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message
|
|
|
|
|
|
|
2026-04-30 04:54:16 +00:00
|
|
|
|
def _thread_reply_target(self, metadata: dict[str, Any]) -> str | None:
|
|
|
|
|
|
"""Return the message_id that should receive a Reply API response."""
|
|
|
|
|
|
if metadata.get("chat_type", "group") != "group":
|
|
|
|
|
|
return None
|
|
|
|
|
|
message_id = metadata.get("message_id")
|
|
|
|
|
|
if not message_id:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if metadata.get("thread_id") or self.config.reply_to_message:
|
|
|
|
|
|
return message_id
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
def _send_message_sync(
|
|
|
|
|
|
self, receive_id_type: str, receive_id: str, msg_type: str, content: str
|
|
|
|
|
|
) -> str | None:
|
2026-03-24 15:57:14 +08:00
|
|
|
|
"""Send a single message and return the message_id on success."""
|
2026-03-04 19:31:39 +01:00
|
|
|
|
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
CreateMessageRequest.builder()
|
|
|
|
|
|
.receive_id_type(receive_id_type)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
.request_body(
|
|
|
|
|
|
CreateMessageRequestBody.builder()
|
2026-02-19 16:31:00 +08:00
|
|
|
|
.receive_id(receive_id)
|
|
|
|
|
|
.msg_type(msg_type)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
.content(content)
|
|
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-02-04 14:07:45 +08:00
|
|
|
|
response = self._client.im.v1.message.create(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.error(
|
|
|
|
|
|
"Failed to send {} message: code={}, msg={}, log_id={}",
|
2026-03-30 18:11:01 +08:00
|
|
|
|
msg_type,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
|
|
|
|
|
response.get_log_id(),
|
2026-02-04 14:07:45 +08:00
|
|
|
|
)
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if msg_type == "interactive":
|
|
|
|
|
|
return self._send_interactive_fallback_sync(
|
|
|
|
|
|
receive_id_type,
|
|
|
|
|
|
receive_id,
|
|
|
|
|
|
content,
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return None
|
|
|
|
|
|
msg_id = getattr(response.data, "message_id", None)
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return msg_id
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error sending {} message", msg_type)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2026-04-19 21:39:50 +08:00
|
|
|
|
def _create_streaming_card_sync(
|
|
|
|
|
|
self,
|
|
|
|
|
|
receive_id_type: str,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
reply_message_id: str | None = None,
|
2026-04-30 11:18:34 +08:00
|
|
|
|
*,
|
|
|
|
|
|
reply_in_thread: bool = False,
|
2026-04-19 21:39:50 +08:00
|
|
|
|
) -> str | None:
|
|
|
|
|
|
"""Create a CardKit streaming card, send it to chat, return card_id.
|
|
|
|
|
|
|
|
|
|
|
|
When *reply_message_id* is provided the card is delivered via the
|
2026-04-30 11:18:34 +08:00
|
|
|
|
reply API. *reply_in_thread* controls whether Feishu creates a
|
|
|
|
|
|
thread/topic for that reply. Otherwise the plain create-message API is
|
|
|
|
|
|
used.
|
2026-04-19 21:39:50 +08:00
|
|
|
|
"""
|
2026-03-24 15:57:14 +08:00
|
|
|
|
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-03-24 15:57:14 +08:00
|
|
|
|
card_json = {
|
|
|
|
|
|
"schema": "2.0",
|
|
|
|
|
|
"config": {"wide_screen_mode": True, "update_multi": True, "streaming_mode": True},
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"body": {
|
|
|
|
|
|
"elements": [{"tag": "markdown", "content": "", "element_id": _STREAM_ELEMENT_ID}]
|
|
|
|
|
|
},
|
2026-03-24 15:57:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
CreateCardRequest.builder()
|
|
|
|
|
|
.request_body(
|
|
|
|
|
|
CreateCardRequestBody.builder()
|
|
|
|
|
|
.type("card_json")
|
|
|
|
|
|
.data(json.dumps(card_json, ensure_ascii=False))
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
response = self._client.cardkit.v1.card.create(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to create streaming card: code={}, msg={}", response.code, response.msg
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return None
|
|
|
|
|
|
card_id = getattr(response.data, "card_id", None)
|
|
|
|
|
|
if card_id:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
card_content = json.dumps(
|
|
|
|
|
|
{"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
|
2026-03-24 15:57:14 +08:00
|
|
|
|
)
|
2026-04-19 21:39:50 +08:00
|
|
|
|
if reply_message_id:
|
|
|
|
|
|
sent = self._reply_message_sync(
|
|
|
|
|
|
reply_message_id, "interactive", card_content,
|
2026-04-30 11:18:34 +08:00
|
|
|
|
reply_in_thread=reply_in_thread,
|
2026-04-19 21:39:50 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sent = self._send_message_sync(
|
|
|
|
|
|
receive_id_type, chat_id, "interactive", card_content,
|
|
|
|
|
|
) is not None
|
|
|
|
|
|
if sent:
|
2026-03-27 13:54:44 +00:00
|
|
|
|
return card_id
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
|
|
|
|
|
)
|
2026-03-27 13:54:44 +00:00
|
|
|
|
return None
|
2026-03-24 15:57:14 +08:00
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Error creating streaming card: {}", e)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
|
|
|
|
|
|
"""Stream-update the markdown element on a CardKit card (typewriter effect)."""
|
2026-03-30 18:11:01 +08:00
|
|
|
|
from lark_oapi.api.cardkit.v1 import (
|
|
|
|
|
|
ContentCardElementRequest,
|
|
|
|
|
|
ContentCardElementRequestBody,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-24 15:57:14 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
ContentCardElementRequest.builder()
|
|
|
|
|
|
.card_id(card_id)
|
|
|
|
|
|
.element_id(_STREAM_ELEMENT_ID)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
.request_body(
|
|
|
|
|
|
ContentCardElementRequestBody.builder()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
.content(content)
|
|
|
|
|
|
.sequence(sequence)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
response = self._client.cardkit.v1.card_element.content(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
"Failed to stream-update card {}: code={}, msg={}",
|
|
|
|
|
|
card_id,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Error stream-updating card {}: {}", card_id, e)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-17 16:16:15 +08:00
|
|
|
|
def _set_streaming_mode_sync(self, card_id: str, enabled: bool, sequence: int) -> bool:
|
|
|
|
|
|
"""Set CardKit streaming_mode using a strictly increasing sequence."""
|
2026-03-24 15:57:14 +08:00
|
|
|
|
from lark_oapi.api.cardkit.v1 import SettingsCardRequest, SettingsCardRequestBody
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-06-17 16:16:15 +08:00
|
|
|
|
settings_payload = json.dumps({"config": {"streaming_mode": enabled}}, ensure_ascii=False)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
try:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
request = (
|
|
|
|
|
|
SettingsCardRequest.builder()
|
|
|
|
|
|
.card_id(card_id)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
.request_body(
|
|
|
|
|
|
SettingsCardRequestBody.builder()
|
|
|
|
|
|
.settings(settings_payload)
|
|
|
|
|
|
.sequence(sequence)
|
|
|
|
|
|
.uuid(str(uuid.uuid4()))
|
|
|
|
|
|
.build()
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
.build()
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
response = self._client.cardkit.v1.card.settings(request)
|
|
|
|
|
|
if not response.success():
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-06-17 16:16:15 +08:00
|
|
|
|
"Failed to set streaming={} on card {}: code={}, msg={}",
|
|
|
|
|
|
enabled,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
card_id,
|
|
|
|
|
|
response.code,
|
|
|
|
|
|
response.msg,
|
2026-03-24 15:57:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
2026-06-17 16:16:15 +08:00
|
|
|
|
self.logger.warning("Error setting streaming={} on card {}: {}", enabled, card_id, e)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-17 16:16:15 +08:00
|
|
|
|
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
|
|
|
|
|
|
"""Turn off CardKit streaming_mode so the chat list preview exits the streaming placeholder.
|
|
|
|
|
|
|
|
|
|
|
|
Per Feishu docs, streaming cards keep a generating-style summary in the session list until
|
|
|
|
|
|
streaming_mode is set to false via card settings (after final content update).
|
|
|
|
|
|
Sequence must strictly exceed the previous card OpenAPI operation on this entity.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self._set_streaming_mode_sync(card_id, False, sequence)
|
|
|
|
|
|
|
|
|
|
|
|
def _stream_update_text_with_reopen_sync(
|
|
|
|
|
|
self,
|
|
|
|
|
|
card_id: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
sequence: int,
|
|
|
|
|
|
) -> tuple[bool, int]:
|
|
|
|
|
|
if self._stream_update_text_sync(card_id, content, sequence):
|
|
|
|
|
|
return True, sequence
|
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
if not self._set_streaming_mode_sync(card_id, True, sequence):
|
|
|
|
|
|
return False, sequence
|
|
|
|
|
|
sequence += 1
|
|
|
|
|
|
return self._stream_update_text_sync(card_id, content, sequence), sequence
|
|
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
async def send_delta(
|
2026-06-30 00:03:07 +08:00
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
delta: str,
|
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
stream_id: str | None = None,
|
|
|
|
|
|
stream_end: bool = False,
|
|
|
|
|
|
resuming: bool = False,
|
2026-07-23 18:35:41 +08:00
|
|
|
|
merge_next: bool = False,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
) -> None:
|
2026-04-08 18:31:40 +08:00
|
|
|
|
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
|
|
|
|
|
|
|
|
|
|
|
|
Supported metadata keys:
|
2026-06-30 00:03:07 +08:00
|
|
|
|
message_id: Original message id (used with stream end for reaction cleanup).
|
2026-04-19 21:39:50 +08:00
|
|
|
|
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
2026-04-08 18:31:40 +08:00
|
|
|
|
"""
|
2026-03-24 15:57:14 +08:00
|
|
|
|
if not self._client:
|
|
|
|
|
|
return
|
|
|
|
|
|
meta = metadata or {}
|
2026-04-26 08:07:30 +00:00
|
|
|
|
stream_key = self._stream_key(chat_id, meta)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
|
|
|
|
|
|
|
|
|
|
|
# --- stream end: final update or fallback ---
|
2026-07-27 01:33:04 +08:00
|
|
|
|
if stream_end and merge_next:
|
|
|
|
|
|
if not delta:
|
|
|
|
|
|
return
|
|
|
|
|
|
stream_end = False
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if stream_end:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
message_id = meta.get("message_id")
|
2026-04-28 17:09:41 +08:00
|
|
|
|
# Only finalize the OnIt -> DONE reaction transition on the truly
|
2026-06-30 00:03:07 +08:00
|
|
|
|
# final stream end. resuming=True means the agent will keep
|
2026-04-28 17:09:41 +08:00
|
|
|
|
# working (more tool-call rounds), so leave the reaction state
|
|
|
|
|
|
# in place — otherwise the OnIt indicator disappears prematurely
|
|
|
|
|
|
# and the DONE reaction fires after every tool call.
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if message_id and not resuming:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
reaction_id = self._reaction_ids.pop(message_id, None)
|
|
|
|
|
|
if reaction_id:
|
|
|
|
|
|
await self._remove_reaction(message_id, reaction_id)
|
2026-04-07 23:56:23 +08:00
|
|
|
|
# Add completion emoji if configured
|
2026-04-19 21:39:50 +08:00
|
|
|
|
if self.config.done_emoji:
|
2026-04-07 23:56:23 +08:00
|
|
|
|
await self._add_reaction(message_id, self.config.done_emoji)
|
2026-04-03 21:07:41 +08:00
|
|
|
|
|
2026-04-26 08:07:30 +00:00
|
|
|
|
buf = self._stream_bufs.pop(stream_key, None)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
if not buf or not buf.text:
|
|
|
|
|
|
return
|
2026-04-14 14:14:14 +08:00
|
|
|
|
# Try to finalize via streaming card; if that fails (e.g.
|
|
|
|
|
|
# streaming mode was closed by Feishu due to timeout), fall
|
|
|
|
|
|
# back to sending a regular interactive card.
|
2026-03-24 15:57:14 +08:00
|
|
|
|
if buf.card_id:
|
|
|
|
|
|
buf.sequence += 1
|
2026-06-17 16:16:15 +08:00
|
|
|
|
ok, buf.sequence = await loop.run_in_executor(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
None,
|
2026-06-17 16:16:15 +08:00
|
|
|
|
self._stream_update_text_with_reopen_sync,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.text,
|
|
|
|
|
|
buf.sequence,
|
2026-03-24 15:57:14 +08:00
|
|
|
|
)
|
2026-04-14 14:14:14 +08:00
|
|
|
|
if ok:
|
|
|
|
|
|
buf.sequence += 1
|
2026-06-17 16:16:15 +08:00
|
|
|
|
closed = await loop.run_in_executor(
|
2026-04-14 14:14:14 +08:00
|
|
|
|
None,
|
|
|
|
|
|
self._close_streaming_mode_sync,
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.sequence,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-06-17 16:16:15 +08:00
|
|
|
|
if not closed:
|
|
|
|
|
|
buf.sequence += 1
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None,
|
|
|
|
|
|
self._close_streaming_mode_sync,
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.sequence,
|
|
|
|
|
|
)
|
2026-04-14 14:14:14 +08:00
|
|
|
|
return
|
2026-06-17 16:16:15 +08:00
|
|
|
|
buf.sequence += 1
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None,
|
|
|
|
|
|
self._close_streaming_mode_sync,
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.sequence,
|
|
|
|
|
|
)
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning(
|
2026-04-14 14:14:14 +08:00
|
|
|
|
"Streaming card {} final update failed, falling back to regular card",
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
for chunk in self._split_elements_by_table_limit(
|
|
|
|
|
|
self._build_card_elements(buf.text)
|
|
|
|
|
|
):
|
|
|
|
|
|
card = json.dumps(
|
|
|
|
|
|
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
)
|
2026-04-30 04:54:16 +00:00
|
|
|
|
# Fallback replies stay in existing topics, but only create a
|
|
|
|
|
|
# new topic when reply-to-message is enabled.
|
|
|
|
|
|
fallback_msg_id = self._thread_reply_target(meta)
|
2026-04-19 21:39:50 +08:00
|
|
|
|
if fallback_msg_id:
|
|
|
|
|
|
await loop.run_in_executor(
|
2026-07-29 21:37:11 +08:00
|
|
|
|
None, partial(
|
|
|
|
|
|
self._reply_message_sync,
|
|
|
|
|
|
fallback_msg_id,
|
|
|
|
|
|
"interactive",
|
|
|
|
|
|
card,
|
2026-04-30 04:54:16 +00:00
|
|
|
|
reply_in_thread=self._should_use_reply_in_thread(meta),
|
2026-04-19 21:39:50 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# --- accumulate delta ---
|
2026-04-26 08:07:30 +00:00
|
|
|
|
buf = self._stream_bufs.get(stream_key)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
if buf is None:
|
|
|
|
|
|
buf = _FeishuStreamBuf()
|
2026-04-26 08:07:30 +00:00
|
|
|
|
self._stream_bufs[stream_key] = buf
|
2026-03-24 15:57:14 +08:00
|
|
|
|
buf.text += delta
|
|
|
|
|
|
if not buf.text.strip():
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
|
if buf.card_id is None:
|
2026-04-30 04:54:16 +00:00
|
|
|
|
# Use the Reply API for existing topics, and only create new topics
|
|
|
|
|
|
# when reply-to-message is enabled.
|
2026-04-30 11:18:34 +08:00
|
|
|
|
use_reply_in_thread = self._should_use_reply_in_thread(meta)
|
2026-04-30 04:54:16 +00:00
|
|
|
|
reply_msg_id = self._thread_reply_target(meta)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
card_id = await loop.run_in_executor(
|
2026-04-19 21:39:50 +08:00
|
|
|
|
None,
|
2026-04-30 11:18:34 +08:00
|
|
|
|
lambda: self._create_streaming_card_sync(
|
|
|
|
|
|
rid_type,
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
reply_msg_id,
|
|
|
|
|
|
reply_in_thread=use_reply_in_thread,
|
|
|
|
|
|
),
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
if card_id:
|
2026-06-17 16:16:15 +08:00
|
|
|
|
ok, sequence = await loop.run_in_executor(
|
|
|
|
|
|
None, self._stream_update_text_with_reopen_sync, card_id, buf.text, 1
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-06-17 16:16:15 +08:00
|
|
|
|
if ok:
|
|
|
|
|
|
buf.card_id = card_id
|
|
|
|
|
|
buf.sequence = sequence
|
|
|
|
|
|
buf.last_edit = now
|
|
|
|
|
|
else:
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None, self._close_streaming_mode_sync, card_id, sequence + 1
|
|
|
|
|
|
)
|
2026-03-24 15:57:14 +08:00
|
|
|
|
elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
|
2026-06-17 16:16:15 +08:00
|
|
|
|
ok, buf.sequence = await loop.run_in_executor(
|
|
|
|
|
|
None,
|
|
|
|
|
|
self._stream_update_text_with_reopen_sync,
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.text,
|
|
|
|
|
|
buf.sequence + 1,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
)
|
2026-06-17 16:16:15 +08:00
|
|
|
|
if ok:
|
|
|
|
|
|
buf.last_edit = now
|
|
|
|
|
|
else:
|
|
|
|
|
|
buf.sequence += 1
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None,
|
|
|
|
|
|
self._close_streaming_mode_sync,
|
|
|
|
|
|
buf.card_id,
|
|
|
|
|
|
buf.sequence,
|
|
|
|
|
|
)
|
|
|
|
|
|
buf.card_id = None
|
2026-03-24 15:57:14 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
2026-02-19 16:31:00 +08:00
|
|
|
|
"""Send a message through Feishu, including media (images/files) if present."""
|
2026-02-04 14:07:45 +08:00
|
|
|
|
if not self._client:
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("client not initialized")
|
2026-02-04 14:07:45 +08:00
|
|
|
|
return
|
2026-02-19 16:31:00 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
try:
|
2026-02-19 17:33:08 +00:00
|
|
|
|
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
2026-02-19 16:31:00 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
|
2026-04-01 17:32:55 +08:00
|
|
|
|
# Handle tool hint messages. When a streaming card is active for
|
|
|
|
|
|
# this chat, inline the hint into the card instead of sending a
|
|
|
|
|
|
# separate message so the user experience stays cohesive.
|
2026-06-30 00:03:07 +08:00
|
|
|
|
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
|
|
|
|
|
|
|
|
|
|
|
|
if progress_event and progress_event.tool_hint:
|
2026-04-01 17:32:55 +08:00
|
|
|
|
hint = (msg.content or "").strip()
|
|
|
|
|
|
if not hint:
|
|
|
|
|
|
return
|
2026-04-26 08:07:30 +00:00
|
|
|
|
buf = self._stream_bufs.get(self._stream_key(msg.chat_id, msg.metadata))
|
2026-04-01 17:32:55 +08:00
|
|
|
|
if buf and buf.card_id:
|
2026-04-08 18:31:40 +08:00
|
|
|
|
# Delegate to send_delta so tool hints get the same
|
|
|
|
|
|
# throttling (and card creation) as regular text deltas.
|
2026-04-14 14:14:14 +08:00
|
|
|
|
await self.send_delta(
|
|
|
|
|
|
msg.chat_id,
|
|
|
|
|
|
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
2026-06-30 00:03:07 +08:00
|
|
|
|
metadata=msg.metadata,
|
2026-04-14 14:14:14 +08:00
|
|
|
|
)
|
2026-04-08 18:31:40 +08:00
|
|
|
|
return
|
2026-04-30 11:18:34 +08:00
|
|
|
|
# No active streaming card — send as a regular interactive card
|
2026-04-30 04:54:16 +00:00
|
|
|
|
# with the same 🔧 prefix style. Existing topics stay threaded;
|
|
|
|
|
|
# new topics are created only when reply-to-message is enabled.
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = json.dumps(
|
|
|
|
|
|
{"config": {"wide_screen_mode": True}, "elements": [
|
|
|
|
|
|
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
|
|
|
|
|
]},
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
)
|
2026-04-30 04:54:16 +00:00
|
|
|
|
_th_msg_id = self._thread_reply_target(msg.metadata)
|
|
|
|
|
|
if _th_msg_id:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None, lambda: self._reply_message_sync(
|
|
|
|
|
|
_th_msg_id, "interactive", card,
|
2026-04-30 04:54:16 +00:00
|
|
|
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
2026-04-19 21:39:50 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
|
|
|
|
|
|
)
|
2026-03-13 14:41:54 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-06 01:23:44 +08:00
|
|
|
|
if (
|
|
|
|
|
|
msg.content.strip() == "New session started."
|
|
|
|
|
|
and msg.metadata.get("chat_type") == "p2p"
|
|
|
|
|
|
and not msg.media
|
|
|
|
|
|
and not msg.buttons
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-13 15:02:57 +08:00
|
|
|
|
# Determine whether the first message should quote the user's message.
|
|
|
|
|
|
# Only the very first send (media or text) in this call uses reply; subsequent
|
|
|
|
|
|
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
2026-04-19 21:39:50 +08:00
|
|
|
|
# Always target message_id — the Feishu Reply API keeps replies in the
|
|
|
|
|
|
# same topic automatically when the target message is inside a topic.
|
2026-03-13 15:02:57 +08:00
|
|
|
|
reply_message_id: str | None = None
|
2026-04-19 21:39:50 +08:00
|
|
|
|
_msg_id = msg.metadata.get("message_id")
|
2026-05-08 23:53:13 +08:00
|
|
|
|
has_thread_id = msg.metadata.get("thread_id")
|
2026-06-30 00:03:07 +08:00
|
|
|
|
if self.config.reply_to_message and progress_event is None:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
reply_message_id = _msg_id
|
2026-03-20 22:26:27 +08:00
|
|
|
|
# For topic group messages, always reply to keep context in thread
|
2026-05-08 23:53:13 +08:00
|
|
|
|
elif has_thread_id:
|
2026-04-19 21:39:50 +08:00
|
|
|
|
reply_message_id = _msg_id
|
2026-03-13 15:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
first_send = True # tracks whether the reply has already been used
|
|
|
|
|
|
|
|
|
|
|
|
def _do_send(m_type: str, content: str) -> None:
|
2026-04-16 00:26:01 +08:00
|
|
|
|
"""Send via reply (first message) or create (subsequent).
|
|
|
|
|
|
|
2026-04-30 11:18:34 +08:00
|
|
|
|
Group chats only set reply_in_thread=True when
|
|
|
|
|
|
reply_to_message is enabled; otherwise a Reply API call for an
|
|
|
|
|
|
existing topic must not create a new topic.
|
2026-04-16 00:26:01 +08:00
|
|
|
|
"""
|
2026-03-13 15:02:57 +08:00
|
|
|
|
nonlocal first_send
|
2026-05-08 23:53:13 +08:00
|
|
|
|
if reply_message_id:
|
|
|
|
|
|
# If we're in a topic, always use reply to stay in the topic
|
|
|
|
|
|
if has_thread_id:
|
|
|
|
|
|
ok = self._reply_message_sync(
|
|
|
|
|
|
reply_message_id, m_type, content,
|
|
|
|
|
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
|
|
|
|
|
)
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
return
|
|
|
|
|
|
elif first_send:
|
|
|
|
|
|
# If we're not in a topic but replying to message, only first uses reply
|
|
|
|
|
|
first_send = False
|
|
|
|
|
|
ok = self._reply_message_sync(
|
|
|
|
|
|
reply_message_id, m_type, content,
|
|
|
|
|
|
reply_in_thread=self._should_use_reply_in_thread(msg.metadata),
|
|
|
|
|
|
)
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
return
|
2026-03-13 15:02:57 +08:00
|
|
|
|
# Fall back to regular send if reply fails
|
2026-07-15 01:08:39 +08:00
|
|
|
|
message_id = self._send_message_sync(
|
|
|
|
|
|
receive_id_type,
|
|
|
|
|
|
msg.chat_id,
|
|
|
|
|
|
m_type,
|
|
|
|
|
|
content,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not message_id:
|
|
|
|
|
|
raise RuntimeError(f"Feishu {m_type} message was not delivered")
|
2026-03-13 15:02:57 +08:00
|
|
|
|
|
2026-02-19 17:33:08 +00:00
|
|
|
|
for file_path in msg.media:
|
|
|
|
|
|
if not os.path.isfile(file_path):
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.warning("Media file not found: {}", file_path)
|
2026-02-19 17:33:08 +00:00
|
|
|
|
continue
|
|
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
|
|
if ext in self._IMAGE_EXTS:
|
|
|
|
|
|
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
|
|
|
|
|
|
if key:
|
|
|
|
|
|
await loop.run_in_executor(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
None,
|
|
|
|
|
|
_do_send,
|
|
|
|
|
|
"image",
|
|
|
|
|
|
json.dumps({"image_key": key}, ensure_ascii=False),
|
2026-02-19 17:33:08 +00:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
|
|
|
|
|
if key:
|
2026-04-24 20:00:56 +00:00
|
|
|
|
# Feishu's OpenAPI names video messages "media".
|
|
|
|
|
|
# Use "audio" for audio, "media" for video, "file" for documents.
|
2026-03-09 11:20:41 +08:00
|
|
|
|
# Feishu requires these specific msg_types for inline playback.
|
|
|
|
|
|
if ext in self._AUDIO_EXTS:
|
|
|
|
|
|
media_type = "audio"
|
|
|
|
|
|
elif ext in self._VIDEO_EXTS:
|
2026-04-24 20:00:56 +00:00
|
|
|
|
media_type = "media"
|
2026-03-06 01:54:00 +08:00
|
|
|
|
else:
|
|
|
|
|
|
media_type = "file"
|
2026-02-19 17:33:08 +00:00
|
|
|
|
await loop.run_in_executor(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
None,
|
|
|
|
|
|
_do_send,
|
|
|
|
|
|
media_type,
|
|
|
|
|
|
json.dumps({"file_key": key}, ensure_ascii=False),
|
2026-02-19 17:33:08 +00:00
|
|
|
|
)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
|
|
|
|
|
|
if msg.content and msg.content.strip():
|
2026-03-06 10:11:53 +08:00
|
|
|
|
fmt = self._detect_msg_format(msg.content)
|
|
|
|
|
|
|
|
|
|
|
|
if fmt == "text":
|
|
|
|
|
|
# Short plain text – send as simple text message
|
|
|
|
|
|
text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
await loop.run_in_executor(None, _do_send, "text", text_body)
|
2026-02-19 16:31:00 +08:00
|
|
|
|
|
2026-03-06 10:11:53 +08:00
|
|
|
|
elif fmt == "post":
|
|
|
|
|
|
# Medium content with links – send as rich-text post
|
|
|
|
|
|
post_body = self._markdown_to_post(msg.content)
|
2026-03-13 15:02:57 +08:00
|
|
|
|
await loop.run_in_executor(None, _do_send, "post", post_body)
|
2026-03-06 10:11:53 +08:00
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Complex / long content – send as interactive card
|
|
|
|
|
|
elements = self._build_card_elements(msg.content)
|
|
|
|
|
|
for chunk in self._split_elements_by_table_limit(elements):
|
|
|
|
|
|
card = {"config": {"wide_screen_mode": True}, "elements": chunk}
|
|
|
|
|
|
await loop.run_in_executor(
|
2026-03-30 18:11:01 +08:00
|
|
|
|
None,
|
|
|
|
|
|
_do_send,
|
|
|
|
|
|
"interactive",
|
|
|
|
|
|
json.dumps(card, ensure_ascii=False),
|
2026-03-06 10:11:53 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error sending message")
|
2026-03-25 14:34:37 +00:00
|
|
|
|
raise
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-07 15:02:06 +00:00
|
|
|
|
def _on_message_sync(self, data: Any) -> None:
|
2026-02-04 14:07:45 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Sync handler for incoming messages (called from WebSocket thread).
|
|
|
|
|
|
Schedules async handling in the main event loop.
|
|
|
|
|
|
"""
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if not self._running:
|
|
|
|
|
|
return
|
2026-02-05 06:01:02 +00:00
|
|
|
|
if self._loop and self._loop.is_running():
|
|
|
|
|
|
asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
|
2026-02-28 20:55:43 +08:00
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
|
2026-02-04 14:07:45 +08:00
|
|
|
|
"""Handle incoming message from Feishu."""
|
2026-07-13 13:11:46 +08:00
|
|
|
|
if not self._running:
|
|
|
|
|
|
return
|
2026-02-04 14:07:45 +08:00
|
|
|
|
try:
|
|
|
|
|
|
event = data.event
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if event is None or event.message is None or event.sender is None:
|
|
|
|
|
|
self.logger.warning("Ignoring incomplete Feishu message event")
|
|
|
|
|
|
return
|
2026-02-04 14:07:45 +08:00
|
|
|
|
message = event.message
|
|
|
|
|
|
sender = event.sender
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("raw message: {}", message.content)
|
|
|
|
|
|
self.logger.debug("mentions: {}", getattr(message, "mentions", None))
|
2026-03-30 18:11:01 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
message_id = message.message_id
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
# Skip bot messages
|
2026-02-21 12:56:57 +08:00
|
|
|
|
if sender.sender_type == "bot":
|
2026-02-04 14:07:45 +08:00
|
|
|
|
return
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-02-05 06:01:02 +00:00
|
|
|
|
sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
|
2026-02-04 14:07:45 +08:00
|
|
|
|
chat_id = message.chat_id
|
2026-02-21 12:56:57 +08:00
|
|
|
|
chat_type = message.chat_type
|
2026-02-04 14:07:45 +08:00
|
|
|
|
msg_type = message.message_type
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if not all(isinstance(value, str) and value for value in (
|
|
|
|
|
|
message_id,
|
|
|
|
|
|
sender_id,
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
chat_type,
|
|
|
|
|
|
msg_type,
|
|
|
|
|
|
)):
|
|
|
|
|
|
self.logger.warning("Ignoring Feishu message event with missing routing fields")
|
|
|
|
|
|
return
|
|
|
|
|
|
message_id = cast(str, message_id)
|
|
|
|
|
|
sender_id = cast(str, sender_id)
|
|
|
|
|
|
chat_id = cast(str, chat_id)
|
|
|
|
|
|
chat_type = cast(str, chat_type)
|
|
|
|
|
|
msg_type = cast(str, msg_type)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-03-12 04:45:57 +00:00
|
|
|
|
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("skipping group message (not mentioned)")
|
2026-03-12 04:45:57 +00:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-05 15:14:40 +00:00
|
|
|
|
# Deduplication check
|
|
|
|
|
|
if message_id in self._processed_message_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._processed_message_ids[message_id] = None
|
|
|
|
|
|
|
|
|
|
|
|
# Trim cache
|
|
|
|
|
|
while len(self._processed_message_ids) > 1000:
|
|
|
|
|
|
self._processed_message_ids.popitem(last=False)
|
|
|
|
|
|
|
2026-05-14 14:32:45 +08:00
|
|
|
|
# Early permission check — avoid side effects for unauthorized users.
|
|
|
|
|
|
# Group chats are silently ignored; DMs get a pairing code.
|
|
|
|
|
|
if not self.is_allowed(sender_id):
|
|
|
|
|
|
if chat_type == "p2p":
|
2026-05-15 10:31:29 +08:00
|
|
|
|
# content="" because the pairing reply is generated by
|
|
|
|
|
|
# BaseChannel._handle_message, not from the original message.
|
2026-05-14 14:32:45 +08:00
|
|
|
|
await self._handle_message(
|
|
|
|
|
|
sender_id=sender_id,
|
|
|
|
|
|
chat_id=sender_id,
|
|
|
|
|
|
content="",
|
|
|
|
|
|
is_dm=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-04-19 21:39:50 +08:00
|
|
|
|
# Add reaction (non-blocking — tracked background task)
|
|
|
|
|
|
task = asyncio.create_task(
|
|
|
|
|
|
self._add_reaction(message_id, self.config.react_emoji)
|
|
|
|
|
|
)
|
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
|
task.add_done_callback(self._on_background_task_done)
|
|
|
|
|
|
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
# Parse content
|
2026-07-29 21:37:11 +08:00
|
|
|
|
content_parts: list[str] = []
|
|
|
|
|
|
media_paths: list[str] = []
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-07-29 21:37:11 +08:00
|
|
|
|
raw_content = message.content if isinstance(message.content, str) else ""
|
|
|
|
|
|
content_json = _as_json_object(json.loads(raw_content)) if raw_content else {}
|
2026-02-21 12:56:57 +08:00
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
content_json = {}
|
2026-07-29 21:37:11 +08:00
|
|
|
|
content_json = content_json or {}
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
if msg_type == "text":
|
2026-02-21 12:56:57 +08:00
|
|
|
|
text = content_json.get("text", "")
|
2026-07-29 21:37:11 +08:00
|
|
|
|
if isinstance(text, str) and text:
|
2026-03-30 18:11:01 +08:00
|
|
|
|
mentions = getattr(message, "mentions", None)
|
2026-06-04 10:51:41 +08:00
|
|
|
|
text = self._strip_leading_bot_mention(text, mentions)
|
2026-03-30 18:11:01 +08:00
|
|
|
|
text = self._resolve_mentions(text, mentions)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
content_parts.append(text)
|
|
|
|
|
|
|
2026-02-14 12:14:31 +08:00
|
|
|
|
elif msg_type == "post":
|
2026-02-24 13:42:07 +08:00
|
|
|
|
text, image_keys = _extract_post_content(content_json)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
if text:
|
|
|
|
|
|
content_parts.append(text)
|
2026-02-24 13:42:07 +08:00
|
|
|
|
# Download images embedded in post
|
|
|
|
|
|
for img_key in image_keys:
|
|
|
|
|
|
file_path, content_text = await self._download_and_save_media(
|
|
|
|
|
|
"image", {"image_key": img_key}, message_id
|
|
|
|
|
|
)
|
|
|
|
|
|
if file_path:
|
|
|
|
|
|
media_paths.append(file_path)
|
|
|
|
|
|
content_parts.append(content_text)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-02-22 17:37:33 +08:00
|
|
|
|
elif msg_type in ("image", "audio", "file", "media"):
|
2026-03-30 18:11:01 +08:00
|
|
|
|
file_path, content_text = await self._download_and_save_media(
|
|
|
|
|
|
msg_type, content_json, message_id
|
|
|
|
|
|
)
|
2026-02-21 12:56:57 +08:00
|
|
|
|
if file_path:
|
|
|
|
|
|
media_paths.append(file_path)
|
2026-03-07 16:19:55 +08:00
|
|
|
|
|
2026-03-11 14:23:19 +00:00
|
|
|
|
if msg_type == "audio" and file_path:
|
|
|
|
|
|
transcription = await self.transcribe_audio(file_path)
|
|
|
|
|
|
if transcription:
|
|
|
|
|
|
content_text = f"[transcription: {transcription}]"
|
2026-03-07 16:19:55 +08:00
|
|
|
|
|
2026-02-21 12:56:57 +08:00
|
|
|
|
content_parts.append(content_text)
|
|
|
|
|
|
|
2026-03-30 18:11:01 +08:00
|
|
|
|
elif msg_type in (
|
|
|
|
|
|
"share_chat",
|
|
|
|
|
|
"share_user",
|
|
|
|
|
|
"interactive",
|
|
|
|
|
|
"share_calendar_event",
|
|
|
|
|
|
"system",
|
|
|
|
|
|
"merge_forward",
|
|
|
|
|
|
):
|
2026-02-21 14:08:25 +08:00
|
|
|
|
# Handle share cards and interactive messages
|
|
|
|
|
|
text = _extract_share_card_content(content_json, msg_type)
|
|
|
|
|
|
if text:
|
|
|
|
|
|
content_parts.append(text)
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
else:
|
2026-02-21 12:56:57 +08:00
|
|
|
|
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
|
|
|
|
|
|
2026-03-13 15:02:57 +08:00
|
|
|
|
# Extract reply context (parent/root message IDs)
|
2026-07-29 21:37:11 +08:00
|
|
|
|
parent_id = getattr(message, "parent_id", None)
|
|
|
|
|
|
root_id = getattr(message, "root_id", None)
|
|
|
|
|
|
thread_id = getattr(message, "thread_id", None)
|
|
|
|
|
|
parent_id = parent_id if isinstance(parent_id, str) else None
|
|
|
|
|
|
root_id = root_id if isinstance(root_id, str) else None
|
|
|
|
|
|
thread_id = thread_id if isinstance(thread_id, str) else None
|
2026-03-13 15:02:57 +08:00
|
|
|
|
|
|
|
|
|
|
# Prepend quoted message text when the user replied to another message
|
|
|
|
|
|
if parent_id and self._client:
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
reply_ctx = await loop.run_in_executor(
|
|
|
|
|
|
None, self._get_message_content_sync, parent_id
|
|
|
|
|
|
)
|
|
|
|
|
|
if reply_ctx:
|
|
|
|
|
|
content_parts.insert(0, reply_ctx)
|
|
|
|
|
|
|
2026-02-21 12:56:57 +08:00
|
|
|
|
content = "\n".join(content_parts) if content_parts else ""
|
|
|
|
|
|
|
|
|
|
|
|
if not content and not media_paths:
|
2026-02-04 14:07:45 +08:00
|
|
|
|
return
|
2026-02-21 12:56:57 +08:00
|
|
|
|
|
2026-07-06 01:23:44 +08:00
|
|
|
|
if chat_type == "p2p" and normalize_command_text(content).lower() == "/new":
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
await loop.run_in_executor(
|
|
|
|
|
|
None,
|
|
|
|
|
|
self._send_message_sync,
|
|
|
|
|
|
"open_id",
|
|
|
|
|
|
sender_id,
|
|
|
|
|
|
"system",
|
|
|
|
|
|
_NEW_SESSION_DIVIDER_CONTENT,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-11 17:27:52 +08:00
|
|
|
|
# Build session key for conversation isolation.
|
|
|
|
|
|
# If topic_isolation is True: each topic gets its own session via root_id/message_id.
|
|
|
|
|
|
# If topic_isolation is False: all messages in group share the same session.
|
2026-04-16 00:18:28 +08:00
|
|
|
|
# Private chat: no override — same behavior as Telegram/Slack.
|
2026-04-20 00:07:25 +08:00
|
|
|
|
if chat_type == "group":
|
2026-05-11 17:27:52 +08:00
|
|
|
|
if self.config.topic_isolation:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
session_key = f"{self.name}:{chat_id}:{root_id or message_id}"
|
2026-05-11 17:27:52 +08:00
|
|
|
|
else:
|
2026-07-13 13:11:46 +08:00
|
|
|
|
session_key = f"{self.name}:{chat_id}"
|
2026-04-16 00:18:28 +08:00
|
|
|
|
else:
|
|
|
|
|
|
session_key = None
|
|
|
|
|
|
|
2026-02-04 14:07:45 +08:00
|
|
|
|
# Forward to message bus
|
|
|
|
|
|
reply_to = chat_id if chat_type == "group" else sender_id
|
|
|
|
|
|
await self._handle_message(
|
|
|
|
|
|
sender_id=sender_id,
|
|
|
|
|
|
chat_id=reply_to,
|
|
|
|
|
|
content=content,
|
2026-02-21 12:56:57 +08:00
|
|
|
|
media=media_paths,
|
2026-02-04 14:07:45 +08:00
|
|
|
|
metadata={
|
|
|
|
|
|
"message_id": message_id,
|
|
|
|
|
|
"chat_type": chat_type,
|
|
|
|
|
|
"msg_type": msg_type,
|
2026-03-13 15:02:57 +08:00
|
|
|
|
"parent_id": parent_id,
|
|
|
|
|
|
"root_id": root_id,
|
2026-03-20 22:26:27 +08:00
|
|
|
|
"thread_id": thread_id,
|
2026-03-30 18:11:01 +08:00
|
|
|
|
},
|
2026-04-16 00:18:28 +08:00
|
|
|
|
session_key=session_key,
|
2026-05-14 13:10:44 +08:00
|
|
|
|
is_dm=chat_type == "p2p",
|
2026-02-04 14:07:45 +08:00
|
|
|
|
)
|
2026-02-08 13:03:32 +08:00
|
|
|
|
|
2026-05-06 21:11:26 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
self.logger.exception("Error processing message")
|
2026-03-07 15:02:06 +00:00
|
|
|
|
|
|
|
|
|
|
def _on_reaction_created(self, data: Any) -> None:
|
|
|
|
|
|
"""Ignore reaction events so they do not generate SDK noise."""
|
2026-02-08 13:03:32 +08:00
|
|
|
|
pass
|
2026-03-07 15:02:06 +00:00
|
|
|
|
|
2026-03-31 12:52:32 +08:00
|
|
|
|
def _on_reaction_deleted(self, data: Any) -> None:
|
|
|
|
|
|
"""Ignore reaction deleted events so they do not generate SDK noise."""
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-03-07 15:02:06 +00:00
|
|
|
|
def _on_message_read(self, data: Any) -> None:
|
|
|
|
|
|
"""Ignore read events so they do not generate SDK noise."""
|
2026-02-08 13:03:32 +08:00
|
|
|
|
pass
|
2026-03-07 15:02:06 +00:00
|
|
|
|
|
2026-02-08 13:03:32 +08:00
|
|
|
|
def _on_bot_p2p_chat_entered(self, data: Any) -> None:
|
2026-03-07 15:02:06 +00:00
|
|
|
|
"""Ignore p2p-enter events when a user opens a bot chat."""
|
2026-05-06 21:11:26 +08:00
|
|
|
|
self.logger.debug("Bot entered p2p chat (user opened chat window)")
|
2026-02-08 13:03:32 +08:00
|
|
|
|
pass
|
2026-03-13 14:52:15 +08:00
|
|
|
|
|
2026-03-14 15:40:53 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _format_tool_hint_lines(tool_hint: str) -> str:
|
|
|
|
|
|
"""Split tool hints across lines on top-level call separators only."""
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
buf: list[str] = []
|
|
|
|
|
|
depth = 0
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
quote_char = ""
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
|
|
|
|
|
|
for i, ch in enumerate(tool_hint):
|
|
|
|
|
|
buf.append(ch)
|
|
|
|
|
|
|
|
|
|
|
|
if in_string:
|
|
|
|
|
|
if escaped:
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
elif ch == "\\":
|
|
|
|
|
|
escaped = True
|
|
|
|
|
|
elif ch == quote_char:
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if ch in {'"', "'"}:
|
|
|
|
|
|
in_string = True
|
|
|
|
|
|
quote_char = ch
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if ch == "(":
|
|
|
|
|
|
depth += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if ch == ")" and depth > 0:
|
|
|
|
|
|
depth -= 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if ch == "," and depth == 0:
|
|
|
|
|
|
next_char = tool_hint[i + 1] if i + 1 < len(tool_hint) else ""
|
|
|
|
|
|
if next_char == " ":
|
|
|
|
|
|
parts.append("".join(buf).rstrip())
|
|
|
|
|
|
buf = []
|
|
|
|
|
|
|
|
|
|
|
|
if buf:
|
|
|
|
|
|
parts.append("".join(buf).strip())
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(part for part in parts if part)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
def _format_tool_hint_delta(self, tool_hint: str) -> str:
|
|
|
|
|
|
"""Format a tool hint string with the 🔧 prefix for each line."""
|
|
|
|
|
|
lines = self.__class__._format_tool_hint_lines(tool_hint).split("\n")
|
|
|
|
|
|
return "\n".join(
|
|
|
|
|
|
f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
|
2026-03-13 14:52:15 +08:00
|
|
|
|
)
|