diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59e3a69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Test and coverage artifacts +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ + +# Build and packaging artifacts +build/ +dist/ +*.egg-info/ +*.egg +pip-wheel-metadata/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Local environment files +.env +.env.* +!.env.example + +# Python tooling caches +.mypy_cache/ +.ruff_cache/ +.pyre/ +.pytype/ + +# OS and editor noise +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b66320f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +global-exclude */__pycache__/* +global-exclude *.pyc +global-exclude .pytest_cache/* +include LICENSE +include README.md +include README.zh.md +recursive-include docs *.md +recursive-include samples/channel *.py +prune tests diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ba43c1 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Lark Channel SDK for Python + +`lark-channel-sdk` is a Python package for building Feishu and Lark +conversational bots. It provides `FeishuChannel` as the main entry point for +event listening, message normalization, policy control, outbound messaging, +media handling, card callbacks, and streaming replies. + +## Install + +```bash +pip install lark-channel-sdk +``` + +## Minimal Example + +```python +import asyncio +import os + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id=os.environ["LARK_APP_ID"], + app_secret=os.environ["LARK_APP_SECRET"], +) + + +async def on_message(msg): + await channel.send( + msg.chat_id, + {"markdown": f"received: {msg.content_text}"}, + {"reply_to": msg.message_id}, + ) + + +channel.on("message", on_message) +asyncio.run(channel.connect()) +``` + +## Documentation + +- [Quickstart](docs/quickstart.md) +- [API reference](docs/reference.md) +- [Markdown messages](docs/markdown.md) +- [Webhook server adapter](docs/webhook-server.md) +- [CardKit streaming](docs/cardkit-streaming.md) +- [Deduplication architecture](docs/dedup-architecture.md) +- [Echo bot sample](samples/channel/echo_bot.py) + +## Migration from `lark_oapi.channel` + +Install the standalone package and update the import path: + +```bash +pip install lark-channel-sdk +``` + +```python +from lark_channel import FeishuChannel +``` + +`lark-channel-sdk` can be installed alongside `lark-oapi`. Use +`lark-channel-sdk` for Channel bot workflows and keep using `lark-oapi` when +your application needs the full OpenAPI SDK surface. + +## Local Development + +```bash +pip install -e ".[test]" +pytest +``` + +## License + +This project is licensed under the MIT License. See [LICENSE](LICENSE). diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..ad2c699 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,73 @@ +# Lark Channel Python SDK + +`lark-channel-sdk` 是用于构建飞书和 Lark 会话式机器人的 Python 包。它以 +`FeishuChannel` 为主要入口,封装事件监听、消息归一化、策略控制、出站消息、 +媒体处理、卡片回调和流式回复。 + +## 安装 + +```bash +pip install lark-channel-sdk +``` + +## 最小示例 + +```python +import asyncio +import os + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id=os.environ["LARK_APP_ID"], + app_secret=os.environ["LARK_APP_SECRET"], +) + + +async def on_message(msg): + await channel.send( + msg.chat_id, + {"markdown": f"received: {msg.content_text}"}, + {"reply_to": msg.message_id}, + ) + + +channel.on("message", on_message) +asyncio.run(channel.connect()) +``` + +## 文档 + +- [快速开始](docs/quickstart.md) +- [API 参考](docs/reference.md) +- [Markdown 消息](docs/markdown.md) +- [Webhook 服务适配](docs/webhook-server.md) +- [CardKit 流式回复](docs/cardkit-streaming.md) +- [去重架构](docs/dedup-architecture.md) +- [Echo bot 示例](samples/channel/echo_bot.py) + +## 从 `lark_oapi.channel` 迁移 + +安装独立包并更新 import 路径: + +```bash +pip install lark-channel-sdk +``` + +```python +from lark_channel import FeishuChannel +``` + +`lark-channel-sdk` 可以与 `lark-oapi` 安装在同一环境中。Channel bot 工作流使用 +`lark-channel-sdk`;如果应用需要完整 OpenAPI SDK 能力,继续使用 `lark-oapi`。 + +## 本地开发 + +```bash +pip install -e ".[test]" +pytest +``` + +## 许可证 + +本项目使用 MIT License,详见 [LICENSE](LICENSE)。 diff --git a/docs/cardkit-streaming.md b/docs/cardkit-streaming.md new file mode 100644 index 0000000..e8faf9e --- /dev/null +++ b/docs/cardkit-streaming.md @@ -0,0 +1,139 @@ +# Streaming with CardKit + +For normal markdown streaming, prefer the high-level `channel.stream(...)` +helper: + +```python +async def produce(stream): + for chunk in ["hello", " ", "world"]: + await stream.append(chunk) + +await channel.stream(chat_id, {"markdown": produce}, {"reply_to": message_id}) +``` + +`channel.stream(...)` owns CardKit preallocation, throttling, cancellation +handling, and the final `finish_streaming_card(...)` call. Use the lower-level +methods below only when you need custom CardKit control. + +## Low-level APIs + +| Method | Purpose | +|---|---| +| `await channel.create_card_instance(spec)` | Allocate a `card_id` from a card JSON spec | +| `await channel.send_card_by_reference(to, card_id, ...)` | Send a message that points to the preallocated card | +| `await channel.update_card_element_content(card_id, element_id, content, sequence)` | Patch one element's text during streaming | +| `await channel.finish_streaming_card(card_id, sequence)` | Close `streaming_mode` so users see the final card | + +The high-level `channel.stream(..., {"markdown": producer}, ...)` path wraps +these methods through `MarkdownStreamController` and is the recommended public +API for token streaming. + +## Required Permissions + +A bot must have the required message and CardKit scopes enabled before calling +CardKit APIs. Scope names can vary by tenant UI; verify the exact names in the +Feishu developer console. + +| Scope name (zh-CN) | Scope ID | Used by | +|---|---|---| +| 发送消息 | `im:message:send_as_bot` | `send_card_by_reference` | +| 获取与发送单聊、群组消息 | `im:message` | Inbound and outbound message operations | +| 创建卡片实体 | `cardkit:card:write` | `create_card_instance` | +| 更新卡片实体 | `cardkit:card` | `update_card_element_content`, `finish_streaming_card` | + +If your tenant still exposes the legacy `cardkit:card:read` / +`cardkit:card:update` split, enable both. After changing scopes, re-install the +bot into the tenant; existing tokens do not pick up new scopes. + +## Sequence Semantics + +`update_card_element_content(card_id, element_id, content, sequence)` carries a +strictly increasing `sequence` number per `card_id`. + +- The first patch must have `sequence >= 1`. +- Each subsequent patch must have `sequence >` the previous one. +- Gaps are allowed, for example `1, 3, 5`. +- `finish_streaming_card(card_id, sequence)` follows the same rule: its + `sequence` must exceed the largest sequence used in any update call for that + card. + +Recommended pattern: + +```python +seq = 0 + +async def patch(text): + nonlocal seq + seq += 1 + await channel.update_card_element_content(card_id, "main", text, sequence=seq) + +# ... stream tokens, calling patch() ... + +seq += 1 +await channel.finish_streaming_card(card_id, sequence=seq) +``` + +## `finish_streaming_card` vs `update_card` + +These methods are not interchangeable. + +| Method | When to use | What it does | +|---|---|---| +| `finish_streaming_card(card_id, sequence)` | Streaming output complete | Sets `config.streaming_mode = false` on the preallocated card. | +| `update_card(message_id, card)` | One-shot card replacement | Replaces the whole card payload of a sent message. Uses `message_id`, not `card_id`, and has no `sequence`. | + +If you need to update a card after `finish_streaming_card`, use +`update_card(message_id, card)` with the `message_id` returned from +`send_card_by_reference(...)`, not the `card_id`. + +## API Error Hints + +Low-level CardKit methods raise `FeishuChannelError(code=unknown, ...)` for +non-zero CardKit responses in this release. Inspect the raw API response inside +the exception message and use the upstream API code as a troubleshooting hint. + +| API code | Likely cause | Fix | +|---|---|---| +| `99991672` / `99991679` | Missing create-card scope | Add scope and re-install bot | +| `99991680` / `99991681` | Missing update-card scope | Add scope and re-install bot | +| `230099` | `sequence` regressed or was reused | Reset the stream and allocate a new `card_id` | +| `230001` | Card JSON spec malformed | Validate against CardKit 2.0 schema | +| `230002` / `230020` | Card already finished, or message recalled | Allocate a new `card_id` | + +## Core Flow Example + +This snippet focuses on the CardKit calls. It assumes `channel` is already +connected and `chat_id` is known. + +```python +card_id = await channel.create_card_instance({ + "schema": "2.0", + "config": {"streaming_mode": True, "summary": {"content": ""}}, + "body": { + "elements": [ + {"tag": "markdown", "element_id": "main", "content": "..."}, + ], + }, +}) + +send_result = await channel.send_card_by_reference(chat_id, card_id) +if not send_result.success: + raise RuntimeError(send_result.error) + +seq = 0 +accumulated = "" +for token in ["hello", " ", "world"]: + accumulated += token + seq += 1 + await channel.update_card_element_content( + card_id, + "main", + accumulated, + sequence=seq, + ) + +seq += 1 +await channel.finish_streaming_card(card_id, sequence=seq) +``` + +Return to the [project README](../README.md). diff --git a/docs/dedup-architecture.md b/docs/dedup-architecture.md new file mode 100644 index 0000000..7d3285a --- /dev/null +++ b/docs/dedup-architecture.md @@ -0,0 +1,202 @@ +# Channel SDK: Two-layer Dedup Architecture + +This document is an advanced architecture note for applications that need to +customize Channel dedup state. Most bots can use the defaults. + +Channel has two dedup layers: + +1. **Pipeline layer**: `InboundPipeline` uses a `DedupStore` before full message + normalization. It catches webhook retries and WebSocket reconnect backfill. +2. **Safety layer**: `SafetyPipeline` uses `SeenCache` before dispatching to + user handlers. It catches duplicate handler dispatches and can optionally + consult a shared `ICache`. + +The two layers use different protocols because they run at different points in +the pipeline. + +## Pipeline Layer: `DedupStore` + +`DedupStore` is defined in `lark_channel.channel.normalize.dedup` and re-exported +from `lark_channel.channel`. + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class DedupStore(Protocol): + def seen(self, key: str) -> bool: ... + def mark(self, key: str, ttl_seconds: int) -> None: ... +``` + +Contract: + +- `seen(key)` returns `True` if the key is still considered seen. +- `mark(key, ttl_seconds)` records the key with the TTL supplied by the SDK. +- Implementations should be thread-safe. +- TTL behavior is part of the protocol. Capacity limits and LRU eviction are + implementation choices, not SDK-enforced protocol methods. + +Key helpers: + +```python +from lark_channel import make_event_key, make_message_key + +make_event_key("cli_xxx", "evt_xxx") # "evt:cli_xxx:evt_xxx" +make_message_key("cli_xxx", "om_xxx") # "msg:cli_xxx:om_xxx" +``` + +Inject a custom store with `dedup_store=...`: + +```python +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + dedup_store=my_dedup_store, +) +``` + +## Safety Layer: `ICache` + +`SeenCache` can use an optional `lark_channel.core.cache.ICache` implementation. +The current `ICache` interface is synchronous: + +```python +class ICache: + def get(self, key: str) -> str: ... + def set(self, key: str, value: str, expire: int): ... +``` + +`expire` is a Unix timestamp in seconds. + +The current `ICache` does not expose an atomic `SETNX` primitive. With multiple +workers, shared-cache dedup is best-effort rather than a strict cross-process +coherence boundary. Safe patterns: + +- Route events for one app to a single worker. +- Make handlers idempotent on event/message ids. + +Inject a shared cache with `safety_cache=...`: + +```python +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + safety_cache=my_cache, +) +``` + +## Configuration Notes + +`DedupConfig.ttl_seconds`, `max_entries`, and `sweep_seconds` are used by the +default in-memory stores. When you pass a custom `dedup_store`, the SDK only +passes `ttl_seconds` into `mark(key, ttl_seconds)`; your store owns any capacity +and eviction behavior. + +In this release, `DedupConfig.enabled` controls the pipeline-layer `Deduper`. +The safety layer still runs `SeenCache` dedup. + +```python +from lark_channel import DedupConfig, FeishuChannel, SafetyConfig + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + safety=SafetyConfig( + dedup=DedupConfig( + ttl_seconds=12 * 3600, + max_entries=5000, + sweep_seconds=5 * 60, + ), + ), +) +``` + +## Example: JSON File Store + +This example persists pipeline-layer dedup state across process restarts. It is +not shipped as an SDK class. + +```python +import json +import threading +import time +from collections import OrderedDict +from pathlib import Path + +class JsonFileDedupStore: + def __init__(self, path: Path, *, max_entries: int = 5000) -> None: + self._path = Path(path) + self._max = max_entries + self._lock = threading.Lock() + self._data = OrderedDict() + self._load() + + def _load(self) -> None: + if not self._path.exists(): + return + try: + raw = json.loads(self._path.read_text()) + now = time.time() + self._data = OrderedDict( + (k, exp) + for k, exp in raw.items() + if isinstance(exp, (int, float)) and exp > now + ) + except (json.JSONDecodeError, OSError): + self._data = OrderedDict() + + def _persist_locked(self) -> None: + tmp = self._path.with_suffix(self._path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(dict(self._data))) + tmp.replace(self._path) + + def seen(self, key: str) -> bool: + with self._lock: + exp = self._data.get(key) + if exp is None: + return False + if exp <= time.time(): + self._data.pop(key, None) + return False + self._data.move_to_end(key) + return True + + def mark(self, key: str, ttl_seconds: int) -> None: + with self._lock: + self._data[key] = time.time() + ttl_seconds + self._data.move_to_end(key) + while len(self._data) > self._max: + self._data.popitem(last=False) + self._persist_locked() +``` + +## Example: Redis `ICache` + +This example adapts Redis to the safety-layer `ICache` shape. It is +best-effort because the SDK calls `get()` and `set()` separately. + +```python +import time + +import redis + +class RedisICache: + def __init__(self, client: redis.Redis, *, prefix: str = "feishu:seen:") -> None: + self._client = client + self._prefix = prefix + + def get(self, key: str): + value = self._client.get(self._prefix + key) + return value.decode() if value else None + + def set(self, key: str, value: str, expire: int): + ttl = max(1, int(expire - time.time())) + self._client.set(self._prefix + key, value, ex=ttl) +``` + +Return to the [project README](../README.md). diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..2b034e6 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,116 @@ +# Markdown to Post Conversion + +Channel sends `{"markdown": ...}` and bare string messages as Feishu post +messages. The SDK converts markdown into a post AST before calling the message +API. + +If you want a plain text message, send `{"text": "..."}` explicitly. + +## Configuration + +```python +from lark_channel import FeishuChannel, MarkdownConverter, OutboundConfig + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + outbound=OutboundConfig( + markdown_converter=MarkdownConverter(tag_md_mode="native"), + ), +) +``` + +## Modes + +| Mode | When to use | Rendering behavior | +|---|---|---| +| `structured` (default) | Deterministic rendering across clients, code blocks, links, and SDK-side wire-format assertions | Parses markdown into explicit post nodes such as `tag:text`, `tag:a`, and `tag:code_block`. Feishu post has no native heading, blockquote, or nested-list nodes, so those constructs are flattened or approximated. | +| `native` | Richer user-facing markdown rendering in Feishu clients | Wraps markdown into `tag:md` nodes and lets the Feishu client render it. Headings, quotes, and lists render closer to native markdown, but exact output depends on client version. | + +`MarkdownConverter.enabled` exists for compatibility with the config schema. Do +not rely on `enabled=False` to send plain text; use `{"text": ...}` instead. + +## Choosing a Mode + +- Use `structured` when you need predictable cross-client output or testable + post ASTs. +- Use `native` when user-facing markdown structure matters more than exact + cross-client parity. +- Use `{"text": ...}` for plain text. + +## Native Mode Notes + +- Rendering is delegated to the Feishu client markdown parser. +- `OutboundPost(post=prebuilt_ast)` is passed through and is not affected by + `tag_md_mode`. +- Structured mentions are inserted as post `tag:at` nodes in the first row. + They are not written as literal `` text inside a `tag:md` string. + +## Wire Format Comparison + +Input: + +```` +# Hello + +> world + +```python +print("hi") +``` +```` + +`tag_md_mode="structured"`: + +```json +{"zh_cn": {"title": "", "content": [ + [{"tag": "text", "text": "Hello", "style": ["bold"]}], + [{"tag": "text", "text": "│ "}, {"tag": "text", "text": "world"}], + [{"tag": "code_block", "language": "PYTHON", "text": "print(\"hi\")"}] +]}} +``` + +`tag_md_mode="native"`: + +```json +{"zh_cn": {"title": "", "content": [ + [{"tag": "md", "text": "# Hello\n\n> world\n"}], + [{"tag": "md", "text": "```python\nprint(\"hi\")\n```"}] +]}} +``` + +## Editing Messages + +`FeishuChannel.edit_message(message_id, message)` accepts the same high-level +outbound shapes as `send()` for editable text/post messages: + +```python +await channel.edit_message(message_id, "# Markdown heading") +await channel.edit_message(message_id, {"markdown": "**bold**"}) +await channel.edit_message(message_id, {"text": "plain text"}) +await channel.edit_message(message_id, {"post": prebuilt_post_ast}) +``` + +Cards are updated with `update_card(message_id, card)`, not `edit_message()`. +Media, share, and sticker messages are not editable through `edit_message()`. + +## Image and Video Captions + +Images and videos can include an optional markdown caption: + +```python +await channel.send(chat_id, {"image": {"source": image_url}, "caption": "Generated screenshot"}) +await channel.send(chat_id, {"video": {"source": video_bytes}, "caption": "Demo clip"}) +``` + +When no caption is provided, image/video messages use the normal `image` or +`media` message type. With a caption, the SDK sends a single post message that +contains the rendered caption followed by an image or video node. Caption +markdown follows `OutboundConfig.markdown_converter`. + +In this release, captions are supported for image and video messages only. +`caption` on file or audio dictionary inputs is rejected with `format_error` +before upload. Send the caption as a separate message if two-message semantics +are acceptable. + +Return to the [project README](../README.md). diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..147303a --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,153 @@ +# Channel Quickstart + +This guide gets a minimal Channel echo bot running. For the full API surface, +see the [Channel reference](./reference.md). + +## Install + +```bash +pip install lark-channel-sdk +``` + +## Prepare the Bot + +In the Feishu developer console: + +- Create a bot application. +- Enable event subscriptions. +- Enable the WebSocket event subscription channel for local development. +- Subscribe to message receive events. +- Grant bot message send/receive scopes such as `im:message` and + `im:message:send_as_bot`. +- Re-install the app into the tenant after changing scopes. + +Export credentials before running: + +```bash +export LARK_APP_ID=cli_xxx +export LARK_APP_SECRET=your_app_secret +``` + +## Run the Echo Bot + +```bash +python samples/channel/echo_bot.py +``` + +The sample is intentionally small: + +```python +import asyncio +import os + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id=os.environ["LARK_APP_ID"], + app_secret=os.environ["LARK_APP_SECRET"], +) + +async def on_message(msg): + await channel.send( + msg.chat_id, + {"text": f"echo: {msg.content_text}"}, + ) + +channel.on("message", on_message) + +asyncio.run(channel.connect()) +``` + +`connect()` starts the WebSocket transport and keeps the process running. Use +`await channel.disconnect()` during graceful shutdown if your application owns +the event loop. + +Register an error handler when you want centralized observability: + +```python +async def on_error(err): + print("channel error:", err) + +channel.on("error", on_error) +``` + +## Send a Reply + +```python +await channel.send( + msg.chat_id, + {"markdown": f"received: {msg.content_text}"}, + {"reply_to": msg.message_id}, +) +``` + +`channel.send(to, message, opts=None)` accepts dict inputs, typed `Outbound*` +dataclasses, or a bare markdown string. + +## Stream a Reply + +```python +async def produce(stream): + for token in ["hello", " ", "world"]: + await stream.append(token) + +await channel.stream( + msg.chat_id, + {"markdown": produce}, + {"reply_to": msg.message_id}, +) +``` + +For lower-level CardKit controls, see [Streaming with CardKit](./cardkit-streaming.md). + +## Webhook Transport + +For HTTP callbacks, construct the channel with `transport="webhook"` and pass +each HTTP request to `handle_webhook_request(headers, body)`. + +```bash +pip install "lark-channel-sdk[aiohttp]" +``` + +```python +from aiohttp import web +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + encrypt_key="...", + verification_token="...", + transport="webhook", +) + +async def on_message(msg): + await channel.send(msg.chat_id, {"text": f"echo: {msg.content_text}"}) + +channel.on("message", on_message) + +async def webhook(request): + status, body = await channel.handle_webhook_request( + headers=dict(request.headers), + body=await request.read(), + ) + return web.Response(status=status, body=body, content_type="application/json") + +async def init(): + app = web.Application() + app.router.add_post("/feishu/webhook", webhook) + await channel.connect_until_ready() + return app + +web.run_app(init()) +``` + +The SDK does not ship a built-in HTTP server. Keep TLS termination, rate +limiting, IP allowlisting, and anomaly tracking in your web framework or +gateway layer. See [Webhook server adapter](./webhook-server.md). + +## Next Steps + +- [Channel reference](./reference.md) +- [Markdown to post conversion](./markdown.md) +- [Two-layer dedup architecture](./dedup-architecture.md) diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..d6e84cc --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,343 @@ +# Channel Reference + +This is the detailed reference for `FeishuChannel`. For first-run setup, see +the [Channel quickstart](./quickstart.md). + +## Entry Point + +```python +from lark_channel import FeishuChannel +``` + +`FeishuChannel` combines WebSocket or webhook event transport, inbound message +normalization, safety policy, deduplication, outbound sending, media +upload/download, streaming replies, and card helpers. + +Use the lower-level WebSocket client (`lark_channel.ws.client.Client`), the +`EventDispatcherHandler`, or the OpenAPI `Client` directly when your integration +only needs raw event dispatch or direct OpenAPI calls. + +## Minimal Example + +```python +import asyncio +import os + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id=os.environ["LARK_APP_ID"], + app_secret=os.environ["LARK_APP_SECRET"], +) + +async def on_message(msg): + await channel.send( + msg.chat_id, + {"markdown": f"received: {msg.content_text}"}, + {"reply_to": msg.message_id}, + ) + +channel.on("message", on_message) + +asyncio.run(channel.connect()) +``` + +## Constructor Options + +| Option | Required | Description | +|---|---:|---| +| `app_id` / `app_secret` | yes | Feishu app credentials | +| `domain` | no | Feishu, Lark, or custom OpenAPI domain | +| `log_level` | no | SDK log level | +| `transport` | no | `"ws"` by default, or `"webhook"` | +| `encrypt_key` | if configured | Webhook/event decryption key from the developer console | +| `verification_token` | if configured | Webhook/event verification token from the developer console | +| `policy` | no | `PolicyConfig` for DM/group admission and mention behavior | +| `safety` | no | `SafetyConfig` for dedup, stale window, batching, and per-chat queue | +| `inbound` | no | `InboundConfig` for normalization, media, names, and reaction behavior | +| `outbound` | no | `OutboundConfig` for chunking, retry, markdown conversion, SSRF, and streaming throttle | +| `uat` | no | `UATConfig` for user access token device-flow behavior | +| `security` | no | `SecurityConfig` for compat/audit/strict security behavior, audit logging, token cache fallback, and WebSocket limits | +| `token_store` | no | Custom user access token store | +| `dedup_store` | no | Pipeline-layer `DedupStore` | +| `safety_cache` | no | Safety-layer `ICache` | +| `name_lookup` | no | Custom `open_id` to display-name resolver | +| `config` | no | Prebuilt `ChannelConfig`; flat kwargs override touched fields | + +```python +from lark_channel import DedupConfig, FeishuChannel, OutboundConfig, RetryConfig, SafetyConfig + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + safety=SafetyConfig(dedup=DedupConfig(ttl_seconds=12 * 3600)), + outbound=OutboundConfig(retry=RetryConfig(max_attempts=5)), +) +``` + +## Lifecycle + +| Method | Purpose | +|---|---| +| `await channel.connect()` | Start transport. In WebSocket mode, keep running until stopped. | +| `await channel.connect_until_ready(timeout=30)` | Start in the background and return after readiness. | +| `await channel.start_background(timeout=30)` | Alias-style background startup with explicit naming. | +| `await channel.disconnect()` | Drain safety batches and stop the transport. | +| `channel.start()` | Synchronous startup. In webhook mode, build the dispatcher and return. | +| `channel.stop()` | Synchronous teardown. | +| `await channel.wait_ready(timeout=30)` | Wait for readiness after startup. | + +`start()` is synchronous and may block during initial setup, including bot +identity resolution. Prefer `connect_until_ready()` in async web framework +startup hooks. + +## Event Listening + +```python +from lark_channel import Events + +channel.on(Events.MESSAGE, on_message) +channel.on(Events.CARD_ACTION, on_card_action) +channel.on(Events.REACTION, on_reaction) +channel.on(Events.BOT_ADDED, on_bot_added) +channel.on(Events.BOT_LEAVE, on_bot_leave) +channel.on(Events.MESSAGE_READ, on_message_read) +channel.on(Events.COMMENT, on_comment) +channel.on(Events.REJECT, on_reject) +channel.on(Events.RECONNECTING, on_reconnecting) +channel.on(Events.RECONNECTED, on_reconnected) +channel.on(Events.ERROR, on_error) +``` + +Dispatched event names: + +| Event | Payload | +|---|---| +| `message` | `InboundMessage` | +| `cardAction` | `CardActionEvent` | +| `reaction` | `ReactionEvent` | +| `botAdded` | `BotAddedEvent` | +| `botLeave` | `BotLeaveEvent` | +| `messageRead` | `MessageReadEvent` | +| `comment` | `CommentEvent` | +| `reject` | `RejectEvent` | +| `reconnecting` | no argument | +| `reconnected` | no argument | +| `error` | exception or `OutboundSendError` | + +Snake-case aliases such as `card_action`, `bot_added`, `bot_leave`, and +`message_read` are normalized for compatibility. + +## Message Model + +`message` handlers receive an `InboundMessage`. + +| Field | Description | +|---|---| +| `message_id` / `id` | Feishu message id | +| `create_time` | Original event timestamp | +| `conversation` | `Conversation(chat_id, chat_type, thread_id)` | +| `chat_id` | Shortcut for `conversation.chat_id` | +| `chat_type` | `p2p`, `group`, `topic`, or `unknown` | +| `sender` | `Identity` for the sender | +| `sender_id` | Shortcut for `sender.open_id` | +| `sender_name` | Optional display name | +| `mentions` | List of `Mention` objects | +| `mentioned_all` | Whether the message mentioned all members | +| `mentioned_bot` | Whether the message mentioned the bot | +| `reply_to_message_id` | Parent message id when present | +| `content` | Typed `MessageContent` dataclass | +| `content_text` | Flattened markdown/XML-style text | +| `safe_content_text` | Escaped flattened text for security-sensitive rendering | +| `resources` | Resource descriptors for download | +| `raw_content_type` | Original Feishu message type | +| `raw` | Original event payload | + +## Security Configuration + +`SecurityConfig` defaults to `mode="compat"` to preserve existing behavior. +Use `mode="audit"` to log security-sensitive legacy behavior without blocking +it, and `mode="strict"` to enforce stricter checks. + +Common options: + +| Option | Purpose | +|---|---| +| `audit_recorder` | Receives security audit events | +| `allow_unsigned_encrypted_webhook` | Allows legacy encrypted webhook bodies without a verified signature in strict mode | +| `allow_insecure_ws` | Allows remote `ws://` WebSocket endpoints in strict mode | +| `allow_local_insecure_ws` | Allows local `ws://` endpoints, enabled by default for local tests | +| `strict_error_response` | Uses a generic error response when strict handling is enabled | +| `strict_content_text` | Makes `content_text` use the escaped text form | +| `legacy_token_cache_fallback` | Controls fallback to the legacy token cache key | +| `max_ws_fragment_parts` / `max_ws_fragment_bytes` | Limits fragmented WebSocket payload assembly | +| `max_concurrent_ws_handlers` | Limits concurrent WebSocket handler tasks | +| `resource_overflow_policy` | Controls whether oversize normalized resources are audited or dropped | + +## Policy + +Defaults: + +- `dm_policy="open"` +- `group_policy="open"` +- `require_mention=True` +- `respond_to_mention_all=False` +- `sender_identity_fields=["open_id"]` + +Runtime update: + +```python +channel.update_policy( + require_mention=False, + respond_to_mention_all=True, + group_policy="allowlist", + group_allowlist=["oc_xxx"], +) +``` + +`update_policy()` accepts keyword changes for fields on `PolicyConfig`. + +## Sending Messages + +`channel.send(to, message, opts=None)` accepts: + +- a bare string, treated as markdown; +- a dict; +- a typed `Outbound*` dataclass. + +```python +await channel.send(chat_id, {"text": "plain text"}) +await channel.send(chat_id, {"markdown": "hello **world**"}) +await channel.send(chat_id, {"post": {"zh_cn": {"title": "", "content": []}}}) +await channel.send(chat_id, {"card": {"schema": "2.0", "body": {"elements": []}}}) +await channel.send(chat_id, {"image": {"source": "./image.png"}}) +await channel.send(chat_id, {"file": {"source": b"content", "file_name": "a.txt"}}) +await channel.send(chat_id, {"audio": {"source": "./audio.ogg"}}) +await channel.send(chat_id, {"video": {"source": "./video.mp4"}}) +await channel.send(chat_id, {"share_chat": {"chat_id": "oc_xxx"}}) +await channel.send(chat_id, {"share_user": {"user_id": "ou_xxx"}}) +await channel.send(chat_id, {"sticker": {"file_key": "file_v3_xxx"}}) +``` + +`opts` may be a `SendOpts` object or a dict: + +```python +await channel.send( + chat_id, + {"markdown": "please check"}, + { + "reply_to": message_id, + "reply_in_thread": True, + "receive_id_type": "chat_id", + "reply_target_gone": "fresh", + "uuid": "optional-idempotency-key", + }, +) +``` + +For structured mentions, use typed outbound messages: + +```python +from lark_channel import Identity, OutboundText + +await channel.send( + chat_id, + OutboundText( + text="please check", + mentions=[Identity(open_id="ou_xxx", display_name="Alice")], + ), +) +``` + +Media `source` accepts: + +- HTTP(S) URL string, guarded by `OutboundConfig.ssrf_allowlist`; +- local file path string; +- `bytes`; +- existing media key string for the matching message kind: `img_...` for + images, `file_...` for file/audio/video. Stickers use + `{"sticker": {"file_key": ...}}` instead of media `source`. + +Image and video messages support `caption`; file and audio captions are rejected +with `format_error`. + +## Streaming + +Markdown stream: + +```python +async def producer(stream): + for token in ["hello", " ", "world"]: + await stream.append(token) + +await channel.stream(chat_id, {"markdown": producer}, {"reply_to": message_id}) +``` + +Card stream: + +```python +async def producer(stream): + await stream.update(next_card_json) + +await channel.stream( + chat_id, + {"card": {"initial": initial_card_json, "producer": producer}}, +) +``` + +For low-level CardKit preallocation, see [Streaming with CardKit](./cardkit-streaming.md). + +## Helpers + +| Method | Return | Notes | +|---|---|---| +| `await channel.update_card(message_id, card)` | `SendResult` | Replace a sent card message | +| `await channel.edit_message(message_id, message)` | `SendResult` | Text/post only | +| `await channel.recall_message(message_id)` | `SendResult` | Recall/delete a message | +| `await channel.add_reaction(message_id, emoji_type)` | `SendResult` | Add a reaction | +| `await channel.remove_reaction(message_id, reaction_id)` | `SendResult` | Remove a reaction by id | +| `await channel.download_resource(file_key, resource_type="image")` | `bytes \| None` | Returns `None` on API failure | +| `await channel.download_resource_to_file(...)` | `Path` | Raises `download_failed` when no body is returned | +| `await channel.get_chat_info(chat_id)` | `ChatInfo \| None` | Returns `None` on API failure | +| `await channel.get_chat_mode(chat_id)` | `str \| None` | Read `chat_mode` with cache and configured fallback | +| `await channel.resolve_quoted_contexts(messages, chat_mode="group")` | `dict[str, QuoteResolution]` | Resolve quoted parent context for a batch without refetching in-batch parents | +| `await channel.resolve_resource_to_cache(message_id="om_x", resource=resource)` | `CachedResource` | Download a message resource into the SDK-managed cache | +| `channel.block_batch_scope(scope)` / `unblock_batch_scope(scope)` / `cancel_batch_scope(scope)` | `None` | Pause, resume, or cancel debounced batches around an active run | +| `await channel.add_typing_reaction(message_id)` / `remove_typing_reaction(message_id, reaction_id)` | `str \| None` / `bool` | Best-effort IM message `Typing` reaction helpers | +| `await channel.resolve_comment_target(file_token="doc_x", file_type="docx")` / `get_comment_context(target=target, comment_id="c1")` / `reply_comment(context, "answer")` | `CommentTarget` / `CommentContext` / API result | Cloud document comment primitives for supported `doc`, `docx`, `sheet`, and `file` targets. `reply_comment` creates a whole-file comment when `context.is_whole` is true, or updates an existing reply when `context.target_reply_id` is present. | +| `channel.client` | `Client` | Underlying OpenAPI client | + +## Error Handling + +`send()` returns `SendResult`. + +- Invalid input and transport/coercion failures may raise. +- Upstream send failures usually return `SendResult(success=False, error=...)`. +- Both raised errors and failed `SendResult.error` are forwarded to + `channel.on("error", handler)`. + +`stream()` and low-level CardKit helpers raise for controller or CardKit +failures. + +Known `FeishuChannelErrorCode` values: + +| Code | Meaning | +|---|---| +| `format_error` | Message/card schema rejected | +| `target_revoked` | Reply target no longer accepts replies | +| `rate_limited` | Upstream rate limit | +| `permission_denied` | Invalid credentials or missing scopes | +| `upload_failed` | Media upload failed | +| `download_failed` | Media download failed | +| `ssrf_blocked` | URL media download blocked by SSRF policy | +| `send_timeout` | Send/connect timeout | +| `not_connected` | Transport is not connected or startup failed | +| `unknown` | Uncategorized upstream or SDK error | + +## Related Documents + +- [Channel quickstart](./quickstart.md) +- [Webhook server adapter](./webhook-server.md) +- [Streaming with CardKit](./cardkit-streaming.md) +- [Markdown to post conversion](./markdown.md) +- [Two-layer dedup architecture](./dedup-architecture.md) diff --git a/docs/release-notes/v1.0.0.md b/docs/release-notes/v1.0.0.md new file mode 100644 index 0000000..b80096d --- /dev/null +++ b/docs/release-notes/v1.0.0.md @@ -0,0 +1,37 @@ +# lark-channel-sdk v1.0.0 + +This is the first standalone release of the Python Channel SDK. + +## Highlights + +- Standalone package: `lark-channel-sdk` +- New import path: `lark_channel` +- High-level `FeishuChannel` entry point +- WebSocket and webhook event handling +- Message normalization, send/reply/update/recall/forward +- Card actions and streaming card updates +- Media upload/download and media cache primitives +- Message reactions and typing reaction helpers +- Cloud document comment context and reply/update helpers for supported `doc`, `docx`, `sheet`, and `file` targets + +## Compatibility + +- Existing `lark_oapi.channel` users are not forced to migrate immediately. +- The full OpenAPI SDK remains available from `lark-oapi`. +- `lark-channel-sdk` is designed to install alongside `lark-oapi`. + +## Migration + +```bash +pip install lark-channel-sdk +``` + +```python +from lark_channel import FeishuChannel +``` + +## Notes + +- Media cache, typing reactions, and cloud document comment helpers require the corresponding Lark app permissions. +- Cloud document comment helpers cover supported `doc`, `docx`, `sheet`, and `file` comment targets. +- The full Drive/OpenAPI surface remains in `lark-oapi`. diff --git a/docs/webhook-server.md b/docs/webhook-server.md new file mode 100644 index 0000000..de43a76 --- /dev/null +++ b/docs/webhook-server.md @@ -0,0 +1,165 @@ +# Webhook Server Adapter + +The Channel SDK does not ship a built-in HTTP server. TLS termination, rate +limiting, IP allowlisting, anomaly tracking, and framework choice belong in +your application or gateway layer. + +Channel exposes one async request entry point: + +```python +status, body_bytes = await channel.handle_webhook_request(headers, body) +``` + +`handle_webhook_request(...)` decrypts the body when `encrypt_key` is +configured, validates `verification_token` when configured, verifies request +signatures for non-challenge events when `encrypt_key` is configured, and routes +the event to your registered `channel.on(...)` handlers. Signature headers may +be present even when event encryption is disabled; without `encrypt_key`, the +dispatcher treats the request as plaintext and does not verify those headers. + +You must initialize the channel before the first request. In async frameworks, +prefer `await channel.connect_until_ready()` during application startup. The +synchronous `channel.start()` method is safe in synchronous setup code, but it +may block while initial setup runs. + +## aiohttp Adapter + +```bash +pip install "lark-channel-sdk[aiohttp]" +``` + +```python +from aiohttp import web + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + encrypt_key="...", + verification_token="...", + transport="webhook", +) + +async def on_message(msg): + await channel.send(msg.chat_id, {"text": f"echo: {msg.content_text}"}) + +channel.on("message", on_message) + +async def webhook(request: web.Request) -> web.Response: + status, body_bytes = await channel.handle_webhook_request( + headers=dict(request.headers), + body=await request.read(), + ) + return web.Response(status=status, body=body_bytes, content_type="application/json") + +async def init() -> web.Application: + app = web.Application() + app.router.add_post("/feishu/webhook", webhook) + await channel.connect_until_ready() + return app + +if __name__ == "__main__": + web.run_app(init(), host="127.0.0.1", port=8765) +``` + +## FastAPI Adapter + +```bash +pip install "lark-channel-sdk[fastapi]" +``` + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, Response + +from lark_channel import FeishuChannel + +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + encrypt_key="...", + verification_token="...", + transport="webhook", +) + +async def on_message(msg): + await channel.send(msg.chat_id, {"text": f"echo: {msg.content_text}"}) + +channel.on("message", on_message) + +@asynccontextmanager +async def lifespan(app: FastAPI): + await channel.connect_until_ready() + try: + yield + finally: + await channel.disconnect() + +app = FastAPI(lifespan=lifespan) + +@app.post("/feishu/webhook") +async def webhook(request: Request): + status, body_bytes = await channel.handle_webhook_request( + headers=dict(request.headers), + body=await request.body(), + ) + return Response(status_code=status, content=body_bytes, media_type="application/json") +``` + +## Synchronous Setup + +If your framework has synchronous startup code and you are in webhook mode, +`channel.start()` builds the dispatcher and returns after initial setup: + +```python +channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + transport="webhook", +) +channel.start() +``` + +Do not call `handle_webhook_request(...)` before startup, or it raises +`FeishuChannelError(code=not_connected)`. + +## Rate Limiting and Anomaly Tracking + +These belong in your web layer's middleware. For aiohttp: + +```python +from collections import defaultdict +from time import time + +from aiohttp import web + +WINDOW_S = 60 +MAX_REQ = 120 +_buckets = defaultdict(list) + +@web.middleware +async def rate_limit(request, handler): + ip = request.remote or "" + now = time() + bucket = _buckets[ip] + bucket[:] = [t for t in bucket if t > now - WINDOW_S] + if len(bucket) >= MAX_REQ: + return web.Response(status=429, text="rate limited") + bucket.append(now) + return await handler(request) + +app = web.Application(middlewares=[rate_limit]) +``` + +For anomaly tracking, wrap the handler and track non-200 responses per IP or +tenant key. The SDK only sees validated request bytes and event payloads. + +## Why No Built-in Server? + +- Avoid forcing aiohttp, FastAPI, or another framework into every SDK user. +- Production deployments usually already have ingress, WAF, and monitoring. +- A framework adapter is small and keeps ownership of HTTP concerns clear. + +Return to the [project README](../README.md). diff --git a/lark_channel/__init__.py b/lark_channel/__init__.py new file mode 100644 index 0000000..355bc85 --- /dev/null +++ b/lark_channel/__init__.py @@ -0,0 +1,40 @@ +from .client import Client +from .core import * +from .event.context import EventContext +from .event.custom import CustomizedEvent +from .event.dispatcher_handler import EventDispatcherHandler +from .channel import * +from .channel import ( + CachedResource, + ChatModeCacheConfig, + CommentContext, + CommentTarget, + FeishuChannel, + KeepaliveConfig, + MediaCacheConfig, + QuotedContext, + QuoteResolution, +) + +__all__ = [ + "CachedResource", + "ChatModeCacheConfig", + "Client", + "CommentContext", + "CommentTarget", + "EventContext", + "CustomizedEvent", + "EventDispatcherHandler", + "FeishuChannel", + "KeepaliveConfig", + "MediaCacheConfig", + "QuotedContext", + "QuoteResolution", +] + +try: + from .channel import __all__ as _channel_all + + __all__.extend(name for name in _channel_all if name not in __all__) +except Exception: + pass diff --git a/lark_channel/api/__init__.py b/lark_channel/api/__init__.py new file mode 100644 index 0000000..0cb8bd8 --- /dev/null +++ b/lark_channel/api/__init__.py @@ -0,0 +1,3 @@ +"""Minimal OpenAPI closure used by lark_channel.""" + +__all__ = ["im", "contact", "cardkit", "drive", "wiki"] diff --git a/lark_channel/api/cardkit/__init__.py b/lark_channel/api/cardkit/__init__.py new file mode 100644 index 0000000..326cdf4 --- /dev/null +++ b/lark_channel/api/cardkit/__init__.py @@ -0,0 +1 @@ +from . import v1 diff --git a/lark_channel/api/cardkit/service.py b/lark_channel/api/cardkit/service.py new file mode 100644 index 0000000..efb7755 --- /dev/null +++ b/lark_channel/api/cardkit/service.py @@ -0,0 +1,7 @@ +from lark_channel.core.model import Config +from .v1.version import V1 + + +class CardkitService(object): + def __init__(self, config: Config) -> None: + self.v1: V1 = V1(config) diff --git a/lark_channel/api/cardkit/v1/__init__.py b/lark_channel/api/cardkit/v1/__init__.py new file mode 100644 index 0000000..85573ca --- /dev/null +++ b/lark_channel/api/cardkit/v1/__init__.py @@ -0,0 +1,5 @@ +"""Minimal CardKit v1 resources required by lark_channel.""" + +from .version import V1 + +__all__ = ["V1"] diff --git a/lark_channel/api/cardkit/v1/model/__init__.py b/lark_channel/api/cardkit/v1/model/__init__.py new file mode 100644 index 0000000..7a00a4f --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/__init__.py @@ -0,0 +1 @@ +"""CardKit model submodules are imported explicitly by lark_channel.""" diff --git a/lark_channel/api/cardkit/v1/model/batch_update_card_request.py b/lark_channel/api/cardkit/v1/model/batch_update_card_request.py new file mode 100644 index 0000000..4d543eb --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/batch_update_card_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .batch_update_card_request_body import BatchUpdateCardRequestBody + + +class BatchUpdateCardRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.request_body: Optional[BatchUpdateCardRequestBody] = None + + @staticmethod + def builder() -> "BatchUpdateCardRequestBuilder": + return BatchUpdateCardRequestBuilder() + + +class BatchUpdateCardRequestBuilder(object): + + def __init__(self) -> None: + batch_update_card_request = BatchUpdateCardRequest() + batch_update_card_request.http_method = HttpMethod.POST + batch_update_card_request.uri = "/open-apis/cardkit/v1/cards/:card_id/batch_update" + batch_update_card_request.token_types = {AccessTokenType.TENANT} + self._batch_update_card_request: BatchUpdateCardRequest = batch_update_card_request + + def card_id(self, card_id: str) -> "BatchUpdateCardRequestBuilder": + self._batch_update_card_request.card_id = card_id + self._batch_update_card_request.paths["card_id"] = str(card_id) + return self + + def request_body(self, request_body: BatchUpdateCardRequestBody) -> "BatchUpdateCardRequestBuilder": + self._batch_update_card_request.request_body = request_body + self._batch_update_card_request.body = request_body + return self + + def build(self) -> BatchUpdateCardRequest: + return self._batch_update_card_request diff --git a/lark_channel/api/cardkit/v1/model/batch_update_card_request_body.py b/lark_channel/api/cardkit/v1/model/batch_update_card_request_body.py new file mode 100644 index 0000000..6879451 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/batch_update_card_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class BatchUpdateCardRequestBody(object): + _types = { + "uuid": str, + "sequence": int, + "actions": str, + } + + def __init__(self, d=None): + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + self.actions: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "BatchUpdateCardRequestBodyBuilder": + return BatchUpdateCardRequestBodyBuilder() + + +class BatchUpdateCardRequestBodyBuilder(object): + def __init__(self) -> None: + self._batch_update_card_request_body = BatchUpdateCardRequestBody() + + def uuid(self, uuid: str) -> "BatchUpdateCardRequestBodyBuilder": + self._batch_update_card_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "BatchUpdateCardRequestBodyBuilder": + self._batch_update_card_request_body.sequence = sequence + return self + + def actions(self, actions: str) -> "BatchUpdateCardRequestBodyBuilder": + self._batch_update_card_request_body.actions = actions + return self + + def build(self) -> "BatchUpdateCardRequestBody": + return self._batch_update_card_request_body diff --git a/lark_channel/api/cardkit/v1/model/batch_update_card_response.py b/lark_channel/api/cardkit/v1/model/batch_update_card_response.py new file mode 100644 index 0000000..50615a8 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/batch_update_card_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class BatchUpdateCardResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/card.py b/lark_channel/api/cardkit/v1/model/card.py new file mode 100644 index 0000000..1dc9bba --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/card.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class Card(object): + _types = { + "type": str, + "data": str, + } + + def __init__(self, d=None): + self.type: Optional[str] = None + self.data: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CardBuilder": + return CardBuilder() + + +class CardBuilder(object): + def __init__(self) -> None: + self._card = Card() + + def type(self, type: str) -> "CardBuilder": + self._card.type = type + return self + + def data(self, data: str) -> "CardBuilder": + self._card.data = data + return self + + def build(self) -> "Card": + return self._card diff --git a/lark_channel/api/cardkit/v1/model/content_card_element_request.py b/lark_channel/api/cardkit/v1/model/content_card_element_request.py new file mode 100644 index 0000000..ef26695 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/content_card_element_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .content_card_element_request_body import ContentCardElementRequestBody + + +class ContentCardElementRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.element_id: Optional[str] = None + self.request_body: Optional[ContentCardElementRequestBody] = None + + @staticmethod + def builder() -> "ContentCardElementRequestBuilder": + return ContentCardElementRequestBuilder() + + +class ContentCardElementRequestBuilder(object): + + def __init__(self) -> None: + content_card_element_request = ContentCardElementRequest() + content_card_element_request.http_method = HttpMethod.PUT + content_card_element_request.uri = "/open-apis/cardkit/v1/cards/:card_id/elements/:element_id/content" + content_card_element_request.token_types = {AccessTokenType.TENANT} + self._content_card_element_request: ContentCardElementRequest = content_card_element_request + + def card_id(self, card_id: str) -> "ContentCardElementRequestBuilder": + self._content_card_element_request.card_id = card_id + self._content_card_element_request.paths["card_id"] = str(card_id) + return self + + def element_id(self, element_id: str) -> "ContentCardElementRequestBuilder": + self._content_card_element_request.element_id = element_id + self._content_card_element_request.paths["element_id"] = str(element_id) + return self + + def request_body(self, request_body: ContentCardElementRequestBody) -> "ContentCardElementRequestBuilder": + self._content_card_element_request.request_body = request_body + self._content_card_element_request.body = request_body + return self + + def build(self) -> ContentCardElementRequest: + return self._content_card_element_request diff --git a/lark_channel/api/cardkit/v1/model/content_card_element_request_body.py b/lark_channel/api/cardkit/v1/model/content_card_element_request_body.py new file mode 100644 index 0000000..d44cbf3 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/content_card_element_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ContentCardElementRequestBody(object): + _types = { + "uuid": str, + "content": str, + "sequence": int, + } + + def __init__(self, d=None): + self.uuid: Optional[str] = None + self.content: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ContentCardElementRequestBodyBuilder": + return ContentCardElementRequestBodyBuilder() + + +class ContentCardElementRequestBodyBuilder(object): + def __init__(self) -> None: + self._content_card_element_request_body = ContentCardElementRequestBody() + + def uuid(self, uuid: str) -> "ContentCardElementRequestBodyBuilder": + self._content_card_element_request_body.uuid = uuid + return self + + def content(self, content: str) -> "ContentCardElementRequestBodyBuilder": + self._content_card_element_request_body.content = content + return self + + def sequence(self, sequence: int) -> "ContentCardElementRequestBodyBuilder": + self._content_card_element_request_body.sequence = sequence + return self + + def build(self) -> "ContentCardElementRequestBody": + return self._content_card_element_request_body diff --git a/lark_channel/api/cardkit/v1/model/content_card_element_response.py b/lark_channel/api/cardkit/v1/model/content_card_element_response.py new file mode 100644 index 0000000..65436c4 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/content_card_element_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class ContentCardElementResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/create_card_element_request.py b/lark_channel/api/cardkit/v1/model/create_card_element_request.py new file mode 100644 index 0000000..320ff2d --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_element_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_card_element_request_body import CreateCardElementRequestBody + + +class CreateCardElementRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.request_body: Optional[CreateCardElementRequestBody] = None + + @staticmethod + def builder() -> "CreateCardElementRequestBuilder": + return CreateCardElementRequestBuilder() + + +class CreateCardElementRequestBuilder(object): + + def __init__(self) -> None: + create_card_element_request = CreateCardElementRequest() + create_card_element_request.http_method = HttpMethod.POST + create_card_element_request.uri = "/open-apis/cardkit/v1/cards/:card_id/elements" + create_card_element_request.token_types = {AccessTokenType.TENANT} + self._create_card_element_request: CreateCardElementRequest = create_card_element_request + + def card_id(self, card_id: str) -> "CreateCardElementRequestBuilder": + self._create_card_element_request.card_id = card_id + self._create_card_element_request.paths["card_id"] = str(card_id) + return self + + def request_body(self, request_body: CreateCardElementRequestBody) -> "CreateCardElementRequestBuilder": + self._create_card_element_request.request_body = request_body + self._create_card_element_request.body = request_body + return self + + def build(self) -> CreateCardElementRequest: + return self._create_card_element_request diff --git a/lark_channel/api/cardkit/v1/model/create_card_element_request_body.py b/lark_channel/api/cardkit/v1/model/create_card_element_request_body.py new file mode 100644 index 0000000..2f47d1d --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_element_request_body.py @@ -0,0 +1,52 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateCardElementRequestBody(object): + _types = { + "type": str, + "target_element_id": str, + "uuid": str, + "sequence": int, + "elements": str, + } + + def __init__(self, d=None): + self.type: Optional[str] = None + self.target_element_id: Optional[str] = None + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + self.elements: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateCardElementRequestBodyBuilder": + return CreateCardElementRequestBodyBuilder() + + +class CreateCardElementRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_card_element_request_body = CreateCardElementRequestBody() + + def type(self, type: str) -> "CreateCardElementRequestBodyBuilder": + self._create_card_element_request_body.type = type + return self + + def target_element_id(self, target_element_id: str) -> "CreateCardElementRequestBodyBuilder": + self._create_card_element_request_body.target_element_id = target_element_id + return self + + def uuid(self, uuid: str) -> "CreateCardElementRequestBodyBuilder": + self._create_card_element_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "CreateCardElementRequestBodyBuilder": + self._create_card_element_request_body.sequence = sequence + return self + + def elements(self, elements: str) -> "CreateCardElementRequestBodyBuilder": + self._create_card_element_request_body.elements = elements + return self + + def build(self) -> "CreateCardElementRequestBody": + return self._create_card_element_request_body diff --git a/lark_channel/api/cardkit/v1/model/create_card_element_response.py b/lark_channel/api/cardkit/v1/model/create_card_element_response.py new file mode 100644 index 0000000..8ab94f1 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_element_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class CreateCardElementResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/create_card_request.py b/lark_channel/api/cardkit/v1/model/create_card_request.py new file mode 100644 index 0000000..7145771 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_request.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_card_request_body import CreateCardRequestBody + + +class CreateCardRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.request_body: Optional[CreateCardRequestBody] = None + + @staticmethod + def builder() -> "CreateCardRequestBuilder": + return CreateCardRequestBuilder() + + +class CreateCardRequestBuilder(object): + + def __init__(self) -> None: + create_card_request = CreateCardRequest() + create_card_request.http_method = HttpMethod.POST + create_card_request.uri = "/open-apis/cardkit/v1/cards" + create_card_request.token_types = {AccessTokenType.TENANT} + self._create_card_request: CreateCardRequest = create_card_request + + def request_body(self, request_body: CreateCardRequestBody) -> "CreateCardRequestBuilder": + self._create_card_request.request_body = request_body + self._create_card_request.body = request_body + return self + + def build(self) -> CreateCardRequest: + return self._create_card_request diff --git a/lark_channel/api/cardkit/v1/model/create_card_request_body.py b/lark_channel/api/cardkit/v1/model/create_card_request_body.py new file mode 100644 index 0000000..7765358 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_request_body.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateCardRequestBody(object): + _types = { + "type": str, + "data": str, + } + + def __init__(self, d=None): + self.type: Optional[str] = None + self.data: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateCardRequestBodyBuilder": + return CreateCardRequestBodyBuilder() + + +class CreateCardRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_card_request_body = CreateCardRequestBody() + + def type(self, type: str) -> "CreateCardRequestBodyBuilder": + self._create_card_request_body.type = type + return self + + def data(self, data: str) -> "CreateCardRequestBodyBuilder": + self._create_card_request_body.data = data + return self + + def build(self) -> "CreateCardRequestBody": + return self._create_card_request_body diff --git a/lark_channel/api/cardkit/v1/model/create_card_response.py b/lark_channel/api/cardkit/v1/model/create_card_response.py new file mode 100644 index 0000000..d973ee8 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_card_response_body import CreateCardResponseBody + + +class CreateCardResponse(BaseResponse): + _types = { + "data": CreateCardResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateCardResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/create_card_response_body.py b/lark_channel/api/cardkit/v1/model/create_card_response_body.py new file mode 100644 index 0000000..a1edbfb --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/create_card_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateCardResponseBody(object): + _types = { + "card_id": str, + } + + def __init__(self, d=None): + self.card_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateCardResponseBodyBuilder": + return CreateCardResponseBodyBuilder() + + +class CreateCardResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_card_response_body = CreateCardResponseBody() + + def card_id(self, card_id: str) -> "CreateCardResponseBodyBuilder": + self._create_card_response_body.card_id = card_id + return self + + def build(self) -> "CreateCardResponseBody": + return self._create_card_response_body diff --git a/lark_channel/api/cardkit/v1/model/delete_card_element_request.py b/lark_channel/api/cardkit/v1/model/delete_card_element_request.py new file mode 100644 index 0000000..3a852a1 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/delete_card_element_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .delete_card_element_request_body import DeleteCardElementRequestBody + + +class DeleteCardElementRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.element_id: Optional[str] = None + self.request_body: Optional[DeleteCardElementRequestBody] = None + + @staticmethod + def builder() -> "DeleteCardElementRequestBuilder": + return DeleteCardElementRequestBuilder() + + +class DeleteCardElementRequestBuilder(object): + + def __init__(self) -> None: + delete_card_element_request = DeleteCardElementRequest() + delete_card_element_request.http_method = HttpMethod.DELETE + delete_card_element_request.uri = "/open-apis/cardkit/v1/cards/:card_id/elements/:element_id" + delete_card_element_request.token_types = {AccessTokenType.TENANT} + self._delete_card_element_request: DeleteCardElementRequest = delete_card_element_request + + def card_id(self, card_id: str) -> "DeleteCardElementRequestBuilder": + self._delete_card_element_request.card_id = card_id + self._delete_card_element_request.paths["card_id"] = str(card_id) + return self + + def element_id(self, element_id: str) -> "DeleteCardElementRequestBuilder": + self._delete_card_element_request.element_id = element_id + self._delete_card_element_request.paths["element_id"] = str(element_id) + return self + + def request_body(self, request_body: DeleteCardElementRequestBody) -> "DeleteCardElementRequestBuilder": + self._delete_card_element_request.request_body = request_body + self._delete_card_element_request.body = request_body + return self + + def build(self) -> DeleteCardElementRequest: + return self._delete_card_element_request diff --git a/lark_channel/api/cardkit/v1/model/delete_card_element_request_body.py b/lark_channel/api/cardkit/v1/model/delete_card_element_request_body.py new file mode 100644 index 0000000..d361bc5 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/delete_card_element_request_body.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class DeleteCardElementRequestBody(object): + _types = { + "uuid": str, + "sequence": int, + } + + def __init__(self, d=None): + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DeleteCardElementRequestBodyBuilder": + return DeleteCardElementRequestBodyBuilder() + + +class DeleteCardElementRequestBodyBuilder(object): + def __init__(self) -> None: + self._delete_card_element_request_body = DeleteCardElementRequestBody() + + def uuid(self, uuid: str) -> "DeleteCardElementRequestBodyBuilder": + self._delete_card_element_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "DeleteCardElementRequestBodyBuilder": + self._delete_card_element_request_body.sequence = sequence + return self + + def build(self) -> "DeleteCardElementRequestBody": + return self._delete_card_element_request_body diff --git a/lark_channel/api/cardkit/v1/model/delete_card_element_response.py b/lark_channel/api/cardkit/v1/model/delete_card_element_response.py new file mode 100644 index 0000000..664484a --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/delete_card_element_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class DeleteCardElementResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/id_convert_card_request.py b/lark_channel/api/cardkit/v1/model/id_convert_card_request.py new file mode 100644 index 0000000..9f737f7 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/id_convert_card_request.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .id_convert_card_request_body import IdConvertCardRequestBody + + +class IdConvertCardRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.request_body: Optional[IdConvertCardRequestBody] = None + + @staticmethod + def builder() -> "IdConvertCardRequestBuilder": + return IdConvertCardRequestBuilder() + + +class IdConvertCardRequestBuilder(object): + + def __init__(self) -> None: + id_convert_card_request = IdConvertCardRequest() + id_convert_card_request.http_method = HttpMethod.POST + id_convert_card_request.uri = "/open-apis/cardkit/v1/cards/id_convert" + id_convert_card_request.token_types = {AccessTokenType.TENANT} + self._id_convert_card_request: IdConvertCardRequest = id_convert_card_request + + def request_body(self, request_body: IdConvertCardRequestBody) -> "IdConvertCardRequestBuilder": + self._id_convert_card_request.request_body = request_body + self._id_convert_card_request.body = request_body + return self + + def build(self) -> IdConvertCardRequest: + return self._id_convert_card_request diff --git a/lark_channel/api/cardkit/v1/model/id_convert_card_request_body.py b/lark_channel/api/cardkit/v1/model/id_convert_card_request_body.py new file mode 100644 index 0000000..ce6fca3 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/id_convert_card_request_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class IdConvertCardRequestBody(object): + _types = { + "message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "IdConvertCardRequestBodyBuilder": + return IdConvertCardRequestBodyBuilder() + + +class IdConvertCardRequestBodyBuilder(object): + def __init__(self) -> None: + self._id_convert_card_request_body = IdConvertCardRequestBody() + + def message_id(self, message_id: str) -> "IdConvertCardRequestBodyBuilder": + self._id_convert_card_request_body.message_id = message_id + return self + + def build(self) -> "IdConvertCardRequestBody": + return self._id_convert_card_request_body diff --git a/lark_channel/api/cardkit/v1/model/id_convert_card_response.py b/lark_channel/api/cardkit/v1/model/id_convert_card_response.py new file mode 100644 index 0000000..6dc05b2 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/id_convert_card_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .id_convert_card_response_body import IdConvertCardResponseBody + + +class IdConvertCardResponse(BaseResponse): + _types = { + "data": IdConvertCardResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[IdConvertCardResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/id_convert_card_response_body.py b/lark_channel/api/cardkit/v1/model/id_convert_card_response_body.py new file mode 100644 index 0000000..adc56bd --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/id_convert_card_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class IdConvertCardResponseBody(object): + _types = { + "card_id": str, + } + + def __init__(self, d=None): + self.card_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "IdConvertCardResponseBodyBuilder": + return IdConvertCardResponseBodyBuilder() + + +class IdConvertCardResponseBodyBuilder(object): + def __init__(self) -> None: + self._id_convert_card_response_body = IdConvertCardResponseBody() + + def card_id(self, card_id: str) -> "IdConvertCardResponseBodyBuilder": + self._id_convert_card_response_body.card_id = card_id + return self + + def build(self) -> "IdConvertCardResponseBody": + return self._id_convert_card_response_body diff --git a/lark_channel/api/cardkit/v1/model/patch_card_element_request.py b/lark_channel/api/cardkit/v1/model/patch_card_element_request.py new file mode 100644 index 0000000..751cf95 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/patch_card_element_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .patch_card_element_request_body import PatchCardElementRequestBody + + +class PatchCardElementRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.element_id: Optional[str] = None + self.request_body: Optional[PatchCardElementRequestBody] = None + + @staticmethod + def builder() -> "PatchCardElementRequestBuilder": + return PatchCardElementRequestBuilder() + + +class PatchCardElementRequestBuilder(object): + + def __init__(self) -> None: + patch_card_element_request = PatchCardElementRequest() + patch_card_element_request.http_method = HttpMethod.PATCH + patch_card_element_request.uri = "/open-apis/cardkit/v1/cards/:card_id/elements/:element_id" + patch_card_element_request.token_types = {AccessTokenType.TENANT} + self._patch_card_element_request: PatchCardElementRequest = patch_card_element_request + + def card_id(self, card_id: str) -> "PatchCardElementRequestBuilder": + self._patch_card_element_request.card_id = card_id + self._patch_card_element_request.paths["card_id"] = str(card_id) + return self + + def element_id(self, element_id: str) -> "PatchCardElementRequestBuilder": + self._patch_card_element_request.element_id = element_id + self._patch_card_element_request.paths["element_id"] = str(element_id) + return self + + def request_body(self, request_body: PatchCardElementRequestBody) -> "PatchCardElementRequestBuilder": + self._patch_card_element_request.request_body = request_body + self._patch_card_element_request.body = request_body + return self + + def build(self) -> PatchCardElementRequest: + return self._patch_card_element_request diff --git a/lark_channel/api/cardkit/v1/model/patch_card_element_request_body.py b/lark_channel/api/cardkit/v1/model/patch_card_element_request_body.py new file mode 100644 index 0000000..3534ba0 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/patch_card_element_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class PatchCardElementRequestBody(object): + _types = { + "partial_element": str, + "uuid": str, + "sequence": int, + } + + def __init__(self, d=None): + self.partial_element: Optional[str] = None + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "PatchCardElementRequestBodyBuilder": + return PatchCardElementRequestBodyBuilder() + + +class PatchCardElementRequestBodyBuilder(object): + def __init__(self) -> None: + self._patch_card_element_request_body = PatchCardElementRequestBody() + + def partial_element(self, partial_element: str) -> "PatchCardElementRequestBodyBuilder": + self._patch_card_element_request_body.partial_element = partial_element + return self + + def uuid(self, uuid: str) -> "PatchCardElementRequestBodyBuilder": + self._patch_card_element_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "PatchCardElementRequestBodyBuilder": + self._patch_card_element_request_body.sequence = sequence + return self + + def build(self) -> "PatchCardElementRequestBody": + return self._patch_card_element_request_body diff --git a/lark_channel/api/cardkit/v1/model/patch_card_element_response.py b/lark_channel/api/cardkit/v1/model/patch_card_element_response.py new file mode 100644 index 0000000..9807955 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/patch_card_element_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class PatchCardElementResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/settings_card_request.py b/lark_channel/api/cardkit/v1/model/settings_card_request.py new file mode 100644 index 0000000..3bff4b5 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/settings_card_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .settings_card_request_body import SettingsCardRequestBody + + +class SettingsCardRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.request_body: Optional[SettingsCardRequestBody] = None + + @staticmethod + def builder() -> "SettingsCardRequestBuilder": + return SettingsCardRequestBuilder() + + +class SettingsCardRequestBuilder(object): + + def __init__(self) -> None: + settings_card_request = SettingsCardRequest() + settings_card_request.http_method = HttpMethod.PATCH + settings_card_request.uri = "/open-apis/cardkit/v1/cards/:card_id/settings" + settings_card_request.token_types = {AccessTokenType.TENANT} + self._settings_card_request: SettingsCardRequest = settings_card_request + + def card_id(self, card_id: str) -> "SettingsCardRequestBuilder": + self._settings_card_request.card_id = card_id + self._settings_card_request.paths["card_id"] = str(card_id) + return self + + def request_body(self, request_body: SettingsCardRequestBody) -> "SettingsCardRequestBuilder": + self._settings_card_request.request_body = request_body + self._settings_card_request.body = request_body + return self + + def build(self) -> SettingsCardRequest: + return self._settings_card_request diff --git a/lark_channel/api/cardkit/v1/model/settings_card_request_body.py b/lark_channel/api/cardkit/v1/model/settings_card_request_body.py new file mode 100644 index 0000000..e184aed --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/settings_card_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class SettingsCardRequestBody(object): + _types = { + "settings": str, + "uuid": str, + "sequence": int, + } + + def __init__(self, d=None): + self.settings: Optional[str] = None + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "SettingsCardRequestBodyBuilder": + return SettingsCardRequestBodyBuilder() + + +class SettingsCardRequestBodyBuilder(object): + def __init__(self) -> None: + self._settings_card_request_body = SettingsCardRequestBody() + + def settings(self, settings: str) -> "SettingsCardRequestBodyBuilder": + self._settings_card_request_body.settings = settings + return self + + def uuid(self, uuid: str) -> "SettingsCardRequestBodyBuilder": + self._settings_card_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "SettingsCardRequestBodyBuilder": + self._settings_card_request_body.sequence = sequence + return self + + def build(self) -> "SettingsCardRequestBody": + return self._settings_card_request_body diff --git a/lark_channel/api/cardkit/v1/model/settings_card_response.py b/lark_channel/api/cardkit/v1/model/settings_card_response.py new file mode 100644 index 0000000..cc63a68 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/settings_card_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class SettingsCardResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/update_card_element_request.py b/lark_channel/api/cardkit/v1/model/update_card_element_request.py new file mode 100644 index 0000000..82dfa9e --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_element_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .update_card_element_request_body import UpdateCardElementRequestBody + + +class UpdateCardElementRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.element_id: Optional[str] = None + self.request_body: Optional[UpdateCardElementRequestBody] = None + + @staticmethod + def builder() -> "UpdateCardElementRequestBuilder": + return UpdateCardElementRequestBuilder() + + +class UpdateCardElementRequestBuilder(object): + + def __init__(self) -> None: + update_card_element_request = UpdateCardElementRequest() + update_card_element_request.http_method = HttpMethod.PUT + update_card_element_request.uri = "/open-apis/cardkit/v1/cards/:card_id/elements/:element_id" + update_card_element_request.token_types = {AccessTokenType.TENANT} + self._update_card_element_request: UpdateCardElementRequest = update_card_element_request + + def card_id(self, card_id: str) -> "UpdateCardElementRequestBuilder": + self._update_card_element_request.card_id = card_id + self._update_card_element_request.paths["card_id"] = str(card_id) + return self + + def element_id(self, element_id: str) -> "UpdateCardElementRequestBuilder": + self._update_card_element_request.element_id = element_id + self._update_card_element_request.paths["element_id"] = str(element_id) + return self + + def request_body(self, request_body: UpdateCardElementRequestBody) -> "UpdateCardElementRequestBuilder": + self._update_card_element_request.request_body = request_body + self._update_card_element_request.body = request_body + return self + + def build(self) -> UpdateCardElementRequest: + return self._update_card_element_request diff --git a/lark_channel/api/cardkit/v1/model/update_card_element_request_body.py b/lark_channel/api/cardkit/v1/model/update_card_element_request_body.py new file mode 100644 index 0000000..2d4ae30 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_element_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UpdateCardElementRequestBody(object): + _types = { + "uuid": str, + "element": str, + "sequence": int, + } + + def __init__(self, d=None): + self.uuid: Optional[str] = None + self.element: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateCardElementRequestBodyBuilder": + return UpdateCardElementRequestBodyBuilder() + + +class UpdateCardElementRequestBodyBuilder(object): + def __init__(self) -> None: + self._update_card_element_request_body = UpdateCardElementRequestBody() + + def uuid(self, uuid: str) -> "UpdateCardElementRequestBodyBuilder": + self._update_card_element_request_body.uuid = uuid + return self + + def element(self, element: str) -> "UpdateCardElementRequestBodyBuilder": + self._update_card_element_request_body.element = element + return self + + def sequence(self, sequence: int) -> "UpdateCardElementRequestBodyBuilder": + self._update_card_element_request_body.sequence = sequence + return self + + def build(self) -> "UpdateCardElementRequestBody": + return self._update_card_element_request_body diff --git a/lark_channel/api/cardkit/v1/model/update_card_element_response.py b/lark_channel/api/cardkit/v1/model/update_card_element_response.py new file mode 100644 index 0000000..6984da5 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_element_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class UpdateCardElementResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/model/update_card_request.py b/lark_channel/api/cardkit/v1/model/update_card_request.py new file mode 100644 index 0000000..79a7f4a --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .update_card_request_body import UpdateCardRequestBody + + +class UpdateCardRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.card_id: Optional[str] = None + self.request_body: Optional[UpdateCardRequestBody] = None + + @staticmethod + def builder() -> "UpdateCardRequestBuilder": + return UpdateCardRequestBuilder() + + +class UpdateCardRequestBuilder(object): + + def __init__(self) -> None: + update_card_request = UpdateCardRequest() + update_card_request.http_method = HttpMethod.PUT + update_card_request.uri = "/open-apis/cardkit/v1/cards/:card_id" + update_card_request.token_types = {AccessTokenType.TENANT} + self._update_card_request: UpdateCardRequest = update_card_request + + def card_id(self, card_id: str) -> "UpdateCardRequestBuilder": + self._update_card_request.card_id = card_id + self._update_card_request.paths["card_id"] = str(card_id) + return self + + def request_body(self, request_body: UpdateCardRequestBody) -> "UpdateCardRequestBuilder": + self._update_card_request.request_body = request_body + self._update_card_request.body = request_body + return self + + def build(self) -> UpdateCardRequest: + return self._update_card_request diff --git a/lark_channel/api/cardkit/v1/model/update_card_request_body.py b/lark_channel/api/cardkit/v1/model/update_card_request_body.py new file mode 100644 index 0000000..d604b24 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_request_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .card import Card + + +class UpdateCardRequestBody(object): + _types = { + "card": Card, + "uuid": str, + "sequence": int, + } + + def __init__(self, d=None): + self.card: Optional[Card] = None + self.uuid: Optional[str] = None + self.sequence: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateCardRequestBodyBuilder": + return UpdateCardRequestBodyBuilder() + + +class UpdateCardRequestBodyBuilder(object): + def __init__(self) -> None: + self._update_card_request_body = UpdateCardRequestBody() + + def card(self, card: Card) -> "UpdateCardRequestBodyBuilder": + self._update_card_request_body.card = card + return self + + def uuid(self, uuid: str) -> "UpdateCardRequestBodyBuilder": + self._update_card_request_body.uuid = uuid + return self + + def sequence(self, sequence: int) -> "UpdateCardRequestBodyBuilder": + self._update_card_request_body.sequence = sequence + return self + + def build(self) -> "UpdateCardRequestBody": + return self._update_card_request_body diff --git a/lark_channel/api/cardkit/v1/model/update_card_response.py b/lark_channel/api/cardkit/v1/model/update_card_response.py new file mode 100644 index 0000000..75873c2 --- /dev/null +++ b/lark_channel/api/cardkit/v1/model/update_card_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class UpdateCardResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/cardkit/v1/resource/__init__.py b/lark_channel/api/cardkit/v1/resource/__init__.py new file mode 100644 index 0000000..87e5c69 --- /dev/null +++ b/lark_channel/api/cardkit/v1/resource/__init__.py @@ -0,0 +1,4 @@ +from .card import Card +from .card_element import CardElement + +__all__ = ["Card", "CardElement"] diff --git a/lark_channel/api/cardkit/v1/resource/card.py b/lark_channel/api/cardkit/v1/resource/card.py new file mode 100644 index 0000000..28356ce --- /dev/null +++ b/lark_channel/api/cardkit/v1/resource/card.py @@ -0,0 +1,209 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.batch_update_card_request import BatchUpdateCardRequest +from ..model.batch_update_card_response import BatchUpdateCardResponse +from ..model.create_card_request import CreateCardRequest +from ..model.create_card_response import CreateCardResponse +from ..model.id_convert_card_request import IdConvertCardRequest +from ..model.id_convert_card_response import IdConvertCardResponse +from ..model.settings_card_request import SettingsCardRequest +from ..model.settings_card_response import SettingsCardResponse +from ..model.update_card_request import UpdateCardRequest +from ..model.update_card_response import UpdateCardResponse + + +class Card(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def batch_update(self, request: BatchUpdateCardRequest, + option: Optional[RequestOption] = None) -> BatchUpdateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: BatchUpdateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchUpdateCardResponse) + response.raw = resp + + return response + + async def abatch_update(self, request: BatchUpdateCardRequest, + option: Optional[RequestOption] = None) -> BatchUpdateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: BatchUpdateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchUpdateCardResponse) + response.raw = resp + + return response + + def create(self, request: CreateCardRequest, option: Optional[RequestOption] = None) -> CreateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateCardResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateCardRequest, option: Optional[RequestOption] = None) -> CreateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateCardResponse) + response.raw = resp + + return response + + def id_convert(self, request: IdConvertCardRequest, + option: Optional[RequestOption] = None) -> IdConvertCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: IdConvertCardResponse = JSON.unmarshal(str(resp.content, UTF_8), IdConvertCardResponse) + response.raw = resp + + return response + + async def aid_convert(self, request: IdConvertCardRequest, + option: Optional[RequestOption] = None) -> IdConvertCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: IdConvertCardResponse = JSON.unmarshal(str(resp.content, UTF_8), IdConvertCardResponse) + response.raw = resp + + return response + + def settings(self, request: SettingsCardRequest, option: Optional[RequestOption] = None) -> SettingsCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: SettingsCardResponse = JSON.unmarshal(str(resp.content, UTF_8), SettingsCardResponse) + response.raw = resp + + return response + + async def asettings(self, request: SettingsCardRequest, + option: Optional[RequestOption] = None) -> SettingsCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: SettingsCardResponse = JSON.unmarshal(str(resp.content, UTF_8), SettingsCardResponse) + response.raw = resp + + return response + + def update(self, request: UpdateCardRequest, option: Optional[RequestOption] = None) -> UpdateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateCardResponse) + response.raw = resp + + return response + + async def aupdate(self, request: UpdateCardRequest, option: Optional[RequestOption] = None) -> UpdateCardResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateCardResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateCardResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/cardkit/v1/resource/card_element.py b/lark_channel/api/cardkit/v1/resource/card_element.py new file mode 100644 index 0000000..712e881 --- /dev/null +++ b/lark_channel/api/cardkit/v1/resource/card_element.py @@ -0,0 +1,214 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.content_card_element_request import ContentCardElementRequest +from ..model.content_card_element_response import ContentCardElementResponse +from ..model.create_card_element_request import CreateCardElementRequest +from ..model.create_card_element_response import CreateCardElementResponse +from ..model.delete_card_element_request import DeleteCardElementRequest +from ..model.delete_card_element_response import DeleteCardElementResponse +from ..model.patch_card_element_request import PatchCardElementRequest +from ..model.patch_card_element_response import PatchCardElementResponse +from ..model.update_card_element_request import UpdateCardElementRequest +from ..model.update_card_element_response import UpdateCardElementResponse + + +class CardElement(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def content(self, request: ContentCardElementRequest, + option: Optional[RequestOption] = None) -> ContentCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ContentCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), ContentCardElementResponse) + response.raw = resp + + return response + + async def acontent(self, request: ContentCardElementRequest, + option: Optional[RequestOption] = None) -> ContentCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ContentCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), ContentCardElementResponse) + response.raw = resp + + return response + + def create(self, request: CreateCardElementRequest, + option: Optional[RequestOption] = None) -> CreateCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateCardElementResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateCardElementRequest, + option: Optional[RequestOption] = None) -> CreateCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateCardElementResponse) + response.raw = resp + + return response + + def delete(self, request: DeleteCardElementRequest, + option: Optional[RequestOption] = None) -> DeleteCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: DeleteCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteCardElementResponse) + response.raw = resp + + return response + + async def adelete(self, request: DeleteCardElementRequest, + option: Optional[RequestOption] = None) -> DeleteCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: DeleteCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteCardElementResponse) + response.raw = resp + + return response + + def patch(self, request: PatchCardElementRequest, + option: Optional[RequestOption] = None) -> PatchCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: PatchCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchCardElementResponse) + response.raw = resp + + return response + + async def apatch(self, request: PatchCardElementRequest, + option: Optional[RequestOption] = None) -> PatchCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: PatchCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchCardElementResponse) + response.raw = resp + + return response + + def update(self, request: UpdateCardElementRequest, + option: Optional[RequestOption] = None) -> UpdateCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateCardElementResponse) + response.raw = resp + + return response + + async def aupdate(self, request: UpdateCardElementRequest, + option: Optional[RequestOption] = None) -> UpdateCardElementResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateCardElementResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateCardElementResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/cardkit/v1/version.py b/lark_channel/api/cardkit/v1/version.py new file mode 100644 index 0000000..c2b8066 --- /dev/null +++ b/lark_channel/api/cardkit/v1/version.py @@ -0,0 +1,9 @@ +from lark_channel.core.model import Config + +from .resource import Card, CardElement + + +class V1(object): + def __init__(self, config: Config) -> None: + self.card: Card = Card(config) + self.card_element: CardElement = CardElement(config) diff --git a/lark_channel/api/contact/__init__.py b/lark_channel/api/contact/__init__.py new file mode 100644 index 0000000..a10884f --- /dev/null +++ b/lark_channel/api/contact/__init__.py @@ -0,0 +1 @@ +from . import v3 diff --git a/lark_channel/api/contact/service.py b/lark_channel/api/contact/service.py new file mode 100644 index 0000000..fe1603c --- /dev/null +++ b/lark_channel/api/contact/service.py @@ -0,0 +1,7 @@ +from lark_channel.core.model import Config +from .v3.version import V3 + + +class ContactService(object): + def __init__(self, config: Config) -> None: + self.v3: V3 = V3(config) diff --git a/lark_channel/api/contact/v3/__init__.py b/lark_channel/api/contact/v3/__init__.py new file mode 100644 index 0000000..faf4f40 --- /dev/null +++ b/lark_channel/api/contact/v3/__init__.py @@ -0,0 +1,5 @@ +"""Minimal Contact v3 resources required by lark_channel.""" + +from .version import V3 + +__all__ = ["V3"] diff --git a/lark_channel/api/contact/v3/model/__init__.py b/lark_channel/api/contact/v3/model/__init__.py new file mode 100644 index 0000000..d8d811c --- /dev/null +++ b/lark_channel/api/contact/v3/model/__init__.py @@ -0,0 +1 @@ +"""Contact model submodules are imported explicitly by lark_channel.""" diff --git a/lark_channel/api/contact/v3/model/avatar_info.py b/lark_channel/api/contact/v3/model/avatar_info.py new file mode 100644 index 0000000..5f22d9c --- /dev/null +++ b/lark_channel/api/contact/v3/model/avatar_info.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class AvatarInfo(object): + _types = { + "avatar_72": str, + "avatar_240": str, + "avatar_640": str, + "avatar_origin": str, + } + + def __init__(self, d=None): + self.avatar_72: Optional[str] = None + self.avatar_240: Optional[str] = None + self.avatar_640: Optional[str] = None + self.avatar_origin: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "AvatarInfoBuilder": + return AvatarInfoBuilder() + + +class AvatarInfoBuilder(object): + def __init__(self) -> None: + self._avatar_info = AvatarInfo() + + def avatar_72(self, avatar_72: str) -> "AvatarInfoBuilder": + self._avatar_info.avatar_72 = avatar_72 + return self + + def avatar_240(self, avatar_240: str) -> "AvatarInfoBuilder": + self._avatar_info.avatar_240 = avatar_240 + return self + + def avatar_640(self, avatar_640: str) -> "AvatarInfoBuilder": + self._avatar_info.avatar_640 = avatar_640 + return self + + def avatar_origin(self, avatar_origin: str) -> "AvatarInfoBuilder": + self._avatar_info.avatar_origin = avatar_origin + return self + + def build(self) -> "AvatarInfo": + return self._avatar_info diff --git a/lark_channel/api/contact/v3/model/batch_get_id_user_request.py b/lark_channel/api/contact/v3/model/batch_get_id_user_request.py new file mode 100644 index 0000000..ec976f1 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_get_id_user_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .batch_get_id_user_request_body import BatchGetIdUserRequestBody + + +class BatchGetIdUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.request_body: Optional[BatchGetIdUserRequestBody] = None + + @staticmethod + def builder() -> "BatchGetIdUserRequestBuilder": + return BatchGetIdUserRequestBuilder() + + +class BatchGetIdUserRequestBuilder(object): + + def __init__(self) -> None: + batch_get_id_user_request = BatchGetIdUserRequest() + batch_get_id_user_request.http_method = HttpMethod.POST + batch_get_id_user_request.uri = "/open-apis/contact/v3/users/batch_get_id" + batch_get_id_user_request.token_types = {AccessTokenType.TENANT} + self._batch_get_id_user_request: BatchGetIdUserRequest = batch_get_id_user_request + + def user_id_type(self, user_id_type: str) -> "BatchGetIdUserRequestBuilder": + self._batch_get_id_user_request.user_id_type = user_id_type + self._batch_get_id_user_request.add_query("user_id_type", user_id_type) + return self + + def request_body(self, request_body: BatchGetIdUserRequestBody) -> "BatchGetIdUserRequestBuilder": + self._batch_get_id_user_request.request_body = request_body + self._batch_get_id_user_request.body = request_body + return self + + def build(self) -> BatchGetIdUserRequest: + return self._batch_get_id_user_request diff --git a/lark_channel/api/contact/v3/model/batch_get_id_user_request_body.py b/lark_channel/api/contact/v3/model/batch_get_id_user_request_body.py new file mode 100644 index 0000000..3d972f8 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_get_id_user_request_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class BatchGetIdUserRequestBody(object): + _types = { + "emails": List[str], + "mobiles": List[str], + "include_resigned": bool, + } + + def __init__(self, d=None): + self.emails: Optional[List[str]] = None + self.mobiles: Optional[List[str]] = None + self.include_resigned: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "BatchGetIdUserRequestBodyBuilder": + return BatchGetIdUserRequestBodyBuilder() + + +class BatchGetIdUserRequestBodyBuilder(object): + def __init__(self) -> None: + self._batch_get_id_user_request_body = BatchGetIdUserRequestBody() + + def emails(self, emails: List[str]) -> "BatchGetIdUserRequestBodyBuilder": + self._batch_get_id_user_request_body.emails = emails + return self + + def mobiles(self, mobiles: List[str]) -> "BatchGetIdUserRequestBodyBuilder": + self._batch_get_id_user_request_body.mobiles = mobiles + return self + + def include_resigned(self, include_resigned: bool) -> "BatchGetIdUserRequestBodyBuilder": + self._batch_get_id_user_request_body.include_resigned = include_resigned + return self + + def build(self) -> "BatchGetIdUserRequestBody": + return self._batch_get_id_user_request_body diff --git a/lark_channel/api/contact/v3/model/batch_get_id_user_response.py b/lark_channel/api/contact/v3/model/batch_get_id_user_response.py new file mode 100644 index 0000000..63a850d --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_get_id_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .batch_get_id_user_response_body import BatchGetIdUserResponseBody + + +class BatchGetIdUserResponse(BaseResponse): + _types = { + "data": BatchGetIdUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[BatchGetIdUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/batch_get_id_user_response_body.py b/lark_channel/api/contact/v3/model/batch_get_id_user_response_body.py new file mode 100644 index 0000000..7aec0f2 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_get_id_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_contact_info import UserContactInfo + + +class BatchGetIdUserResponseBody(object): + _types = { + "user_list": List[UserContactInfo], + } + + def __init__(self, d=None): + self.user_list: Optional[List[UserContactInfo]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "BatchGetIdUserResponseBodyBuilder": + return BatchGetIdUserResponseBodyBuilder() + + +class BatchGetIdUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._batch_get_id_user_response_body = BatchGetIdUserResponseBody() + + def user_list(self, user_list: List[UserContactInfo]) -> "BatchGetIdUserResponseBodyBuilder": + self._batch_get_id_user_response_body.user_list = user_list + return self + + def build(self) -> "BatchGetIdUserResponseBody": + return self._batch_get_id_user_response_body diff --git a/lark_channel/api/contact/v3/model/batch_user_request.py b/lark_channel/api/contact/v3/model/batch_user_request.py new file mode 100644 index 0000000..67a6839 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_user_request.py @@ -0,0 +1,43 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class BatchUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_ids: Optional[List[str]] = None + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + + @staticmethod + def builder() -> "BatchUserRequestBuilder": + return BatchUserRequestBuilder() + + +class BatchUserRequestBuilder(object): + + def __init__(self) -> None: + batch_user_request = BatchUserRequest() + batch_user_request.http_method = HttpMethod.GET + batch_user_request.uri = "/open-apis/contact/v3/users/batch" + batch_user_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._batch_user_request: BatchUserRequest = batch_user_request + + def user_ids(self, user_ids: List[str]) -> "BatchUserRequestBuilder": + self._batch_user_request.user_ids = user_ids + self._batch_user_request.add_query("user_ids", user_ids) + return self + + def user_id_type(self, user_id_type: str) -> "BatchUserRequestBuilder": + self._batch_user_request.user_id_type = user_id_type + self._batch_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "BatchUserRequestBuilder": + self._batch_user_request.department_id_type = department_id_type + self._batch_user_request.add_query("department_id_type", department_id_type) + return self + + def build(self) -> BatchUserRequest: + return self._batch_user_request diff --git a/lark_channel/api/contact/v3/model/batch_user_response.py b/lark_channel/api/contact/v3/model/batch_user_response.py new file mode 100644 index 0000000..8aa73e7 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .batch_user_response_body import BatchUserResponseBody + + +class BatchUserResponse(BaseResponse): + _types = { + "data": BatchUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[BatchUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/batch_user_response_body.py b/lark_channel/api/contact/v3/model/batch_user_response_body.py new file mode 100644 index 0000000..6363225 --- /dev/null +++ b/lark_channel/api/contact/v3/model/batch_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class BatchUserResponseBody(object): + _types = { + "items": List[User], + } + + def __init__(self, d=None): + self.items: Optional[List[User]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "BatchUserResponseBodyBuilder": + return BatchUserResponseBodyBuilder() + + +class BatchUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._batch_user_response_body = BatchUserResponseBody() + + def items(self, items: List[User]) -> "BatchUserResponseBodyBuilder": + self._batch_user_response_body.items = items + return self + + def build(self) -> "BatchUserResponseBody": + return self._batch_user_response_body diff --git a/lark_channel/api/contact/v3/model/create_user_request.py b/lark_channel/api/contact/v3/model/create_user_request.py new file mode 100644 index 0000000..1510cf0 --- /dev/null +++ b/lark_channel/api/contact/v3/model/create_user_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .user import User + + +class CreateUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.client_token: Optional[str] = None + self.request_body: Optional[User] = None + + @staticmethod + def builder() -> "CreateUserRequestBuilder": + return CreateUserRequestBuilder() + + +class CreateUserRequestBuilder(object): + + def __init__(self) -> None: + create_user_request = CreateUserRequest() + create_user_request.http_method = HttpMethod.POST + create_user_request.uri = "/open-apis/contact/v3/users" + create_user_request.token_types = {AccessTokenType.TENANT} + self._create_user_request: CreateUserRequest = create_user_request + + def user_id_type(self, user_id_type: str) -> "CreateUserRequestBuilder": + self._create_user_request.user_id_type = user_id_type + self._create_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "CreateUserRequestBuilder": + self._create_user_request.department_id_type = department_id_type + self._create_user_request.add_query("department_id_type", department_id_type) + return self + + def client_token(self, client_token: str) -> "CreateUserRequestBuilder": + self._create_user_request.client_token = client_token + self._create_user_request.add_query("client_token", client_token) + return self + + def request_body(self, request_body: User) -> "CreateUserRequestBuilder": + self._create_user_request.request_body = request_body + self._create_user_request.body = request_body + return self + + def build(self) -> CreateUserRequest: + return self._create_user_request diff --git a/lark_channel/api/contact/v3/model/create_user_response.py b/lark_channel/api/contact/v3/model/create_user_response.py new file mode 100644 index 0000000..d617650 --- /dev/null +++ b/lark_channel/api/contact/v3/model/create_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_user_response_body import CreateUserResponseBody + + +class CreateUserResponse(BaseResponse): + _types = { + "data": CreateUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/create_user_response_body.py b/lark_channel/api/contact/v3/model/create_user_response_body.py new file mode 100644 index 0000000..eead4c4 --- /dev/null +++ b/lark_channel/api/contact/v3/model/create_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class CreateUserResponseBody(object): + _types = { + "user": User, + } + + def __init__(self, d=None): + self.user: Optional[User] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateUserResponseBodyBuilder": + return CreateUserResponseBodyBuilder() + + +class CreateUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_user_response_body = CreateUserResponseBody() + + def user(self, user: User) -> "CreateUserResponseBodyBuilder": + self._create_user_response_body.user = user + return self + + def build(self) -> "CreateUserResponseBody": + return self._create_user_response_body diff --git a/lark_channel/api/contact/v3/model/custom_attr_generic_user.py b/lark_channel/api/contact/v3/model/custom_attr_generic_user.py new file mode 100644 index 0000000..4dd900c --- /dev/null +++ b/lark_channel/api/contact/v3/model/custom_attr_generic_user.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CustomAttrGenericUser(object): + _types = { + "id": str, + "type": int, + } + + def __init__(self, d=None): + self.id: Optional[str] = None + self.type: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CustomAttrGenericUserBuilder": + return CustomAttrGenericUserBuilder() + + +class CustomAttrGenericUserBuilder(object): + def __init__(self) -> None: + self._custom_attr_generic_user = CustomAttrGenericUser() + + def id(self, id: str) -> "CustomAttrGenericUserBuilder": + self._custom_attr_generic_user.id = id + return self + + def type(self, type: int) -> "CustomAttrGenericUserBuilder": + self._custom_attr_generic_user.type = type + return self + + def build(self) -> "CustomAttrGenericUser": + return self._custom_attr_generic_user diff --git a/lark_channel/api/contact/v3/model/delete_user_request.py b/lark_channel/api/contact/v3/model/delete_user_request.py new file mode 100644 index 0000000..470421c --- /dev/null +++ b/lark_channel/api/contact/v3/model/delete_user_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .delete_user_request_body import DeleteUserRequestBody + + +class DeleteUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.user_id: Optional[str] = None + self.request_body: Optional[DeleteUserRequestBody] = None + + @staticmethod + def builder() -> "DeleteUserRequestBuilder": + return DeleteUserRequestBuilder() + + +class DeleteUserRequestBuilder(object): + + def __init__(self) -> None: + delete_user_request = DeleteUserRequest() + delete_user_request.http_method = HttpMethod.DELETE + delete_user_request.uri = "/open-apis/contact/v3/users/:user_id" + delete_user_request.token_types = {AccessTokenType.TENANT} + self._delete_user_request: DeleteUserRequest = delete_user_request + + def user_id_type(self, user_id_type: str) -> "DeleteUserRequestBuilder": + self._delete_user_request.user_id_type = user_id_type + self._delete_user_request.add_query("user_id_type", user_id_type) + return self + + def user_id(self, user_id: str) -> "DeleteUserRequestBuilder": + self._delete_user_request.user_id = user_id + self._delete_user_request.paths["user_id"] = str(user_id) + return self + + def request_body(self, request_body: DeleteUserRequestBody) -> "DeleteUserRequestBuilder": + self._delete_user_request.request_body = request_body + self._delete_user_request.body = request_body + return self + + def build(self) -> DeleteUserRequest: + return self._delete_user_request diff --git a/lark_channel/api/contact/v3/model/delete_user_request_body.py b/lark_channel/api/contact/v3/model/delete_user_request_body.py new file mode 100644 index 0000000..8f63f97 --- /dev/null +++ b/lark_channel/api/contact/v3/model/delete_user_request_body.py @@ -0,0 +1,77 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .resource_acceptor import ResourceAcceptor + + +class DeleteUserRequestBody(object): + _types = { + "department_chat_acceptor_user_id": str, + "external_chat_acceptor_user_id": str, + "docs_acceptor_user_id": str, + "calendar_acceptor_user_id": str, + "application_acceptor_user_id": str, + "minutes_acceptor_user_id": str, + "survey_acceptor_user_id": str, + "email_acceptor": ResourceAcceptor, + "anycross_acceptor_user_id": str, + } + + def __init__(self, d=None): + self.department_chat_acceptor_user_id: Optional[str] = None + self.external_chat_acceptor_user_id: Optional[str] = None + self.docs_acceptor_user_id: Optional[str] = None + self.calendar_acceptor_user_id: Optional[str] = None + self.application_acceptor_user_id: Optional[str] = None + self.minutes_acceptor_user_id: Optional[str] = None + self.survey_acceptor_user_id: Optional[str] = None + self.email_acceptor: Optional[ResourceAcceptor] = None + self.anycross_acceptor_user_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DeleteUserRequestBodyBuilder": + return DeleteUserRequestBodyBuilder() + + +class DeleteUserRequestBodyBuilder(object): + def __init__(self) -> None: + self._delete_user_request_body = DeleteUserRequestBody() + + def department_chat_acceptor_user_id(self, department_chat_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.department_chat_acceptor_user_id = department_chat_acceptor_user_id + return self + + def external_chat_acceptor_user_id(self, external_chat_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.external_chat_acceptor_user_id = external_chat_acceptor_user_id + return self + + def docs_acceptor_user_id(self, docs_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.docs_acceptor_user_id = docs_acceptor_user_id + return self + + def calendar_acceptor_user_id(self, calendar_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.calendar_acceptor_user_id = calendar_acceptor_user_id + return self + + def application_acceptor_user_id(self, application_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.application_acceptor_user_id = application_acceptor_user_id + return self + + def minutes_acceptor_user_id(self, minutes_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.minutes_acceptor_user_id = minutes_acceptor_user_id + return self + + def survey_acceptor_user_id(self, survey_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.survey_acceptor_user_id = survey_acceptor_user_id + return self + + def email_acceptor(self, email_acceptor: ResourceAcceptor) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.email_acceptor = email_acceptor + return self + + def anycross_acceptor_user_id(self, anycross_acceptor_user_id: str) -> "DeleteUserRequestBodyBuilder": + self._delete_user_request_body.anycross_acceptor_user_id = anycross_acceptor_user_id + return self + + def build(self) -> "DeleteUserRequestBody": + return self._delete_user_request_body diff --git a/lark_channel/api/contact/v3/model/delete_user_response.py b/lark_channel/api/contact/v3/model/delete_user_response.py new file mode 100644 index 0000000..f4f7512 --- /dev/null +++ b/lark_channel/api/contact/v3/model/delete_user_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class DeleteUserResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/department_detail.py b/lark_channel/api/contact/v3/model/department_detail.py new file mode 100644 index 0000000..3891e68 --- /dev/null +++ b/lark_channel/api/contact/v3/model/department_detail.py @@ -0,0 +1,42 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .department_path_name import DepartmentPathName +from .department_path import DepartmentPath + + +class DepartmentDetail(object): + _types = { + "department_id": int, + "department_name": DepartmentPathName, + "department_path": DepartmentPath, + } + + def __init__(self, d=None): + self.department_id: Optional[int] = None + self.department_name: Optional[DepartmentPathName] = None + self.department_path: Optional[DepartmentPath] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DepartmentDetailBuilder": + return DepartmentDetailBuilder() + + +class DepartmentDetailBuilder(object): + def __init__(self) -> None: + self._department_detail = DepartmentDetail() + + def department_id(self, department_id: int) -> "DepartmentDetailBuilder": + self._department_detail.department_id = department_id + return self + + def department_name(self, department_name: DepartmentPathName) -> "DepartmentDetailBuilder": + self._department_detail.department_name = department_name + return self + + def department_path(self, department_path: DepartmentPath) -> "DepartmentDetailBuilder": + self._department_detail.department_path = department_path + return self + + def build(self) -> "DepartmentDetail": + return self._department_detail diff --git a/lark_channel/api/contact/v3/model/department_i18n_name.py b/lark_channel/api/contact/v3/model/department_i18n_name.py new file mode 100644 index 0000000..598e737 --- /dev/null +++ b/lark_channel/api/contact/v3/model/department_i18n_name.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class DepartmentI18nName(object): + _types = { + "zh_cn": str, + "ja_jp": str, + "en_us": str, + } + + def __init__(self, d=None): + self.zh_cn: Optional[str] = None + self.ja_jp: Optional[str] = None + self.en_us: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DepartmentI18nNameBuilder": + return DepartmentI18nNameBuilder() + + +class DepartmentI18nNameBuilder(object): + def __init__(self) -> None: + self._department_i18n_name = DepartmentI18nName() + + def zh_cn(self, zh_cn: str) -> "DepartmentI18nNameBuilder": + self._department_i18n_name.zh_cn = zh_cn + return self + + def ja_jp(self, ja_jp: str) -> "DepartmentI18nNameBuilder": + self._department_i18n_name.ja_jp = ja_jp + return self + + def en_us(self, en_us: str) -> "DepartmentI18nNameBuilder": + self._department_i18n_name.en_us = en_us + return self + + def build(self) -> "DepartmentI18nName": + return self._department_i18n_name diff --git a/lark_channel/api/contact/v3/model/department_path.py b/lark_channel/api/contact/v3/model/department_path.py new file mode 100644 index 0000000..e3a1957 --- /dev/null +++ b/lark_channel/api/contact/v3/model/department_path.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .department_path_name import DepartmentPathName + + +class DepartmentPath(object): + _types = { + "department_ids": List[str], + "department_path_name": DepartmentPathName, + } + + def __init__(self, d=None): + self.department_ids: Optional[List[str]] = None + self.department_path_name: Optional[DepartmentPathName] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DepartmentPathBuilder": + return DepartmentPathBuilder() + + +class DepartmentPathBuilder(object): + def __init__(self) -> None: + self._department_path = DepartmentPath() + + def department_ids(self, department_ids: List[str]) -> "DepartmentPathBuilder": + self._department_path.department_ids = department_ids + return self + + def department_path_name(self, department_path_name: DepartmentPathName) -> "DepartmentPathBuilder": + self._department_path.department_path_name = department_path_name + return self + + def build(self) -> "DepartmentPath": + return self._department_path diff --git a/lark_channel/api/contact/v3/model/department_path_name.py b/lark_channel/api/contact/v3/model/department_path_name.py new file mode 100644 index 0000000..d6d17dc --- /dev/null +++ b/lark_channel/api/contact/v3/model/department_path_name.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .department_i18n_name import DepartmentI18nName + + +class DepartmentPathName(object): + _types = { + "name": str, + "i18n_name": DepartmentI18nName, + } + + def __init__(self, d=None): + self.name: Optional[str] = None + self.i18n_name: Optional[DepartmentI18nName] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DepartmentPathNameBuilder": + return DepartmentPathNameBuilder() + + +class DepartmentPathNameBuilder(object): + def __init__(self) -> None: + self._department_path_name = DepartmentPathName() + + def name(self, name: str) -> "DepartmentPathNameBuilder": + self._department_path_name.name = name + return self + + def i18n_name(self, i18n_name: DepartmentI18nName) -> "DepartmentPathNameBuilder": + self._department_path_name.i18n_name = i18n_name + return self + + def build(self) -> "DepartmentPathName": + return self._department_path_name diff --git a/lark_channel/api/contact/v3/model/find_by_department_user_request.py b/lark_channel/api/contact/v3/model/find_by_department_user_request.py new file mode 100644 index 0000000..7fe0304 --- /dev/null +++ b/lark_channel/api/contact/v3/model/find_by_department_user_request.py @@ -0,0 +1,55 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class FindByDepartmentUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.department_id: Optional[str] = None + self.page_size: Optional[int] = None + self.page_token: Optional[str] = None + + @staticmethod + def builder() -> "FindByDepartmentUserRequestBuilder": + return FindByDepartmentUserRequestBuilder() + + +class FindByDepartmentUserRequestBuilder(object): + + def __init__(self) -> None: + find_by_department_user_request = FindByDepartmentUserRequest() + find_by_department_user_request.http_method = HttpMethod.GET + find_by_department_user_request.uri = "/open-apis/contact/v3/users/find_by_department" + find_by_department_user_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._find_by_department_user_request: FindByDepartmentUserRequest = find_by_department_user_request + + def user_id_type(self, user_id_type: str) -> "FindByDepartmentUserRequestBuilder": + self._find_by_department_user_request.user_id_type = user_id_type + self._find_by_department_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "FindByDepartmentUserRequestBuilder": + self._find_by_department_user_request.department_id_type = department_id_type + self._find_by_department_user_request.add_query("department_id_type", department_id_type) + return self + + def department_id(self, department_id: str) -> "FindByDepartmentUserRequestBuilder": + self._find_by_department_user_request.department_id = department_id + self._find_by_department_user_request.add_query("department_id", department_id) + return self + + def page_size(self, page_size: int) -> "FindByDepartmentUserRequestBuilder": + self._find_by_department_user_request.page_size = page_size + self._find_by_department_user_request.add_query("page_size", page_size) + return self + + def page_token(self, page_token: str) -> "FindByDepartmentUserRequestBuilder": + self._find_by_department_user_request.page_token = page_token + self._find_by_department_user_request.add_query("page_token", page_token) + return self + + def build(self) -> FindByDepartmentUserRequest: + return self._find_by_department_user_request diff --git a/lark_channel/api/contact/v3/model/find_by_department_user_response.py b/lark_channel/api/contact/v3/model/find_by_department_user_response.py new file mode 100644 index 0000000..855fdc1 --- /dev/null +++ b/lark_channel/api/contact/v3/model/find_by_department_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .find_by_department_user_response_body import FindByDepartmentUserResponseBody + + +class FindByDepartmentUserResponse(BaseResponse): + _types = { + "data": FindByDepartmentUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[FindByDepartmentUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/find_by_department_user_response_body.py b/lark_channel/api/contact/v3/model/find_by_department_user_response_body.py new file mode 100644 index 0000000..5547aa2 --- /dev/null +++ b/lark_channel/api/contact/v3/model/find_by_department_user_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class FindByDepartmentUserResponseBody(object): + _types = { + "has_more": bool, + "page_token": str, + "items": List[User], + } + + def __init__(self, d=None): + self.has_more: Optional[bool] = None + self.page_token: Optional[str] = None + self.items: Optional[List[User]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "FindByDepartmentUserResponseBodyBuilder": + return FindByDepartmentUserResponseBodyBuilder() + + +class FindByDepartmentUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._find_by_department_user_response_body = FindByDepartmentUserResponseBody() + + def has_more(self, has_more: bool) -> "FindByDepartmentUserResponseBodyBuilder": + self._find_by_department_user_response_body.has_more = has_more + return self + + def page_token(self, page_token: str) -> "FindByDepartmentUserResponseBodyBuilder": + self._find_by_department_user_response_body.page_token = page_token + return self + + def items(self, items: List[User]) -> "FindByDepartmentUserResponseBodyBuilder": + self._find_by_department_user_response_body.items = items + return self + + def build(self) -> "FindByDepartmentUserResponseBody": + return self._find_by_department_user_response_body diff --git a/lark_channel/api/contact/v3/model/get_user_request.py b/lark_channel/api/contact/v3/model/get_user_request.py new file mode 100644 index 0000000..a8403b2 --- /dev/null +++ b/lark_channel/api/contact/v3/model/get_user_request.py @@ -0,0 +1,43 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.user_id: Optional[str] = None + + @staticmethod + def builder() -> "GetUserRequestBuilder": + return GetUserRequestBuilder() + + +class GetUserRequestBuilder(object): + + def __init__(self) -> None: + get_user_request = GetUserRequest() + get_user_request.http_method = HttpMethod.GET + get_user_request.uri = "/open-apis/contact/v3/users/:user_id" + get_user_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._get_user_request: GetUserRequest = get_user_request + + def user_id_type(self, user_id_type: str) -> "GetUserRequestBuilder": + self._get_user_request.user_id_type = user_id_type + self._get_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "GetUserRequestBuilder": + self._get_user_request.department_id_type = department_id_type + self._get_user_request.add_query("department_id_type", department_id_type) + return self + + def user_id(self, user_id: str) -> "GetUserRequestBuilder": + self._get_user_request.user_id = user_id + self._get_user_request.paths["user_id"] = str(user_id) + return self + + def build(self) -> GetUserRequest: + return self._get_user_request diff --git a/lark_channel/api/contact/v3/model/get_user_response.py b/lark_channel/api/contact/v3/model/get_user_response.py new file mode 100644 index 0000000..d398ebe --- /dev/null +++ b/lark_channel/api/contact/v3/model/get_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .get_user_response_body import GetUserResponseBody + + +class GetUserResponse(BaseResponse): + _types = { + "data": GetUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[GetUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/get_user_response_body.py b/lark_channel/api/contact/v3/model/get_user_response_body.py new file mode 100644 index 0000000..67833d7 --- /dev/null +++ b/lark_channel/api/contact/v3/model/get_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class GetUserResponseBody(object): + _types = { + "user": User, + } + + def __init__(self, d=None): + self.user: Optional[User] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "GetUserResponseBodyBuilder": + return GetUserResponseBodyBuilder() + + +class GetUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._get_user_response_body = GetUserResponseBody() + + def user(self, user: User) -> "GetUserResponseBodyBuilder": + self._get_user_response_body.user = user + return self + + def build(self) -> "GetUserResponseBody": + return self._get_user_response_body diff --git a/lark_channel/api/contact/v3/model/list_user_request.py b/lark_channel/api/contact/v3/model/list_user_request.py new file mode 100644 index 0000000..fcef224 --- /dev/null +++ b/lark_channel/api/contact/v3/model/list_user_request.py @@ -0,0 +1,55 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class ListUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.department_id: Optional[str] = None + self.page_token: Optional[str] = None + self.page_size: Optional[int] = None + + @staticmethod + def builder() -> "ListUserRequestBuilder": + return ListUserRequestBuilder() + + +class ListUserRequestBuilder(object): + + def __init__(self) -> None: + list_user_request = ListUserRequest() + list_user_request.http_method = HttpMethod.GET + list_user_request.uri = "/open-apis/contact/v3/users" + list_user_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._list_user_request: ListUserRequest = list_user_request + + def user_id_type(self, user_id_type: str) -> "ListUserRequestBuilder": + self._list_user_request.user_id_type = user_id_type + self._list_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "ListUserRequestBuilder": + self._list_user_request.department_id_type = department_id_type + self._list_user_request.add_query("department_id_type", department_id_type) + return self + + def department_id(self, department_id: str) -> "ListUserRequestBuilder": + self._list_user_request.department_id = department_id + self._list_user_request.add_query("department_id", department_id) + return self + + def page_token(self, page_token: str) -> "ListUserRequestBuilder": + self._list_user_request.page_token = page_token + self._list_user_request.add_query("page_token", page_token) + return self + + def page_size(self, page_size: int) -> "ListUserRequestBuilder": + self._list_user_request.page_size = page_size + self._list_user_request.add_query("page_size", page_size) + return self + + def build(self) -> ListUserRequest: + return self._list_user_request diff --git a/lark_channel/api/contact/v3/model/list_user_response.py b/lark_channel/api/contact/v3/model/list_user_response.py new file mode 100644 index 0000000..662c721 --- /dev/null +++ b/lark_channel/api/contact/v3/model/list_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .list_user_response_body import ListUserResponseBody + + +class ListUserResponse(BaseResponse): + _types = { + "data": ListUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ListUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/list_user_response_body.py b/lark_channel/api/contact/v3/model/list_user_response_body.py new file mode 100644 index 0000000..a049b9d --- /dev/null +++ b/lark_channel/api/contact/v3/model/list_user_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class ListUserResponseBody(object): + _types = { + "has_more": bool, + "page_token": str, + "items": List[User], + } + + def __init__(self, d=None): + self.has_more: Optional[bool] = None + self.page_token: Optional[str] = None + self.items: Optional[List[User]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListUserResponseBodyBuilder": + return ListUserResponseBodyBuilder() + + +class ListUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._list_user_response_body = ListUserResponseBody() + + def has_more(self, has_more: bool) -> "ListUserResponseBodyBuilder": + self._list_user_response_body.has_more = has_more + return self + + def page_token(self, page_token: str) -> "ListUserResponseBodyBuilder": + self._list_user_response_body.page_token = page_token + return self + + def items(self, items: List[User]) -> "ListUserResponseBodyBuilder": + self._list_user_response_body.items = items + return self + + def build(self) -> "ListUserResponseBody": + return self._list_user_response_body diff --git a/lark_channel/api/contact/v3/model/notification_option.py b/lark_channel/api/contact/v3/model/notification_option.py new file mode 100644 index 0000000..ab0ad9e --- /dev/null +++ b/lark_channel/api/contact/v3/model/notification_option.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class NotificationOption(object): + _types = { + "channels": List[str], + "language": str, + } + + def __init__(self, d=None): + self.channels: Optional[List[str]] = None + self.language: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "NotificationOptionBuilder": + return NotificationOptionBuilder() + + +class NotificationOptionBuilder(object): + def __init__(self) -> None: + self._notification_option = NotificationOption() + + def channels(self, channels: List[str]) -> "NotificationOptionBuilder": + self._notification_option.channels = channels + return self + + def language(self, language: str) -> "NotificationOptionBuilder": + self._notification_option.language = language + return self + + def build(self) -> "NotificationOption": + return self._notification_option diff --git a/lark_channel/api/contact/v3/model/patch_user_request.py b/lark_channel/api/contact/v3/model/patch_user_request.py new file mode 100644 index 0000000..4097bc5 --- /dev/null +++ b/lark_channel/api/contact/v3/model/patch_user_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .user import User + + +class PatchUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.user_id: Optional[str] = None + self.request_body: Optional[User] = None + + @staticmethod + def builder() -> "PatchUserRequestBuilder": + return PatchUserRequestBuilder() + + +class PatchUserRequestBuilder(object): + + def __init__(self) -> None: + patch_user_request = PatchUserRequest() + patch_user_request.http_method = HttpMethod.PATCH + patch_user_request.uri = "/open-apis/contact/v3/users/:user_id" + patch_user_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._patch_user_request: PatchUserRequest = patch_user_request + + def user_id_type(self, user_id_type: str) -> "PatchUserRequestBuilder": + self._patch_user_request.user_id_type = user_id_type + self._patch_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "PatchUserRequestBuilder": + self._patch_user_request.department_id_type = department_id_type + self._patch_user_request.add_query("department_id_type", department_id_type) + return self + + def user_id(self, user_id: str) -> "PatchUserRequestBuilder": + self._patch_user_request.user_id = user_id + self._patch_user_request.paths["user_id"] = str(user_id) + return self + + def request_body(self, request_body: User) -> "PatchUserRequestBuilder": + self._patch_user_request.request_body = request_body + self._patch_user_request.body = request_body + return self + + def build(self) -> PatchUserRequest: + return self._patch_user_request diff --git a/lark_channel/api/contact/v3/model/patch_user_response.py b/lark_channel/api/contact/v3/model/patch_user_response.py new file mode 100644 index 0000000..fe19cc1 --- /dev/null +++ b/lark_channel/api/contact/v3/model/patch_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .patch_user_response_body import PatchUserResponseBody + + +class PatchUserResponse(BaseResponse): + _types = { + "data": PatchUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[PatchUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/patch_user_response_body.py b/lark_channel/api/contact/v3/model/patch_user_response_body.py new file mode 100644 index 0000000..702fb14 --- /dev/null +++ b/lark_channel/api/contact/v3/model/patch_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class PatchUserResponseBody(object): + _types = { + "user": User, + } + + def __init__(self, d=None): + self.user: Optional[User] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "PatchUserResponseBodyBuilder": + return PatchUserResponseBodyBuilder() + + +class PatchUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._patch_user_response_body = PatchUserResponseBody() + + def user(self, user: User) -> "PatchUserResponseBodyBuilder": + self._patch_user_response_body.user = user + return self + + def build(self) -> "PatchUserResponseBody": + return self._patch_user_response_body diff --git a/lark_channel/api/contact/v3/model/product_i18n_name.py b/lark_channel/api/contact/v3/model/product_i18n_name.py new file mode 100644 index 0000000..17bc3fe --- /dev/null +++ b/lark_channel/api/contact/v3/model/product_i18n_name.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ProductI18nName(object): + _types = { + "zh_cn": str, + "ja_jp": str, + "en_us": str, + } + + def __init__(self, d=None): + self.zh_cn: Optional[str] = None + self.ja_jp: Optional[str] = None + self.en_us: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ProductI18nNameBuilder": + return ProductI18nNameBuilder() + + +class ProductI18nNameBuilder(object): + def __init__(self) -> None: + self._product_i18n_name = ProductI18nName() + + def zh_cn(self, zh_cn: str) -> "ProductI18nNameBuilder": + self._product_i18n_name.zh_cn = zh_cn + return self + + def ja_jp(self, ja_jp: str) -> "ProductI18nNameBuilder": + self._product_i18n_name.ja_jp = ja_jp + return self + + def en_us(self, en_us: str) -> "ProductI18nNameBuilder": + self._product_i18n_name.en_us = en_us + return self + + def build(self) -> "ProductI18nName": + return self._product_i18n_name diff --git a/lark_channel/api/contact/v3/model/resource_acceptor.py b/lark_channel/api/contact/v3/model/resource_acceptor.py new file mode 100644 index 0000000..2841064 --- /dev/null +++ b/lark_channel/api/contact/v3/model/resource_acceptor.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ResourceAcceptor(object): + _types = { + "processing_type": int, + "acceptor_user_id": int, + } + + def __init__(self, d=None): + self.processing_type: Optional[int] = None + self.acceptor_user_id: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ResourceAcceptorBuilder": + return ResourceAcceptorBuilder() + + +class ResourceAcceptorBuilder(object): + def __init__(self) -> None: + self._resource_acceptor = ResourceAcceptor() + + def processing_type(self, processing_type: int) -> "ResourceAcceptorBuilder": + self._resource_acceptor.processing_type = processing_type + return self + + def acceptor_user_id(self, acceptor_user_id: int) -> "ResourceAcceptorBuilder": + self._resource_acceptor.acceptor_user_id = acceptor_user_id + return self + + def build(self) -> "ResourceAcceptor": + return self._resource_acceptor diff --git a/lark_channel/api/contact/v3/model/resurrect_user_request.py b/lark_channel/api/contact/v3/model/resurrect_user_request.py new file mode 100644 index 0000000..9eb364e --- /dev/null +++ b/lark_channel/api/contact/v3/model/resurrect_user_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .resurrect_user_request_body import ResurrectUserRequestBody + + +class ResurrectUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.user_id: Optional[int] = None + self.request_body: Optional[ResurrectUserRequestBody] = None + + @staticmethod + def builder() -> "ResurrectUserRequestBuilder": + return ResurrectUserRequestBuilder() + + +class ResurrectUserRequestBuilder(object): + + def __init__(self) -> None: + resurrect_user_request = ResurrectUserRequest() + resurrect_user_request.http_method = HttpMethod.POST + resurrect_user_request.uri = "/open-apis/contact/v3/users/:user_id/resurrect" + resurrect_user_request.token_types = {AccessTokenType.TENANT} + self._resurrect_user_request: ResurrectUserRequest = resurrect_user_request + + def user_id_type(self, user_id_type: str) -> "ResurrectUserRequestBuilder": + self._resurrect_user_request.user_id_type = user_id_type + self._resurrect_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "ResurrectUserRequestBuilder": + self._resurrect_user_request.department_id_type = department_id_type + self._resurrect_user_request.add_query("department_id_type", department_id_type) + return self + + def user_id(self, user_id: int) -> "ResurrectUserRequestBuilder": + self._resurrect_user_request.user_id = user_id + self._resurrect_user_request.paths["user_id"] = str(user_id) + return self + + def request_body(self, request_body: ResurrectUserRequestBody) -> "ResurrectUserRequestBuilder": + self._resurrect_user_request.request_body = request_body + self._resurrect_user_request.body = request_body + return self + + def build(self) -> ResurrectUserRequest: + return self._resurrect_user_request diff --git a/lark_channel/api/contact/v3/model/resurrect_user_request_body.py b/lark_channel/api/contact/v3/model/resurrect_user_request_body.py new file mode 100644 index 0000000..a8d5a12 --- /dev/null +++ b/lark_channel/api/contact/v3/model/resurrect_user_request_body.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_department_info import UserDepartmentInfo + + +class ResurrectUserRequestBody(object): + _types = { + "departments": List[UserDepartmentInfo], + "subscription_ids": List[int], + } + + def __init__(self, d=None): + self.departments: Optional[List[UserDepartmentInfo]] = None + self.subscription_ids: Optional[List[int]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ResurrectUserRequestBodyBuilder": + return ResurrectUserRequestBodyBuilder() + + +class ResurrectUserRequestBodyBuilder(object): + def __init__(self) -> None: + self._resurrect_user_request_body = ResurrectUserRequestBody() + + def departments(self, departments: List[UserDepartmentInfo]) -> "ResurrectUserRequestBodyBuilder": + self._resurrect_user_request_body.departments = departments + return self + + def subscription_ids(self, subscription_ids: List[int]) -> "ResurrectUserRequestBodyBuilder": + self._resurrect_user_request_body.subscription_ids = subscription_ids + return self + + def build(self) -> "ResurrectUserRequestBody": + return self._resurrect_user_request_body diff --git a/lark_channel/api/contact/v3/model/resurrect_user_response.py b/lark_channel/api/contact/v3/model/resurrect_user_response.py new file mode 100644 index 0000000..3289844 --- /dev/null +++ b/lark_channel/api/contact/v3/model/resurrect_user_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class ResurrectUserResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/update_user_id_user_request.py b/lark_channel/api/contact/v3/model/update_user_id_user_request.py new file mode 100644 index 0000000..a4bc78b --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_id_user_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .update_user_id_user_request_body import UpdateUserIdUserRequestBody + + +class UpdateUserIdUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.user_id: Optional[str] = None + self.request_body: Optional[UpdateUserIdUserRequestBody] = None + + @staticmethod + def builder() -> "UpdateUserIdUserRequestBuilder": + return UpdateUserIdUserRequestBuilder() + + +class UpdateUserIdUserRequestBuilder(object): + + def __init__(self) -> None: + update_user_id_user_request = UpdateUserIdUserRequest() + update_user_id_user_request.http_method = HttpMethod.PATCH + update_user_id_user_request.uri = "/open-apis/contact/v3/users/:user_id/update_user_id" + update_user_id_user_request.token_types = {AccessTokenType.TENANT} + self._update_user_id_user_request: UpdateUserIdUserRequest = update_user_id_user_request + + def user_id_type(self, user_id_type: str) -> "UpdateUserIdUserRequestBuilder": + self._update_user_id_user_request.user_id_type = user_id_type + self._update_user_id_user_request.add_query("user_id_type", user_id_type) + return self + + def user_id(self, user_id: str) -> "UpdateUserIdUserRequestBuilder": + self._update_user_id_user_request.user_id = user_id + self._update_user_id_user_request.paths["user_id"] = str(user_id) + return self + + def request_body(self, request_body: UpdateUserIdUserRequestBody) -> "UpdateUserIdUserRequestBuilder": + self._update_user_id_user_request.request_body = request_body + self._update_user_id_user_request.body = request_body + return self + + def build(self) -> UpdateUserIdUserRequest: + return self._update_user_id_user_request diff --git a/lark_channel/api/contact/v3/model/update_user_id_user_request_body.py b/lark_channel/api/contact/v3/model/update_user_id_user_request_body.py new file mode 100644 index 0000000..0d58e16 --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_id_user_request_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UpdateUserIdUserRequestBody(object): + _types = { + "new_user_id": str, + } + + def __init__(self, d=None): + self.new_user_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateUserIdUserRequestBodyBuilder": + return UpdateUserIdUserRequestBodyBuilder() + + +class UpdateUserIdUserRequestBodyBuilder(object): + def __init__(self) -> None: + self._update_user_id_user_request_body = UpdateUserIdUserRequestBody() + + def new_user_id(self, new_user_id: str) -> "UpdateUserIdUserRequestBodyBuilder": + self._update_user_id_user_request_body.new_user_id = new_user_id + return self + + def build(self) -> "UpdateUserIdUserRequestBody": + return self._update_user_id_user_request_body diff --git a/lark_channel/api/contact/v3/model/update_user_id_user_response.py b/lark_channel/api/contact/v3/model/update_user_id_user_response.py new file mode 100644 index 0000000..141b186 --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_id_user_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class UpdateUserIdUserResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/update_user_request.py b/lark_channel/api/contact/v3/model/update_user_request.py new file mode 100644 index 0000000..f0cbc8e --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .user import User + + +class UpdateUserRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.department_id_type: Optional[str] = None + self.user_id: Optional[str] = None + self.request_body: Optional[User] = None + + @staticmethod + def builder() -> "UpdateUserRequestBuilder": + return UpdateUserRequestBuilder() + + +class UpdateUserRequestBuilder(object): + + def __init__(self) -> None: + update_user_request = UpdateUserRequest() + update_user_request.http_method = HttpMethod.PUT + update_user_request.uri = "/open-apis/contact/v3/users/:user_id" + update_user_request.token_types = {AccessTokenType.TENANT} + self._update_user_request: UpdateUserRequest = update_user_request + + def user_id_type(self, user_id_type: str) -> "UpdateUserRequestBuilder": + self._update_user_request.user_id_type = user_id_type + self._update_user_request.add_query("user_id_type", user_id_type) + return self + + def department_id_type(self, department_id_type: str) -> "UpdateUserRequestBuilder": + self._update_user_request.department_id_type = department_id_type + self._update_user_request.add_query("department_id_type", department_id_type) + return self + + def user_id(self, user_id: str) -> "UpdateUserRequestBuilder": + self._update_user_request.user_id = user_id + self._update_user_request.paths["user_id"] = str(user_id) + return self + + def request_body(self, request_body: User) -> "UpdateUserRequestBuilder": + self._update_user_request.request_body = request_body + self._update_user_request.body = request_body + return self + + def build(self) -> UpdateUserRequest: + return self._update_user_request diff --git a/lark_channel/api/contact/v3/model/update_user_response.py b/lark_channel/api/contact/v3/model/update_user_response.py new file mode 100644 index 0000000..a123262 --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .update_user_response_body import UpdateUserResponseBody + + +class UpdateUserResponse(BaseResponse): + _types = { + "data": UpdateUserResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[UpdateUserResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/contact/v3/model/update_user_response_body.py b/lark_channel/api/contact/v3/model/update_user_response_body.py new file mode 100644 index 0000000..0662752 --- /dev/null +++ b/lark_channel/api/contact/v3/model/update_user_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user import User + + +class UpdateUserResponseBody(object): + _types = { + "user": User, + } + + def __init__(self, d=None): + self.user: Optional[User] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateUserResponseBodyBuilder": + return UpdateUserResponseBodyBuilder() + + +class UpdateUserResponseBodyBuilder(object): + def __init__(self) -> None: + self._update_user_response_body = UpdateUserResponseBody() + + def user(self, user: User) -> "UpdateUserResponseBodyBuilder": + self._update_user_response_body.user = user + return self + + def build(self) -> "UpdateUserResponseBody": + return self._update_user_response_body diff --git a/lark_channel/api/contact/v3/model/user.py b/lark_channel/api/contact/v3/model/user.py new file mode 100644 index 0000000..82c9cce --- /dev/null +++ b/lark_channel/api/contact/v3/model/user.py @@ -0,0 +1,270 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .avatar_info import AvatarInfo +from .user_status import UserStatus +from .user_position import UserPosition +from .user_order import UserOrder +from .user_custom_attr import UserCustomAttr +from .notification_option import NotificationOption +from .user_assign_info import UserAssignInfo +from .department_detail import DepartmentDetail + + +class User(object): + _types = { + "union_id": str, + "user_id": str, + "open_id": str, + "name": str, + "en_name": str, + "nickname": str, + "email": str, + "mobile": str, + "mobile_visible": bool, + "gender": int, + "avatar_key": str, + "avatar": AvatarInfo, + "status": UserStatus, + "department_ids": List[str], + "leader_user_id": str, + "city": str, + "country": str, + "work_station": str, + "join_time": int, + "is_tenant_manager": bool, + "employee_no": str, + "employee_type": int, + "positions": List[UserPosition], + "orders": List[UserOrder], + "custom_attrs": List[UserCustomAttr], + "enterprise_email": str, + "idp_type": str, + "time_zone": str, + "description": str, + "job_title": str, + "need_send_notification": bool, + "notification_option": NotificationOption, + "is_frozen": bool, + "geo": str, + "job_level_id": str, + "job_family_id": str, + "subscription_ids": List[int], + "assign_info": List[UserAssignInfo], + "department_path": List[DepartmentDetail], + "dotted_line_leader_user_ids": List[int], + } + + def __init__(self, d=None): + self.union_id: Optional[str] = None + self.user_id: Optional[str] = None + self.open_id: Optional[str] = None + self.name: Optional[str] = None + self.en_name: Optional[str] = None + self.nickname: Optional[str] = None + self.email: Optional[str] = None + self.mobile: Optional[str] = None + self.mobile_visible: Optional[bool] = None + self.gender: Optional[int] = None + self.avatar_key: Optional[str] = None + self.avatar: Optional[AvatarInfo] = None + self.status: Optional[UserStatus] = None + self.department_ids: Optional[List[str]] = None + self.leader_user_id: Optional[str] = None + self.city: Optional[str] = None + self.country: Optional[str] = None + self.work_station: Optional[str] = None + self.join_time: Optional[int] = None + self.is_tenant_manager: Optional[bool] = None + self.employee_no: Optional[str] = None + self.employee_type: Optional[int] = None + self.positions: Optional[List[UserPosition]] = None + self.orders: Optional[List[UserOrder]] = None + self.custom_attrs: Optional[List[UserCustomAttr]] = None + self.enterprise_email: Optional[str] = None + self.idp_type: Optional[str] = None + self.time_zone: Optional[str] = None + self.description: Optional[str] = None + self.job_title: Optional[str] = None + self.need_send_notification: Optional[bool] = None + self.notification_option: Optional[NotificationOption] = None + self.is_frozen: Optional[bool] = None + self.geo: Optional[str] = None + self.job_level_id: Optional[str] = None + self.job_family_id: Optional[str] = None + self.subscription_ids: Optional[List[int]] = None + self.assign_info: Optional[List[UserAssignInfo]] = None + self.department_path: Optional[List[DepartmentDetail]] = None + self.dotted_line_leader_user_ids: Optional[List[int]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserBuilder": + return UserBuilder() + + +class UserBuilder(object): + def __init__(self) -> None: + self._user = User() + + def union_id(self, union_id: str) -> "UserBuilder": + self._user.union_id = union_id + return self + + def user_id(self, user_id: str) -> "UserBuilder": + self._user.user_id = user_id + return self + + def open_id(self, open_id: str) -> "UserBuilder": + self._user.open_id = open_id + return self + + def name(self, name: str) -> "UserBuilder": + self._user.name = name + return self + + def en_name(self, en_name: str) -> "UserBuilder": + self._user.en_name = en_name + return self + + def nickname(self, nickname: str) -> "UserBuilder": + self._user.nickname = nickname + return self + + def email(self, email: str) -> "UserBuilder": + self._user.email = email + return self + + def mobile(self, mobile: str) -> "UserBuilder": + self._user.mobile = mobile + return self + + def mobile_visible(self, mobile_visible: bool) -> "UserBuilder": + self._user.mobile_visible = mobile_visible + return self + + def gender(self, gender: int) -> "UserBuilder": + self._user.gender = gender + return self + + def avatar_key(self, avatar_key: str) -> "UserBuilder": + self._user.avatar_key = avatar_key + return self + + def avatar(self, avatar: AvatarInfo) -> "UserBuilder": + self._user.avatar = avatar + return self + + def status(self, status: UserStatus) -> "UserBuilder": + self._user.status = status + return self + + def department_ids(self, department_ids: List[str]) -> "UserBuilder": + self._user.department_ids = department_ids + return self + + def leader_user_id(self, leader_user_id: str) -> "UserBuilder": + self._user.leader_user_id = leader_user_id + return self + + def city(self, city: str) -> "UserBuilder": + self._user.city = city + return self + + def country(self, country: str) -> "UserBuilder": + self._user.country = country + return self + + def work_station(self, work_station: str) -> "UserBuilder": + self._user.work_station = work_station + return self + + def join_time(self, join_time: int) -> "UserBuilder": + self._user.join_time = join_time + return self + + def is_tenant_manager(self, is_tenant_manager: bool) -> "UserBuilder": + self._user.is_tenant_manager = is_tenant_manager + return self + + def employee_no(self, employee_no: str) -> "UserBuilder": + self._user.employee_no = employee_no + return self + + def employee_type(self, employee_type: int) -> "UserBuilder": + self._user.employee_type = employee_type + return self + + def positions(self, positions: List[UserPosition]) -> "UserBuilder": + self._user.positions = positions + return self + + def orders(self, orders: List[UserOrder]) -> "UserBuilder": + self._user.orders = orders + return self + + def custom_attrs(self, custom_attrs: List[UserCustomAttr]) -> "UserBuilder": + self._user.custom_attrs = custom_attrs + return self + + def enterprise_email(self, enterprise_email: str) -> "UserBuilder": + self._user.enterprise_email = enterprise_email + return self + + def idp_type(self, idp_type: str) -> "UserBuilder": + self._user.idp_type = idp_type + return self + + def time_zone(self, time_zone: str) -> "UserBuilder": + self._user.time_zone = time_zone + return self + + def description(self, description: str) -> "UserBuilder": + self._user.description = description + return self + + def job_title(self, job_title: str) -> "UserBuilder": + self._user.job_title = job_title + return self + + def need_send_notification(self, need_send_notification: bool) -> "UserBuilder": + self._user.need_send_notification = need_send_notification + return self + + def notification_option(self, notification_option: NotificationOption) -> "UserBuilder": + self._user.notification_option = notification_option + return self + + def is_frozen(self, is_frozen: bool) -> "UserBuilder": + self._user.is_frozen = is_frozen + return self + + def geo(self, geo: str) -> "UserBuilder": + self._user.geo = geo + return self + + def job_level_id(self, job_level_id: str) -> "UserBuilder": + self._user.job_level_id = job_level_id + return self + + def job_family_id(self, job_family_id: str) -> "UserBuilder": + self._user.job_family_id = job_family_id + return self + + def subscription_ids(self, subscription_ids: List[int]) -> "UserBuilder": + self._user.subscription_ids = subscription_ids + return self + + def assign_info(self, assign_info: List[UserAssignInfo]) -> "UserBuilder": + self._user.assign_info = assign_info + return self + + def department_path(self, department_path: List[DepartmentDetail]) -> "UserBuilder": + self._user.department_path = department_path + return self + + def dotted_line_leader_user_ids(self, dotted_line_leader_user_ids: List[int]) -> "UserBuilder": + self._user.dotted_line_leader_user_ids = dotted_line_leader_user_ids + return self + + def build(self) -> "User": + return self._user diff --git a/lark_channel/api/contact/v3/model/user_assign_info.py b/lark_channel/api/contact/v3/model/user_assign_info.py new file mode 100644 index 0000000..a289559 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_assign_info.py @@ -0,0 +1,59 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .product_i18n_name import ProductI18nName + + +class UserAssignInfo(object): + _types = { + "subscription_id": int, + "license_plan_key": str, + "product_name": str, + "i18n_name": ProductI18nName, + "start_time": int, + "end_time": int, + } + + def __init__(self, d=None): + self.subscription_id: Optional[int] = None + self.license_plan_key: Optional[str] = None + self.product_name: Optional[str] = None + self.i18n_name: Optional[ProductI18nName] = None + self.start_time: Optional[int] = None + self.end_time: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserAssignInfoBuilder": + return UserAssignInfoBuilder() + + +class UserAssignInfoBuilder(object): + def __init__(self) -> None: + self._user_assign_info = UserAssignInfo() + + def subscription_id(self, subscription_id: int) -> "UserAssignInfoBuilder": + self._user_assign_info.subscription_id = subscription_id + return self + + def license_plan_key(self, license_plan_key: str) -> "UserAssignInfoBuilder": + self._user_assign_info.license_plan_key = license_plan_key + return self + + def product_name(self, product_name: str) -> "UserAssignInfoBuilder": + self._user_assign_info.product_name = product_name + return self + + def i18n_name(self, i18n_name: ProductI18nName) -> "UserAssignInfoBuilder": + self._user_assign_info.i18n_name = i18n_name + return self + + def start_time(self, start_time: int) -> "UserAssignInfoBuilder": + self._user_assign_info.start_time = start_time + return self + + def end_time(self, end_time: int) -> "UserAssignInfoBuilder": + self._user_assign_info.end_time = end_time + return self + + def build(self) -> "UserAssignInfo": + return self._user_assign_info diff --git a/lark_channel/api/contact/v3/model/user_contact_info.py b/lark_channel/api/contact/v3/model/user_contact_info.py new file mode 100644 index 0000000..fe26a14 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_contact_info.py @@ -0,0 +1,47 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_status import UserStatus + + +class UserContactInfo(object): + _types = { + "user_id": str, + "mobile": str, + "email": str, + "status": UserStatus, + } + + def __init__(self, d=None): + self.user_id: Optional[str] = None + self.mobile: Optional[str] = None + self.email: Optional[str] = None + self.status: Optional[UserStatus] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserContactInfoBuilder": + return UserContactInfoBuilder() + + +class UserContactInfoBuilder(object): + def __init__(self) -> None: + self._user_contact_info = UserContactInfo() + + def user_id(self, user_id: str) -> "UserContactInfoBuilder": + self._user_contact_info.user_id = user_id + return self + + def mobile(self, mobile: str) -> "UserContactInfoBuilder": + self._user_contact_info.mobile = mobile + return self + + def email(self, email: str) -> "UserContactInfoBuilder": + self._user_contact_info.email = email + return self + + def status(self, status: UserStatus) -> "UserContactInfoBuilder": + self._user_contact_info.status = status + return self + + def build(self) -> "UserContactInfo": + return self._user_contact_info diff --git a/lark_channel/api/contact/v3/model/user_custom_attr.py b/lark_channel/api/contact/v3/model/user_custom_attr.py new file mode 100644 index 0000000..bdd21e0 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_custom_attr.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_custom_attr_value import UserCustomAttrValue + + +class UserCustomAttr(object): + _types = { + "type": str, + "id": str, + "value": UserCustomAttrValue, + } + + def __init__(self, d=None): + self.type: Optional[str] = None + self.id: Optional[str] = None + self.value: Optional[UserCustomAttrValue] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserCustomAttrBuilder": + return UserCustomAttrBuilder() + + +class UserCustomAttrBuilder(object): + def __init__(self) -> None: + self._user_custom_attr = UserCustomAttr() + + def type(self, type: str) -> "UserCustomAttrBuilder": + self._user_custom_attr.type = type + return self + + def id(self, id: str) -> "UserCustomAttrBuilder": + self._user_custom_attr.id = id + return self + + def value(self, value: UserCustomAttrValue) -> "UserCustomAttrBuilder": + self._user_custom_attr.value = value + return self + + def build(self) -> "UserCustomAttr": + return self._user_custom_attr diff --git a/lark_channel/api/contact/v3/model/user_custom_attr_value.py b/lark_channel/api/contact/v3/model/user_custom_attr_value.py new file mode 100644 index 0000000..ccc2ae9 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_custom_attr_value.py @@ -0,0 +1,71 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .custom_attr_generic_user import CustomAttrGenericUser + + +class UserCustomAttrValue(object): + _types = { + "text": str, + "url": str, + "pc_url": str, + "option_id": str, + "option_value": str, + "name": str, + "picture_url": str, + "generic_user": CustomAttrGenericUser, + } + + def __init__(self, d=None): + self.text: Optional[str] = None + self.url: Optional[str] = None + self.pc_url: Optional[str] = None + self.option_id: Optional[str] = None + self.option_value: Optional[str] = None + self.name: Optional[str] = None + self.picture_url: Optional[str] = None + self.generic_user: Optional[CustomAttrGenericUser] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserCustomAttrValueBuilder": + return UserCustomAttrValueBuilder() + + +class UserCustomAttrValueBuilder(object): + def __init__(self) -> None: + self._user_custom_attr_value = UserCustomAttrValue() + + def text(self, text: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.text = text + return self + + def url(self, url: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.url = url + return self + + def pc_url(self, pc_url: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.pc_url = pc_url + return self + + def option_id(self, option_id: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.option_id = option_id + return self + + def option_value(self, option_value: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.option_value = option_value + return self + + def name(self, name: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.name = name + return self + + def picture_url(self, picture_url: str) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.picture_url = picture_url + return self + + def generic_user(self, generic_user: CustomAttrGenericUser) -> "UserCustomAttrValueBuilder": + self._user_custom_attr_value.generic_user = generic_user + return self + + def build(self) -> "UserCustomAttrValue": + return self._user_custom_attr_value diff --git a/lark_channel/api/contact/v3/model/user_department_info.py b/lark_channel/api/contact/v3/model/user_department_info.py new file mode 100644 index 0000000..5ee472c --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_department_info.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UserDepartmentInfo(object): + _types = { + "department_id": int, + "user_order": int, + "department_order": int, + } + + def __init__(self, d=None): + self.department_id: Optional[int] = None + self.user_order: Optional[int] = None + self.department_order: Optional[int] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserDepartmentInfoBuilder": + return UserDepartmentInfoBuilder() + + +class UserDepartmentInfoBuilder(object): + def __init__(self) -> None: + self._user_department_info = UserDepartmentInfo() + + def department_id(self, department_id: int) -> "UserDepartmentInfoBuilder": + self._user_department_info.department_id = department_id + return self + + def user_order(self, user_order: int) -> "UserDepartmentInfoBuilder": + self._user_department_info.user_order = user_order + return self + + def department_order(self, department_order: int) -> "UserDepartmentInfoBuilder": + self._user_department_info.department_order = department_order + return self + + def build(self) -> "UserDepartmentInfo": + return self._user_department_info diff --git a/lark_channel/api/contact/v3/model/user_order.py b/lark_channel/api/contact/v3/model/user_order.py new file mode 100644 index 0000000..63cb165 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_order.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UserOrder(object): + _types = { + "department_id": str, + "user_order": int, + "department_order": int, + "is_primary_dept": bool, + } + + def __init__(self, d=None): + self.department_id: Optional[str] = None + self.user_order: Optional[int] = None + self.department_order: Optional[int] = None + self.is_primary_dept: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserOrderBuilder": + return UserOrderBuilder() + + +class UserOrderBuilder(object): + def __init__(self) -> None: + self._user_order = UserOrder() + + def department_id(self, department_id: str) -> "UserOrderBuilder": + self._user_order.department_id = department_id + return self + + def user_order(self, user_order: int) -> "UserOrderBuilder": + self._user_order.user_order = user_order + return self + + def department_order(self, department_order: int) -> "UserOrderBuilder": + self._user_order.department_order = department_order + return self + + def is_primary_dept(self, is_primary_dept: bool) -> "UserOrderBuilder": + self._user_order.is_primary_dept = is_primary_dept + return self + + def build(self) -> "UserOrder": + return self._user_order diff --git a/lark_channel/api/contact/v3/model/user_position.py b/lark_channel/api/contact/v3/model/user_position.py new file mode 100644 index 0000000..d641b26 --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_position.py @@ -0,0 +1,58 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UserPosition(object): + _types = { + "position_code": str, + "position_name": str, + "department_id": str, + "leader_user_id": str, + "leader_position_code": str, + "is_major": bool, + } + + def __init__(self, d=None): + self.position_code: Optional[str] = None + self.position_name: Optional[str] = None + self.department_id: Optional[str] = None + self.leader_user_id: Optional[str] = None + self.leader_position_code: Optional[str] = None + self.is_major: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserPositionBuilder": + return UserPositionBuilder() + + +class UserPositionBuilder(object): + def __init__(self) -> None: + self._user_position = UserPosition() + + def position_code(self, position_code: str) -> "UserPositionBuilder": + self._user_position.position_code = position_code + return self + + def position_name(self, position_name: str) -> "UserPositionBuilder": + self._user_position.position_name = position_name + return self + + def department_id(self, department_id: str) -> "UserPositionBuilder": + self._user_position.department_id = department_id + return self + + def leader_user_id(self, leader_user_id: str) -> "UserPositionBuilder": + self._user_position.leader_user_id = leader_user_id + return self + + def leader_position_code(self, leader_position_code: str) -> "UserPositionBuilder": + self._user_position.leader_position_code = leader_position_code + return self + + def is_major(self, is_major: bool) -> "UserPositionBuilder": + self._user_position.is_major = is_major + return self + + def build(self) -> "UserPosition": + return self._user_position diff --git a/lark_channel/api/contact/v3/model/user_status.py b/lark_channel/api/contact/v3/model/user_status.py new file mode 100644 index 0000000..2bf7f5a --- /dev/null +++ b/lark_channel/api/contact/v3/model/user_status.py @@ -0,0 +1,52 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UserStatus(object): + _types = { + "is_frozen": bool, + "is_resigned": bool, + "is_activated": bool, + "is_exited": bool, + "is_unjoin": bool, + } + + def __init__(self, d=None): + self.is_frozen: Optional[bool] = None + self.is_resigned: Optional[bool] = None + self.is_activated: Optional[bool] = None + self.is_exited: Optional[bool] = None + self.is_unjoin: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserStatusBuilder": + return UserStatusBuilder() + + +class UserStatusBuilder(object): + def __init__(self) -> None: + self._user_status = UserStatus() + + def is_frozen(self, is_frozen: bool) -> "UserStatusBuilder": + self._user_status.is_frozen = is_frozen + return self + + def is_resigned(self, is_resigned: bool) -> "UserStatusBuilder": + self._user_status.is_resigned = is_resigned + return self + + def is_activated(self, is_activated: bool) -> "UserStatusBuilder": + self._user_status.is_activated = is_activated + return self + + def is_exited(self, is_exited: bool) -> "UserStatusBuilder": + self._user_status.is_exited = is_exited + return self + + def is_unjoin(self, is_unjoin: bool) -> "UserStatusBuilder": + self._user_status.is_unjoin = is_unjoin + return self + + def build(self) -> "UserStatus": + return self._user_status diff --git a/lark_channel/api/contact/v3/resource/__init__.py b/lark_channel/api/contact/v3/resource/__init__.py new file mode 100644 index 0000000..35b01f3 --- /dev/null +++ b/lark_channel/api/contact/v3/resource/__init__.py @@ -0,0 +1,3 @@ +from .user import User + +__all__ = ["User"] diff --git a/lark_channel/api/contact/v3/resource/user.py b/lark_channel/api/contact/v3/resource/user.py new file mode 100644 index 0000000..b9b2cce --- /dev/null +++ b/lark_channel/api/contact/v3/resource/user.py @@ -0,0 +1,439 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.batch_user_request import BatchUserRequest +from ..model.batch_user_response import BatchUserResponse +from ..model.batch_get_id_user_request import BatchGetIdUserRequest +from ..model.batch_get_id_user_response import BatchGetIdUserResponse +from ..model.create_user_request import CreateUserRequest +from ..model.create_user_response import CreateUserResponse +from ..model.delete_user_request import DeleteUserRequest +from ..model.delete_user_response import DeleteUserResponse +from ..model.find_by_department_user_request import FindByDepartmentUserRequest +from ..model.find_by_department_user_response import FindByDepartmentUserResponse +from ..model.get_user_request import GetUserRequest +from ..model.get_user_response import GetUserResponse +from ..model.list_user_request import ListUserRequest +from ..model.list_user_response import ListUserResponse +from ..model.patch_user_request import PatchUserRequest +from ..model.patch_user_response import PatchUserResponse +from ..model.resurrect_user_request import ResurrectUserRequest +from ..model.resurrect_user_response import ResurrectUserResponse +from ..model.update_user_request import UpdateUserRequest +from ..model.update_user_response import UpdateUserResponse +from ..model.update_user_id_user_request import UpdateUserIdUserRequest +from ..model.update_user_id_user_response import UpdateUserIdUserResponse + + +class User(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def batch(self, request: BatchUserRequest, option: Optional[RequestOption] = None) -> BatchUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: BatchUserResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchUserResponse) + response.raw = resp + + return response + + async def abatch(self, request: BatchUserRequest, option: Optional[RequestOption] = None) -> BatchUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: BatchUserResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchUserResponse) + response.raw = resp + + return response + + def batch_get_id(self, request: BatchGetIdUserRequest, + option: Optional[RequestOption] = None) -> BatchGetIdUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: BatchGetIdUserResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchGetIdUserResponse) + response.raw = resp + + return response + + async def abatch_get_id(self, request: BatchGetIdUserRequest, + option: Optional[RequestOption] = None) -> BatchGetIdUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: BatchGetIdUserResponse = JSON.unmarshal(str(resp.content, UTF_8), BatchGetIdUserResponse) + response.raw = resp + + return response + + def create(self, request: CreateUserRequest, option: Optional[RequestOption] = None) -> CreateUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateUserResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateUserResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateUserRequest, option: Optional[RequestOption] = None) -> CreateUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateUserResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateUserResponse) + response.raw = resp + + return response + + def delete(self, request: DeleteUserRequest, option: Optional[RequestOption] = None) -> DeleteUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: DeleteUserResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteUserResponse) + response.raw = resp + + return response + + async def adelete(self, request: DeleteUserRequest, option: Optional[RequestOption] = None) -> DeleteUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: DeleteUserResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteUserResponse) + response.raw = resp + + return response + + def find_by_department(self, request: FindByDepartmentUserRequest, + option: Optional[RequestOption] = None) -> FindByDepartmentUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: FindByDepartmentUserResponse = JSON.unmarshal(str(resp.content, UTF_8), FindByDepartmentUserResponse) + response.raw = resp + + return response + + async def afind_by_department(self, request: FindByDepartmentUserRequest, + option: Optional[RequestOption] = None) -> FindByDepartmentUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: FindByDepartmentUserResponse = JSON.unmarshal(str(resp.content, UTF_8), FindByDepartmentUserResponse) + response.raw = resp + + return response + + def get(self, request: GetUserRequest, option: Optional[RequestOption] = None) -> GetUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: GetUserResponse = JSON.unmarshal(str(resp.content, UTF_8), GetUserResponse) + response.raw = resp + + return response + + async def aget(self, request: GetUserRequest, option: Optional[RequestOption] = None) -> GetUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: GetUserResponse = JSON.unmarshal(str(resp.content, UTF_8), GetUserResponse) + response.raw = resp + + return response + + def list(self, request: ListUserRequest, option: Optional[RequestOption] = None) -> ListUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ListUserResponse = JSON.unmarshal(str(resp.content, UTF_8), ListUserResponse) + response.raw = resp + + return response + + async def alist(self, request: ListUserRequest, option: Optional[RequestOption] = None) -> ListUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ListUserResponse = JSON.unmarshal(str(resp.content, UTF_8), ListUserResponse) + response.raw = resp + + return response + + def patch(self, request: PatchUserRequest, option: Optional[RequestOption] = None) -> PatchUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: PatchUserResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchUserResponse) + response.raw = resp + + return response + + async def apatch(self, request: PatchUserRequest, option: Optional[RequestOption] = None) -> PatchUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: PatchUserResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchUserResponse) + response.raw = resp + + return response + + def resurrect(self, request: ResurrectUserRequest, option: Optional[RequestOption] = None) -> ResurrectUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ResurrectUserResponse = JSON.unmarshal(str(resp.content, UTF_8), ResurrectUserResponse) + response.raw = resp + + return response + + async def aresurrect(self, request: ResurrectUserRequest, + option: Optional[RequestOption] = None) -> ResurrectUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ResurrectUserResponse = JSON.unmarshal(str(resp.content, UTF_8), ResurrectUserResponse) + response.raw = resp + + return response + + def update(self, request: UpdateUserRequest, option: Optional[RequestOption] = None) -> UpdateUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateUserResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateUserResponse) + response.raw = resp + + return response + + async def aupdate(self, request: UpdateUserRequest, option: Optional[RequestOption] = None) -> UpdateUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateUserResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateUserResponse) + response.raw = resp + + return response + + def update_user_id(self, request: UpdateUserIdUserRequest, + option: Optional[RequestOption] = None) -> UpdateUserIdUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateUserIdUserResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateUserIdUserResponse) + response.raw = resp + + return response + + async def aupdate_user_id(self, request: UpdateUserIdUserRequest, + option: Optional[RequestOption] = None) -> UpdateUserIdUserResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateUserIdUserResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateUserIdUserResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/contact/v3/version.py b/lark_channel/api/contact/v3/version.py new file mode 100644 index 0000000..7d5e667 --- /dev/null +++ b/lark_channel/api/contact/v3/version.py @@ -0,0 +1,8 @@ +from lark_channel.core.model import Config + +from .resource import User + + +class V3(object): + def __init__(self, config: Config) -> None: + self.user: User = User(config) diff --git a/lark_channel/api/drive/__init__.py b/lark_channel/api/drive/__init__.py new file mode 100644 index 0000000..0b12163 --- /dev/null +++ b/lark_channel/api/drive/__init__.py @@ -0,0 +1 @@ +"""Minimal Drive primitives required by lark_channel.""" diff --git a/lark_channel/api/drive/comment.py b/lark_channel/api/drive/comment.py new file mode 100644 index 0000000..eb027ac --- /dev/null +++ b/lark_channel/api/drive/comment.py @@ -0,0 +1,97 @@ +from lark_channel.core.enum import AccessTokenType, HttpMethod +from lark_channel.core.model import BaseRequest + + +def _request(method: HttpMethod, uri: str) -> BaseRequest: + req = BaseRequest() + req.http_method = method + req.uri = uri + req.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + return req + + +def _reply_content(content: str): + return { + "elements": [ + { + "type": "text_run", + "text_run": {"text": content}, + } + ], + } + + +def build_comment_get_request(*, file_token: str, file_type: str, comment_id: str) -> BaseRequest: + req = _request(HttpMethod.GET, "/open-apis/drive/v1/files/:file_token/comments/:comment_id") + req.paths["file_token"] = file_token + req.paths["comment_id"] = comment_id + req.add_query("file_type", file_type) + return req + + +def build_comment_list_request( + *, + file_token: str, + file_type: str, + page_token=None, + page_size=None, + is_whole=None, + is_solved=None, +) -> BaseRequest: + req = _request(HttpMethod.GET, "/open-apis/drive/v1/files/:file_token/comments") + req.paths["file_token"] = file_token + req.add_query("file_type", file_type) + if page_token is not None: + req.add_query("page_token", page_token) + if page_size is not None: + req.add_query("page_size", page_size) + if is_whole is not None: + req.add_query("is_whole", is_whole) + if is_solved is not None: + req.add_query("is_solved", is_solved) + return req + + +def build_comment_reply_list_request( + *, + file_token: str, + file_type: str, + comment_id: str, + page_token=None, + page_size=None, +) -> BaseRequest: + req = _request( + HttpMethod.GET, + "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies", + ) + req.paths["file_token"] = file_token + req.paths["comment_id"] = comment_id + req.add_query("file_type", file_type) + if page_token is not None: + req.add_query("page_token", page_token) + if page_size is not None: + req.add_query("page_size", page_size) + return req + + +def build_comment_create_request(*, file_token: str, file_type: str, content: str) -> BaseRequest: + req = _request(HttpMethod.POST, "/open-apis/drive/v1/files/:file_token/comments") + req.paths["file_token"] = file_token + req.add_query("file_type", file_type) + req.body = {"reply_list": {"replies": [{"content": _reply_content(content)}]}} + return req + + +def build_comment_reply_update_request( + *, file_token: str, file_type: str, comment_id: str, reply_id: str, content: str +) -> BaseRequest: + req = _request( + HttpMethod.PUT, + "/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id", + ) + req.paths["file_token"] = file_token + req.paths["comment_id"] = comment_id + req.paths["reply_id"] = reply_id + req.add_query("file_type", file_type) + req.body = {"content": _reply_content(content)} + return req diff --git a/lark_channel/api/im/__init__.py b/lark_channel/api/im/__init__.py new file mode 100644 index 0000000..326cdf4 --- /dev/null +++ b/lark_channel/api/im/__init__.py @@ -0,0 +1 @@ +from . import v1 diff --git a/lark_channel/api/im/service.py b/lark_channel/api/im/service.py new file mode 100644 index 0000000..a90adc4 --- /dev/null +++ b/lark_channel/api/im/service.py @@ -0,0 +1,7 @@ +from .v1.version import V1 +from lark_channel.core.model import Config + + +class ImService(object): + def __init__(self, config: Config) -> None: + self.v1: V1 = V1(config) diff --git a/lark_channel/api/im/v1/__init__.py b/lark_channel/api/im/v1/__init__.py new file mode 100644 index 0000000..bced8e1 --- /dev/null +++ b/lark_channel/api/im/v1/__init__.py @@ -0,0 +1,5 @@ +"""Minimal IM v1 resources required by lark_channel.""" + +from .version import V1 + +__all__ = ["V1"] diff --git a/lark_channel/api/im/v1/model/__init__.py b/lark_channel/api/im/v1/model/__init__.py new file mode 100644 index 0000000..e65733f --- /dev/null +++ b/lark_channel/api/im/v1/model/__init__.py @@ -0,0 +1 @@ +"""IM model submodules are imported explicitly by lark_channel.""" diff --git a/lark_channel/api/im/v1/model/chat_change.py b/lark_channel/api/im/v1/model/chat_change.py new file mode 100644 index 0000000..965fa60 --- /dev/null +++ b/lark_channel/api/im/v1/model/chat_change.py @@ -0,0 +1,121 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_names import I18nNames +from .user_id import UserId +from .restricted_mode_setting import RestrictedModeSetting + + +class ChatChange(object): + _types = { + "avatar": str, + "name": str, + "description": str, + "i18n_names": I18nNames, + "add_member_permission": str, + "share_card_permission": str, + "at_all_permission": str, + "edit_permission": str, + "membership_approval": str, + "join_message_visibility": str, + "leave_message_visibility": str, + "moderation_permission": str, + "owner_id": UserId, + "labels": List[str], + "restricted_mode_setting": RestrictedModeSetting, + "group_message_type": str, + } + + def __init__(self, d=None): + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + self.add_member_permission: Optional[str] = None + self.share_card_permission: Optional[str] = None + self.at_all_permission: Optional[str] = None + self.edit_permission: Optional[str] = None + self.membership_approval: Optional[str] = None + self.join_message_visibility: Optional[str] = None + self.leave_message_visibility: Optional[str] = None + self.moderation_permission: Optional[str] = None + self.owner_id: Optional[UserId] = None + self.labels: Optional[List[str]] = None + self.restricted_mode_setting: Optional[RestrictedModeSetting] = None + self.group_message_type: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ChatChangeBuilder": + return ChatChangeBuilder() + + +class ChatChangeBuilder(object): + def __init__(self) -> None: + self._chat_change = ChatChange() + + def avatar(self, avatar: str) -> "ChatChangeBuilder": + self._chat_change.avatar = avatar + return self + + def name(self, name: str) -> "ChatChangeBuilder": + self._chat_change.name = name + return self + + def description(self, description: str) -> "ChatChangeBuilder": + self._chat_change.description = description + return self + + def i18n_names(self, i18n_names: I18nNames) -> "ChatChangeBuilder": + self._chat_change.i18n_names = i18n_names + return self + + def add_member_permission(self, add_member_permission: str) -> "ChatChangeBuilder": + self._chat_change.add_member_permission = add_member_permission + return self + + def share_card_permission(self, share_card_permission: str) -> "ChatChangeBuilder": + self._chat_change.share_card_permission = share_card_permission + return self + + def at_all_permission(self, at_all_permission: str) -> "ChatChangeBuilder": + self._chat_change.at_all_permission = at_all_permission + return self + + def edit_permission(self, edit_permission: str) -> "ChatChangeBuilder": + self._chat_change.edit_permission = edit_permission + return self + + def membership_approval(self, membership_approval: str) -> "ChatChangeBuilder": + self._chat_change.membership_approval = membership_approval + return self + + def join_message_visibility(self, join_message_visibility: str) -> "ChatChangeBuilder": + self._chat_change.join_message_visibility = join_message_visibility + return self + + def leave_message_visibility(self, leave_message_visibility: str) -> "ChatChangeBuilder": + self._chat_change.leave_message_visibility = leave_message_visibility + return self + + def moderation_permission(self, moderation_permission: str) -> "ChatChangeBuilder": + self._chat_change.moderation_permission = moderation_permission + return self + + def owner_id(self, owner_id: UserId) -> "ChatChangeBuilder": + self._chat_change.owner_id = owner_id + return self + + def labels(self, labels: List[str]) -> "ChatChangeBuilder": + self._chat_change.labels = labels + return self + + def restricted_mode_setting(self, restricted_mode_setting: RestrictedModeSetting) -> "ChatChangeBuilder": + self._chat_change.restricted_mode_setting = restricted_mode_setting + return self + + def group_message_type(self, group_message_type: str) -> "ChatChangeBuilder": + self._chat_change.group_message_type = group_message_type + return self + + def build(self) -> "ChatChange": + return self._chat_change diff --git a/lark_channel/api/im/v1/model/chat_member_user.py b/lark_channel/api/im/v1/model/chat_member_user.py new file mode 100644 index 0000000..83e4c2e --- /dev/null +++ b/lark_channel/api/im/v1/model/chat_member_user.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_id import UserId + + +class ChatMemberUser(object): + _types = { + "name": str, + "tenant_key": str, + "user_id": UserId, + } + + def __init__(self, d=None): + self.name: Optional[str] = None + self.tenant_key: Optional[str] = None + self.user_id: Optional[UserId] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ChatMemberUserBuilder": + return ChatMemberUserBuilder() + + +class ChatMemberUserBuilder(object): + def __init__(self) -> None: + self._chat_member_user = ChatMemberUser() + + def name(self, name: str) -> "ChatMemberUserBuilder": + self._chat_member_user.name = name + return self + + def tenant_key(self, tenant_key: str) -> "ChatMemberUserBuilder": + self._chat_member_user.tenant_key = tenant_key + return self + + def user_id(self, user_id: UserId) -> "ChatMemberUserBuilder": + self._chat_member_user.user_id = user_id + return self + + def build(self) -> "ChatMemberUser": + return self._chat_member_user diff --git a/lark_channel/api/im/v1/model/create_chat_request.py b/lark_channel/api/im/v1/model/create_chat_request.py new file mode 100644 index 0000000..eba1368 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_chat_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_chat_request_body import CreateChatRequestBody + + +class CreateChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.set_bot_manager: Optional[bool] = None + self.uuid: Optional[str] = None + self.request_body: Optional[CreateChatRequestBody] = None + + @staticmethod + def builder() -> "CreateChatRequestBuilder": + return CreateChatRequestBuilder() + + +class CreateChatRequestBuilder(object): + + def __init__(self) -> None: + create_chat_request = CreateChatRequest() + create_chat_request.http_method = HttpMethod.POST + create_chat_request.uri = "/open-apis/im/v1/chats" + create_chat_request.token_types = {AccessTokenType.TENANT} + self._create_chat_request: CreateChatRequest = create_chat_request + + def user_id_type(self, user_id_type: str) -> "CreateChatRequestBuilder": + self._create_chat_request.user_id_type = user_id_type + self._create_chat_request.add_query("user_id_type", user_id_type) + return self + + def set_bot_manager(self, set_bot_manager: bool) -> "CreateChatRequestBuilder": + self._create_chat_request.set_bot_manager = set_bot_manager + self._create_chat_request.add_query("set_bot_manager", set_bot_manager) + return self + + def uuid(self, uuid: str) -> "CreateChatRequestBuilder": + self._create_chat_request.uuid = uuid + self._create_chat_request.add_query("uuid", uuid) + return self + + def request_body(self, request_body: CreateChatRequestBody) -> "CreateChatRequestBuilder": + self._create_chat_request.request_body = request_body + self._create_chat_request.body = request_body + return self + + def build(self) -> CreateChatRequest: + return self._create_chat_request diff --git a/lark_channel/api/im/v1/model/create_chat_request_body.py b/lark_channel/api/im/v1/model/create_chat_request_body.py new file mode 100644 index 0000000..f55fe7e --- /dev/null +++ b/lark_channel/api/im/v1/model/create_chat_request_body.py @@ -0,0 +1,162 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_names import I18nNames +from .restricted_mode_setting import RestrictedModeSetting + + +class CreateChatRequestBody(object): + _types = { + "avatar": str, + "name": str, + "description": str, + "i18n_names": I18nNames, + "owner_id": str, + "user_id_list": List[str], + "bot_id_list": List[str], + "group_message_type": str, + "chat_mode": str, + "chat_type": str, + "external": bool, + "join_message_visibility": str, + "leave_message_visibility": str, + "membership_approval": str, + "labels": List[str], + "toolkit_ids": List[int], + "restricted_mode_setting": RestrictedModeSetting, + "urgent_setting": str, + "video_conference_setting": str, + "edit_permission": str, + "chat_tags": List[str], + "pin_manage_setting": str, + "hide_member_count_setting": str, + } + + def __init__(self, d=None): + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + self.owner_id: Optional[str] = None + self.user_id_list: Optional[List[str]] = None + self.bot_id_list: Optional[List[str]] = None + self.group_message_type: Optional[str] = None + self.chat_mode: Optional[str] = None + self.chat_type: Optional[str] = None + self.external: Optional[bool] = None + self.join_message_visibility: Optional[str] = None + self.leave_message_visibility: Optional[str] = None + self.membership_approval: Optional[str] = None + self.labels: Optional[List[str]] = None + self.toolkit_ids: Optional[List[int]] = None + self.restricted_mode_setting: Optional[RestrictedModeSetting] = None + self.urgent_setting: Optional[str] = None + self.video_conference_setting: Optional[str] = None + self.edit_permission: Optional[str] = None + self.chat_tags: Optional[List[str]] = None + self.pin_manage_setting: Optional[str] = None + self.hide_member_count_setting: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateChatRequestBodyBuilder": + return CreateChatRequestBodyBuilder() + + +class CreateChatRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_chat_request_body = CreateChatRequestBody() + + def avatar(self, avatar: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.avatar = avatar + return self + + def name(self, name: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.name = name + return self + + def description(self, description: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.description = description + return self + + def i18n_names(self, i18n_names: I18nNames) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.i18n_names = i18n_names + return self + + def owner_id(self, owner_id: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.owner_id = owner_id + return self + + def user_id_list(self, user_id_list: List[str]) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.user_id_list = user_id_list + return self + + def bot_id_list(self, bot_id_list: List[str]) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.bot_id_list = bot_id_list + return self + + def group_message_type(self, group_message_type: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.group_message_type = group_message_type + return self + + def chat_mode(self, chat_mode: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.chat_mode = chat_mode + return self + + def chat_type(self, chat_type: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.chat_type = chat_type + return self + + def external(self, external: bool) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.external = external + return self + + def join_message_visibility(self, join_message_visibility: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.join_message_visibility = join_message_visibility + return self + + def leave_message_visibility(self, leave_message_visibility: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.leave_message_visibility = leave_message_visibility + return self + + def membership_approval(self, membership_approval: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.membership_approval = membership_approval + return self + + def labels(self, labels: List[str]) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.labels = labels + return self + + def toolkit_ids(self, toolkit_ids: List[int]) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.toolkit_ids = toolkit_ids + return self + + def restricted_mode_setting(self, restricted_mode_setting: RestrictedModeSetting) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.restricted_mode_setting = restricted_mode_setting + return self + + def urgent_setting(self, urgent_setting: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.urgent_setting = urgent_setting + return self + + def video_conference_setting(self, video_conference_setting: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.video_conference_setting = video_conference_setting + return self + + def edit_permission(self, edit_permission: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.edit_permission = edit_permission + return self + + def chat_tags(self, chat_tags: List[str]) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.chat_tags = chat_tags + return self + + def pin_manage_setting(self, pin_manage_setting: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.pin_manage_setting = pin_manage_setting + return self + + def hide_member_count_setting(self, hide_member_count_setting: str) -> "CreateChatRequestBodyBuilder": + self._create_chat_request_body.hide_member_count_setting = hide_member_count_setting + return self + + def build(self) -> "CreateChatRequestBody": + return self._create_chat_request_body diff --git a/lark_channel/api/im/v1/model/create_chat_response.py b/lark_channel/api/im/v1/model/create_chat_response.py new file mode 100644 index 0000000..7bc8411 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_chat_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_chat_response_body import CreateChatResponseBody + + +class CreateChatResponse(BaseResponse): + _types = { + "data": CreateChatResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateChatResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/create_chat_response_body.py b/lark_channel/api/im/v1/model/create_chat_response_body.py new file mode 100644 index 0000000..42be20d --- /dev/null +++ b/lark_channel/api/im/v1/model/create_chat_response_body.py @@ -0,0 +1,193 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_names import I18nNames +from .restricted_mode_setting import RestrictedModeSetting + + +class CreateChatResponseBody(object): + _types = { + "chat_id": str, + "avatar": str, + "name": str, + "description": str, + "i18n_names": I18nNames, + "owner_id": str, + "owner_id_type": str, + "urgent_setting": str, + "video_conference_setting": str, + "pin_manage_setting": str, + "add_member_permission": str, + "share_card_permission": str, + "at_all_permission": str, + "edit_permission": str, + "group_message_type": str, + "chat_mode": str, + "chat_type": str, + "chat_tag": str, + "external": bool, + "tenant_key": str, + "join_message_visibility": str, + "leave_message_visibility": str, + "membership_approval": str, + "moderation_permission": str, + "labels": List[str], + "toolkit_ids": List[int], + "restricted_mode_setting": RestrictedModeSetting, + "hide_member_count_setting": str, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + self.owner_id: Optional[str] = None + self.owner_id_type: Optional[str] = None + self.urgent_setting: Optional[str] = None + self.video_conference_setting: Optional[str] = None + self.pin_manage_setting: Optional[str] = None + self.add_member_permission: Optional[str] = None + self.share_card_permission: Optional[str] = None + self.at_all_permission: Optional[str] = None + self.edit_permission: Optional[str] = None + self.group_message_type: Optional[str] = None + self.chat_mode: Optional[str] = None + self.chat_type: Optional[str] = None + self.chat_tag: Optional[str] = None + self.external: Optional[bool] = None + self.tenant_key: Optional[str] = None + self.join_message_visibility: Optional[str] = None + self.leave_message_visibility: Optional[str] = None + self.membership_approval: Optional[str] = None + self.moderation_permission: Optional[str] = None + self.labels: Optional[List[str]] = None + self.toolkit_ids: Optional[List[int]] = None + self.restricted_mode_setting: Optional[RestrictedModeSetting] = None + self.hide_member_count_setting: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateChatResponseBodyBuilder": + return CreateChatResponseBodyBuilder() + + +class CreateChatResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_chat_response_body = CreateChatResponseBody() + + def chat_id(self, chat_id: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.chat_id = chat_id + return self + + def avatar(self, avatar: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.avatar = avatar + return self + + def name(self, name: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.name = name + return self + + def description(self, description: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.description = description + return self + + def i18n_names(self, i18n_names: I18nNames) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.i18n_names = i18n_names + return self + + def owner_id(self, owner_id: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.owner_id = owner_id + return self + + def owner_id_type(self, owner_id_type: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.owner_id_type = owner_id_type + return self + + def urgent_setting(self, urgent_setting: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.urgent_setting = urgent_setting + return self + + def video_conference_setting(self, video_conference_setting: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.video_conference_setting = video_conference_setting + return self + + def pin_manage_setting(self, pin_manage_setting: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.pin_manage_setting = pin_manage_setting + return self + + def add_member_permission(self, add_member_permission: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.add_member_permission = add_member_permission + return self + + def share_card_permission(self, share_card_permission: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.share_card_permission = share_card_permission + return self + + def at_all_permission(self, at_all_permission: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.at_all_permission = at_all_permission + return self + + def edit_permission(self, edit_permission: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.edit_permission = edit_permission + return self + + def group_message_type(self, group_message_type: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.group_message_type = group_message_type + return self + + def chat_mode(self, chat_mode: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.chat_mode = chat_mode + return self + + def chat_type(self, chat_type: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.chat_type = chat_type + return self + + def chat_tag(self, chat_tag: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.chat_tag = chat_tag + return self + + def external(self, external: bool) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.external = external + return self + + def tenant_key(self, tenant_key: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.tenant_key = tenant_key + return self + + def join_message_visibility(self, join_message_visibility: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.join_message_visibility = join_message_visibility + return self + + def leave_message_visibility(self, leave_message_visibility: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.leave_message_visibility = leave_message_visibility + return self + + def membership_approval(self, membership_approval: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.membership_approval = membership_approval + return self + + def moderation_permission(self, moderation_permission: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.moderation_permission = moderation_permission + return self + + def labels(self, labels: List[str]) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.labels = labels + return self + + def toolkit_ids(self, toolkit_ids: List[int]) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.toolkit_ids = toolkit_ids + return self + + def restricted_mode_setting(self, + restricted_mode_setting: RestrictedModeSetting) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.restricted_mode_setting = restricted_mode_setting + return self + + def hide_member_count_setting(self, hide_member_count_setting: str) -> "CreateChatResponseBodyBuilder": + self._create_chat_response_body.hide_member_count_setting = hide_member_count_setting + return self + + def build(self) -> "CreateChatResponseBody": + return self._create_chat_response_body diff --git a/lark_channel/api/im/v1/model/create_file_request.py b/lark_channel/api/im/v1/model/create_file_request.py new file mode 100644 index 0000000..aa82ff8 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_file_request.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_file_request_body import CreateFileRequestBody + + +class CreateFileRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.request_body: Optional[CreateFileRequestBody] = None + + @staticmethod + def builder() -> "CreateFileRequestBuilder": + return CreateFileRequestBuilder() + + +class CreateFileRequestBuilder(object): + + def __init__(self) -> None: + create_file_request = CreateFileRequest() + create_file_request.http_method = HttpMethod.POST + create_file_request.uri = "/open-apis/im/v1/files" + create_file_request.token_types = {AccessTokenType.TENANT} + self._create_file_request: CreateFileRequest = create_file_request + + def request_body(self, request_body: CreateFileRequestBody) -> "CreateFileRequestBuilder": + self._create_file_request.request_body = request_body + self._create_file_request.body = request_body + return self + + def build(self) -> CreateFileRequest: + return self._create_file_request diff --git a/lark_channel/api/im/v1/model/create_file_request_body.py b/lark_channel/api/im/v1/model/create_file_request_body.py new file mode 100644 index 0000000..776a46f --- /dev/null +++ b/lark_channel/api/im/v1/model/create_file_request_body.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateFileRequestBody(object): + _types = { + "file_type": str, + "file_name": str, + "duration": int, + "file": IO[Any], + } + + def __init__(self, d=None): + self.file_type: Optional[str] = None + self.file_name: Optional[str] = None + self.duration: Optional[int] = None + self.file: Optional[IO[Any]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateFileRequestBodyBuilder": + return CreateFileRequestBodyBuilder() + + +class CreateFileRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_file_request_body = CreateFileRequestBody() + + def file_type(self, file_type: str) -> "CreateFileRequestBodyBuilder": + self._create_file_request_body.file_type = file_type + return self + + def file_name(self, file_name: str) -> "CreateFileRequestBodyBuilder": + self._create_file_request_body.file_name = file_name + return self + + def duration(self, duration: int) -> "CreateFileRequestBodyBuilder": + self._create_file_request_body.duration = duration + return self + + def file(self, file: IO[Any]) -> "CreateFileRequestBodyBuilder": + self._create_file_request_body.file = file + return self + + def build(self) -> "CreateFileRequestBody": + return self._create_file_request_body diff --git a/lark_channel/api/im/v1/model/create_file_response.py b/lark_channel/api/im/v1/model/create_file_response.py new file mode 100644 index 0000000..d45868c --- /dev/null +++ b/lark_channel/api/im/v1/model/create_file_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_file_response_body import CreateFileResponseBody + + +class CreateFileResponse(BaseResponse): + _types = { + "data": CreateFileResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateFileResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/create_file_response_body.py b/lark_channel/api/im/v1/model/create_file_response_body.py new file mode 100644 index 0000000..6a0c189 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_file_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateFileResponseBody(object): + _types = { + "file_key": str, + } + + def __init__(self, d=None): + self.file_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateFileResponseBodyBuilder": + return CreateFileResponseBodyBuilder() + + +class CreateFileResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_file_response_body = CreateFileResponseBody() + + def file_key(self, file_key: str) -> "CreateFileResponseBodyBuilder": + self._create_file_response_body.file_key = file_key + return self + + def build(self) -> "CreateFileResponseBody": + return self._create_file_response_body diff --git a/lark_channel/api/im/v1/model/create_image_request.py b/lark_channel/api/im/v1/model/create_image_request.py new file mode 100644 index 0000000..66a8c54 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_image_request.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_image_request_body import CreateImageRequestBody + + +class CreateImageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.request_body: Optional[CreateImageRequestBody] = None + + @staticmethod + def builder() -> "CreateImageRequestBuilder": + return CreateImageRequestBuilder() + + +class CreateImageRequestBuilder(object): + + def __init__(self) -> None: + create_image_request = CreateImageRequest() + create_image_request.http_method = HttpMethod.POST + create_image_request.uri = "/open-apis/im/v1/images" + create_image_request.token_types = {AccessTokenType.TENANT} + self._create_image_request: CreateImageRequest = create_image_request + + def request_body(self, request_body: CreateImageRequestBody) -> "CreateImageRequestBuilder": + self._create_image_request.request_body = request_body + self._create_image_request.body = request_body + return self + + def build(self) -> CreateImageRequest: + return self._create_image_request diff --git a/lark_channel/api/im/v1/model/create_image_request_body.py b/lark_channel/api/im/v1/model/create_image_request_body.py new file mode 100644 index 0000000..17cfcfa --- /dev/null +++ b/lark_channel/api/im/v1/model/create_image_request_body.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateImageRequestBody(object): + _types = { + "image_type": str, + "image": IO[Any], + } + + def __init__(self, d=None): + self.image_type: Optional[str] = None + self.image: Optional[IO[Any]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateImageRequestBodyBuilder": + return CreateImageRequestBodyBuilder() + + +class CreateImageRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_image_request_body = CreateImageRequestBody() + + def image_type(self, image_type: str) -> "CreateImageRequestBodyBuilder": + self._create_image_request_body.image_type = image_type + return self + + def image(self, image: IO[Any]) -> "CreateImageRequestBodyBuilder": + self._create_image_request_body.image = image + return self + + def build(self) -> "CreateImageRequestBody": + return self._create_image_request_body diff --git a/lark_channel/api/im/v1/model/create_image_response.py b/lark_channel/api/im/v1/model/create_image_response.py new file mode 100644 index 0000000..67fc269 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_image_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_image_response_body import CreateImageResponseBody + + +class CreateImageResponse(BaseResponse): + _types = { + "data": CreateImageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateImageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/create_image_response_body.py b/lark_channel/api/im/v1/model/create_image_response_body.py new file mode 100644 index 0000000..ef163d8 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_image_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateImageResponseBody(object): + _types = { + "image_key": str, + } + + def __init__(self, d=None): + self.image_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateImageResponseBodyBuilder": + return CreateImageResponseBodyBuilder() + + +class CreateImageResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_image_response_body = CreateImageResponseBody() + + def image_key(self, image_key: str) -> "CreateImageResponseBodyBuilder": + self._create_image_response_body.image_key = image_key + return self + + def build(self) -> "CreateImageResponseBody": + return self._create_image_response_body diff --git a/lark_channel/api/im/v1/model/create_message_reaction_request.py b/lark_channel/api/im/v1/model/create_message_reaction_request.py new file mode 100644 index 0000000..81c992a --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_reaction_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_message_reaction_request_body import CreateMessageReactionRequestBody + + +class CreateMessageReactionRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.request_body: Optional[CreateMessageReactionRequestBody] = None + + @staticmethod + def builder() -> "CreateMessageReactionRequestBuilder": + return CreateMessageReactionRequestBuilder() + + +class CreateMessageReactionRequestBuilder(object): + + def __init__(self) -> None: + create_message_reaction_request = CreateMessageReactionRequest() + create_message_reaction_request.http_method = HttpMethod.POST + create_message_reaction_request.uri = "/open-apis/im/v1/messages/:message_id/reactions" + create_message_reaction_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._create_message_reaction_request: CreateMessageReactionRequest = create_message_reaction_request + + def message_id(self, message_id: str) -> "CreateMessageReactionRequestBuilder": + self._create_message_reaction_request.message_id = message_id + self._create_message_reaction_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: CreateMessageReactionRequestBody) -> "CreateMessageReactionRequestBuilder": + self._create_message_reaction_request.request_body = request_body + self._create_message_reaction_request.body = request_body + return self + + def build(self) -> CreateMessageReactionRequest: + return self._create_message_reaction_request diff --git a/lark_channel/api/im/v1/model/create_message_reaction_request_body.py b/lark_channel/api/im/v1/model/create_message_reaction_request_body.py new file mode 100644 index 0000000..ace04b8 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_reaction_request_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .emoji import Emoji + + +class CreateMessageReactionRequestBody(object): + _types = { + "reaction_type": Emoji, + } + + def __init__(self, d=None): + self.reaction_type: Optional[Emoji] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateMessageReactionRequestBodyBuilder": + return CreateMessageReactionRequestBodyBuilder() + + +class CreateMessageReactionRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_message_reaction_request_body = CreateMessageReactionRequestBody() + + def reaction_type(self, reaction_type: Emoji) -> "CreateMessageReactionRequestBodyBuilder": + self._create_message_reaction_request_body.reaction_type = reaction_type + return self + + def build(self) -> "CreateMessageReactionRequestBody": + return self._create_message_reaction_request_body diff --git a/lark_channel/api/im/v1/model/create_message_reaction_response.py b/lark_channel/api/im/v1/model/create_message_reaction_response.py new file mode 100644 index 0000000..6d88f6b --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_reaction_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_message_reaction_response_body import CreateMessageReactionResponseBody + + +class CreateMessageReactionResponse(BaseResponse): + _types = { + "data": CreateMessageReactionResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateMessageReactionResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/create_message_reaction_response_body.py b/lark_channel/api/im/v1/model/create_message_reaction_response_body.py new file mode 100644 index 0000000..c3aa017 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_reaction_response_body.py @@ -0,0 +1,48 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .operator import Operator +from .emoji import Emoji + + +class CreateMessageReactionResponseBody(object): + _types = { + "reaction_id": str, + "operator": Operator, + "action_time": int, + "reaction_type": Emoji, + } + + def __init__(self, d=None): + self.reaction_id: Optional[str] = None + self.operator: Optional[Operator] = None + self.action_time: Optional[int] = None + self.reaction_type: Optional[Emoji] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateMessageReactionResponseBodyBuilder": + return CreateMessageReactionResponseBodyBuilder() + + +class CreateMessageReactionResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_message_reaction_response_body = CreateMessageReactionResponseBody() + + def reaction_id(self, reaction_id: str) -> "CreateMessageReactionResponseBodyBuilder": + self._create_message_reaction_response_body.reaction_id = reaction_id + return self + + def operator(self, operator: Operator) -> "CreateMessageReactionResponseBodyBuilder": + self._create_message_reaction_response_body.operator = operator + return self + + def action_time(self, action_time: int) -> "CreateMessageReactionResponseBodyBuilder": + self._create_message_reaction_response_body.action_time = action_time + return self + + def reaction_type(self, reaction_type: Emoji) -> "CreateMessageReactionResponseBodyBuilder": + self._create_message_reaction_response_body.reaction_type = reaction_type + return self + + def build(self) -> "CreateMessageReactionResponseBody": + return self._create_message_reaction_response_body diff --git a/lark_channel/api/im/v1/model/create_message_request.py b/lark_channel/api/im/v1/model/create_message_request.py new file mode 100644 index 0000000..d266d19 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .create_message_request_body import CreateMessageRequestBody + + +class CreateMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.receive_id_type: Optional[str] = None + self.request_body: Optional[CreateMessageRequestBody] = None + + @staticmethod + def builder() -> "CreateMessageRequestBuilder": + return CreateMessageRequestBuilder() + + +class CreateMessageRequestBuilder(object): + + def __init__(self) -> None: + create_message_request = CreateMessageRequest() + create_message_request.http_method = HttpMethod.POST + create_message_request.uri = "/open-apis/im/v1/messages" + create_message_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._create_message_request: CreateMessageRequest = create_message_request + + def receive_id_type(self, receive_id_type: str) -> "CreateMessageRequestBuilder": + self._create_message_request.receive_id_type = receive_id_type + self._create_message_request.add_query("receive_id_type", receive_id_type) + return self + + def request_body(self, request_body: CreateMessageRequestBody) -> "CreateMessageRequestBuilder": + self._create_message_request.request_body = request_body + self._create_message_request.body = request_body + return self + + def build(self) -> CreateMessageRequest: + return self._create_message_request diff --git a/lark_channel/api/im/v1/model/create_message_request_body.py b/lark_channel/api/im/v1/model/create_message_request_body.py new file mode 100644 index 0000000..793d6dd --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_request_body.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class CreateMessageRequestBody(object): + _types = { + "receive_id": str, + "msg_type": str, + "content": str, + "uuid": str, + } + + def __init__(self, d=None): + self.receive_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.content: Optional[str] = None + self.uuid: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateMessageRequestBodyBuilder": + return CreateMessageRequestBodyBuilder() + + +class CreateMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._create_message_request_body = CreateMessageRequestBody() + + def receive_id(self, receive_id: str) -> "CreateMessageRequestBodyBuilder": + self._create_message_request_body.receive_id = receive_id + return self + + def msg_type(self, msg_type: str) -> "CreateMessageRequestBodyBuilder": + self._create_message_request_body.msg_type = msg_type + return self + + def content(self, content: str) -> "CreateMessageRequestBodyBuilder": + self._create_message_request_body.content = content + return self + + def uuid(self, uuid: str) -> "CreateMessageRequestBodyBuilder": + self._create_message_request_body.uuid = uuid + return self + + def build(self) -> "CreateMessageRequestBody": + return self._create_message_request_body diff --git a/lark_channel/api/im/v1/model/create_message_response.py b/lark_channel/api/im/v1/model/create_message_response.py new file mode 100644 index 0000000..6521da9 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .create_message_response_body import CreateMessageResponseBody + + +class CreateMessageResponse(BaseResponse): + _types = { + "data": CreateMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[CreateMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/create_message_response_body.py b/lark_channel/api/im/v1/model/create_message_response_body.py new file mode 100644 index 0000000..78b2a83 --- /dev/null +++ b/lark_channel/api/im/v1/model/create_message_response_body.py @@ -0,0 +1,109 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .sender import Sender +from .message_body import MessageBody +from .mention import Mention + + +class CreateMessageResponseBody(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "thread_id": str, + "msg_type": str, + "create_time": int, + "update_time": int, + "deleted": bool, + "updated": bool, + "chat_id": str, + "sender": Sender, + "body": MessageBody, + "mentions": List[Mention], + "upper_message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.deleted: Optional[bool] = None + self.updated: Optional[bool] = None + self.chat_id: Optional[str] = None + self.sender: Optional[Sender] = None + self.body: Optional[MessageBody] = None + self.mentions: Optional[List[Mention]] = None + self.upper_message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "CreateMessageResponseBodyBuilder": + return CreateMessageResponseBodyBuilder() + + +class CreateMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._create_message_response_body = CreateMessageResponseBody() + + def message_id(self, message_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.message_id = message_id + return self + + def root_id(self, root_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.parent_id = parent_id + return self + + def thread_id(self, thread_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.thread_id = thread_id + return self + + def msg_type(self, msg_type: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.msg_type = msg_type + return self + + def create_time(self, create_time: int) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.create_time = create_time + return self + + def update_time(self, update_time: int) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.update_time = update_time + return self + + def deleted(self, deleted: bool) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.deleted = deleted + return self + + def updated(self, updated: bool) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.updated = updated + return self + + def chat_id(self, chat_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.chat_id = chat_id + return self + + def sender(self, sender: Sender) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.sender = sender + return self + + def body(self, body: MessageBody) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.body = body + return self + + def mentions(self, mentions: List[Mention]) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.mentions = mentions + return self + + def upper_message_id(self, upper_message_id: str) -> "CreateMessageResponseBodyBuilder": + self._create_message_response_body.upper_message_id = upper_message_id + return self + + def build(self) -> "CreateMessageResponseBody": + return self._create_message_response_body diff --git a/lark_channel/api/im/v1/model/delete_chat_request.py b/lark_channel/api/im/v1/model/delete_chat_request.py new file mode 100644 index 0000000..bd2b8c1 --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_chat_request.py @@ -0,0 +1,31 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class DeleteChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.chat_id: Optional[str] = None + + @staticmethod + def builder() -> "DeleteChatRequestBuilder": + return DeleteChatRequestBuilder() + + +class DeleteChatRequestBuilder(object): + + def __init__(self) -> None: + delete_chat_request = DeleteChatRequest() + delete_chat_request.http_method = HttpMethod.DELETE + delete_chat_request.uri = "/open-apis/im/v1/chats/:chat_id" + delete_chat_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._delete_chat_request: DeleteChatRequest = delete_chat_request + + def chat_id(self, chat_id: str) -> "DeleteChatRequestBuilder": + self._delete_chat_request.chat_id = chat_id + self._delete_chat_request.paths["chat_id"] = str(chat_id) + return self + + def build(self) -> DeleteChatRequest: + return self._delete_chat_request diff --git a/lark_channel/api/im/v1/model/delete_chat_response.py b/lark_channel/api/im/v1/model/delete_chat_response.py new file mode 100644 index 0000000..f7495fe --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_chat_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class DeleteChatResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/delete_message_reaction_request.py b/lark_channel/api/im/v1/model/delete_message_reaction_request.py new file mode 100644 index 0000000..dcd262e --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_message_reaction_request.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class DeleteMessageReactionRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.reaction_id: Optional[str] = None + + @staticmethod + def builder() -> "DeleteMessageReactionRequestBuilder": + return DeleteMessageReactionRequestBuilder() + + +class DeleteMessageReactionRequestBuilder(object): + + def __init__(self) -> None: + delete_message_reaction_request = DeleteMessageReactionRequest() + delete_message_reaction_request.http_method = HttpMethod.DELETE + delete_message_reaction_request.uri = "/open-apis/im/v1/messages/:message_id/reactions/:reaction_id" + delete_message_reaction_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._delete_message_reaction_request: DeleteMessageReactionRequest = delete_message_reaction_request + + def message_id(self, message_id: str) -> "DeleteMessageReactionRequestBuilder": + self._delete_message_reaction_request.message_id = message_id + self._delete_message_reaction_request.paths["message_id"] = str(message_id) + return self + + def reaction_id(self, reaction_id: str) -> "DeleteMessageReactionRequestBuilder": + self._delete_message_reaction_request.reaction_id = reaction_id + self._delete_message_reaction_request.paths["reaction_id"] = str(reaction_id) + return self + + def build(self) -> DeleteMessageReactionRequest: + return self._delete_message_reaction_request diff --git a/lark_channel/api/im/v1/model/delete_message_reaction_response.py b/lark_channel/api/im/v1/model/delete_message_reaction_response.py new file mode 100644 index 0000000..4a405e4 --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_message_reaction_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .delete_message_reaction_response_body import DeleteMessageReactionResponseBody + + +class DeleteMessageReactionResponse(BaseResponse): + _types = { + "data": DeleteMessageReactionResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[DeleteMessageReactionResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/delete_message_reaction_response_body.py b/lark_channel/api/im/v1/model/delete_message_reaction_response_body.py new file mode 100644 index 0000000..a200605 --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_message_reaction_response_body.py @@ -0,0 +1,48 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .operator import Operator +from .emoji import Emoji + + +class DeleteMessageReactionResponseBody(object): + _types = { + "reaction_id": str, + "operator": Operator, + "action_time": int, + "reaction_type": Emoji, + } + + def __init__(self, d=None): + self.reaction_id: Optional[str] = None + self.operator: Optional[Operator] = None + self.action_time: Optional[int] = None + self.reaction_type: Optional[Emoji] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "DeleteMessageReactionResponseBodyBuilder": + return DeleteMessageReactionResponseBodyBuilder() + + +class DeleteMessageReactionResponseBodyBuilder(object): + def __init__(self) -> None: + self._delete_message_reaction_response_body = DeleteMessageReactionResponseBody() + + def reaction_id(self, reaction_id: str) -> "DeleteMessageReactionResponseBodyBuilder": + self._delete_message_reaction_response_body.reaction_id = reaction_id + return self + + def operator(self, operator: Operator) -> "DeleteMessageReactionResponseBodyBuilder": + self._delete_message_reaction_response_body.operator = operator + return self + + def action_time(self, action_time: int) -> "DeleteMessageReactionResponseBodyBuilder": + self._delete_message_reaction_response_body.action_time = action_time + return self + + def reaction_type(self, reaction_type: Emoji) -> "DeleteMessageReactionResponseBodyBuilder": + self._delete_message_reaction_response_body.reaction_type = reaction_type + return self + + def build(self) -> "DeleteMessageReactionResponseBody": + return self._delete_message_reaction_response_body diff --git a/lark_channel/api/im/v1/model/delete_message_request.py b/lark_channel/api/im/v1/model/delete_message_request.py new file mode 100644 index 0000000..93c9fe2 --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_message_request.py @@ -0,0 +1,31 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class DeleteMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + + @staticmethod + def builder() -> "DeleteMessageRequestBuilder": + return DeleteMessageRequestBuilder() + + +class DeleteMessageRequestBuilder(object): + + def __init__(self) -> None: + delete_message_request = DeleteMessageRequest() + delete_message_request.http_method = HttpMethod.DELETE + delete_message_request.uri = "/open-apis/im/v1/messages/:message_id" + delete_message_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._delete_message_request: DeleteMessageRequest = delete_message_request + + def message_id(self, message_id: str) -> "DeleteMessageRequestBuilder": + self._delete_message_request.message_id = message_id + self._delete_message_request.paths["message_id"] = str(message_id) + return self + + def build(self) -> DeleteMessageRequest: + return self._delete_message_request diff --git a/lark_channel/api/im/v1/model/delete_message_response.py b/lark_channel/api/im/v1/model/delete_message_response.py new file mode 100644 index 0000000..a8d38e6 --- /dev/null +++ b/lark_channel/api/im/v1/model/delete_message_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class DeleteMessageResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/emoji.py b/lark_channel/api/im/v1/model/emoji.py new file mode 100644 index 0000000..4783db1 --- /dev/null +++ b/lark_channel/api/im/v1/model/emoji.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class Emoji(object): + _types = { + "emoji_type": str, + } + + def __init__(self, d=None): + self.emoji_type: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "EmojiBuilder": + return EmojiBuilder() + + +class EmojiBuilder(object): + def __init__(self) -> None: + self._emoji = Emoji() + + def emoji_type(self, emoji_type: str) -> "EmojiBuilder": + self._emoji.emoji_type = emoji_type + return self + + def build(self) -> "Emoji": + return self._emoji diff --git a/lark_channel/api/im/v1/model/event_message.py b/lark_channel/api/im/v1/model/event_message.py new file mode 100644 index 0000000..1d42a67 --- /dev/null +++ b/lark_channel/api/im/v1/model/event_message.py @@ -0,0 +1,95 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .mention_event import MentionEvent + + +class EventMessage(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "create_time": int, + "update_time": int, + "chat_id": str, + "thread_id": str, + "chat_type": str, + "message_type": str, + "content": str, + "mentions": List[MentionEvent], + "user_agent": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.chat_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.chat_type: Optional[str] = None + self.message_type: Optional[str] = None + self.content: Optional[str] = None + self.mentions: Optional[List[MentionEvent]] = None + self.user_agent: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "EventMessageBuilder": + return EventMessageBuilder() + + +class EventMessageBuilder(object): + def __init__(self) -> None: + self._event_message = EventMessage() + + def message_id(self, message_id: str) -> "EventMessageBuilder": + self._event_message.message_id = message_id + return self + + def root_id(self, root_id: str) -> "EventMessageBuilder": + self._event_message.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "EventMessageBuilder": + self._event_message.parent_id = parent_id + return self + + def create_time(self, create_time: int) -> "EventMessageBuilder": + self._event_message.create_time = create_time + return self + + def update_time(self, update_time: int) -> "EventMessageBuilder": + self._event_message.update_time = update_time + return self + + def chat_id(self, chat_id: str) -> "EventMessageBuilder": + self._event_message.chat_id = chat_id + return self + + def thread_id(self, thread_id: str) -> "EventMessageBuilder": + self._event_message.thread_id = thread_id + return self + + def chat_type(self, chat_type: str) -> "EventMessageBuilder": + self._event_message.chat_type = chat_type + return self + + def message_type(self, message_type: str) -> "EventMessageBuilder": + self._event_message.message_type = message_type + return self + + def content(self, content: str) -> "EventMessageBuilder": + self._event_message.content = content + return self + + def mentions(self, mentions: List[MentionEvent]) -> "EventMessageBuilder": + self._event_message.mentions = mentions + return self + + def user_agent(self, user_agent: str) -> "EventMessageBuilder": + self._event_message.user_agent = user_agent + return self + + def build(self) -> "EventMessage": + return self._event_message diff --git a/lark_channel/api/im/v1/model/event_message_reader.py b/lark_channel/api/im/v1/model/event_message_reader.py new file mode 100644 index 0000000..1e7ad0a --- /dev/null +++ b/lark_channel/api/im/v1/model/event_message_reader.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_id import UserId + + +class EventMessageReader(object): + _types = { + "reader_id": UserId, + "read_time": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.reader_id: Optional[UserId] = None + self.read_time: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "EventMessageReaderBuilder": + return EventMessageReaderBuilder() + + +class EventMessageReaderBuilder(object): + def __init__(self) -> None: + self._event_message_reader = EventMessageReader() + + def reader_id(self, reader_id: UserId) -> "EventMessageReaderBuilder": + self._event_message_reader.reader_id = reader_id + return self + + def read_time(self, read_time: str) -> "EventMessageReaderBuilder": + self._event_message_reader.read_time = read_time + return self + + def tenant_key(self, tenant_key: str) -> "EventMessageReaderBuilder": + self._event_message_reader.tenant_key = tenant_key + return self + + def build(self) -> "EventMessageReader": + return self._event_message_reader diff --git a/lark_channel/api/im/v1/model/event_sender.py b/lark_channel/api/im/v1/model/event_sender.py new file mode 100644 index 0000000..c58b1db --- /dev/null +++ b/lark_channel/api/im/v1/model/event_sender.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_id import UserId + + +class EventSender(object): + _types = { + "sender_id": UserId, + "sender_type": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.sender_id: Optional[UserId] = None + self.sender_type: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "EventSenderBuilder": + return EventSenderBuilder() + + +class EventSenderBuilder(object): + def __init__(self) -> None: + self._event_sender = EventSender() + + def sender_id(self, sender_id: UserId) -> "EventSenderBuilder": + self._event_sender.sender_id = sender_id + return self + + def sender_type(self, sender_type: str) -> "EventSenderBuilder": + self._event_sender.sender_type = sender_type + return self + + def tenant_key(self, tenant_key: str) -> "EventSenderBuilder": + self._event_sender.tenant_key = tenant_key + return self + + def build(self) -> "EventSender": + return self._event_sender diff --git a/lark_channel/api/im/v1/model/follow_up.py b/lark_channel/api/im/v1/model/follow_up.py new file mode 100644 index 0000000..7e4b5ab --- /dev/null +++ b/lark_channel/api/im/v1/model/follow_up.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_content import I18nContent + + +class FollowUp(object): + _types = { + "content": str, + "i18n_contents": List[I18nContent], + } + + def __init__(self, d=None): + self.content: Optional[str] = None + self.i18n_contents: Optional[List[I18nContent]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "FollowUpBuilder": + return FollowUpBuilder() + + +class FollowUpBuilder(object): + def __init__(self) -> None: + self._follow_up = FollowUp() + + def content(self, content: str) -> "FollowUpBuilder": + self._follow_up.content = content + return self + + def i18n_contents(self, i18n_contents: List[I18nContent]) -> "FollowUpBuilder": + self._follow_up.i18n_contents = i18n_contents + return self + + def build(self) -> "FollowUp": + return self._follow_up diff --git a/lark_channel/api/im/v1/model/forward_message_request.py b/lark_channel/api/im/v1/model/forward_message_request.py new file mode 100644 index 0000000..45cbe3d --- /dev/null +++ b/lark_channel/api/im/v1/model/forward_message_request.py @@ -0,0 +1,50 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .forward_message_request_body import ForwardMessageRequestBody + + +class ForwardMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.receive_id_type: Optional[str] = None + self.uuid: Optional[str] = None + self.message_id: Optional[str] = None + self.request_body: Optional[ForwardMessageRequestBody] = None + + @staticmethod + def builder() -> "ForwardMessageRequestBuilder": + return ForwardMessageRequestBuilder() + + +class ForwardMessageRequestBuilder(object): + + def __init__(self) -> None: + forward_message_request = ForwardMessageRequest() + forward_message_request.http_method = HttpMethod.POST + forward_message_request.uri = "/open-apis/im/v1/messages/:message_id/forward" + forward_message_request.token_types = {AccessTokenType.TENANT} + self._forward_message_request: ForwardMessageRequest = forward_message_request + + def receive_id_type(self, receive_id_type: str) -> "ForwardMessageRequestBuilder": + self._forward_message_request.receive_id_type = receive_id_type + self._forward_message_request.add_query("receive_id_type", receive_id_type) + return self + + def uuid(self, uuid: str) -> "ForwardMessageRequestBuilder": + self._forward_message_request.uuid = uuid + self._forward_message_request.add_query("uuid", uuid) + return self + + def message_id(self, message_id: str) -> "ForwardMessageRequestBuilder": + self._forward_message_request.message_id = message_id + self._forward_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: ForwardMessageRequestBody) -> "ForwardMessageRequestBuilder": + self._forward_message_request.request_body = request_body + self._forward_message_request.body = request_body + return self + + def build(self) -> ForwardMessageRequest: + return self._forward_message_request diff --git a/lark_channel/api/im/v1/model/forward_message_request_body.py b/lark_channel/api/im/v1/model/forward_message_request_body.py new file mode 100644 index 0000000..f550b04 --- /dev/null +++ b/lark_channel/api/im/v1/model/forward_message_request_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ForwardMessageRequestBody(object): + _types = { + "receive_id": str, + } + + def __init__(self, d=None): + self.receive_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ForwardMessageRequestBodyBuilder": + return ForwardMessageRequestBodyBuilder() + + +class ForwardMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._forward_message_request_body = ForwardMessageRequestBody() + + def receive_id(self, receive_id: str) -> "ForwardMessageRequestBodyBuilder": + self._forward_message_request_body.receive_id = receive_id + return self + + def build(self) -> "ForwardMessageRequestBody": + return self._forward_message_request_body diff --git a/lark_channel/api/im/v1/model/forward_message_response.py b/lark_channel/api/im/v1/model/forward_message_response.py new file mode 100644 index 0000000..72b5a3f --- /dev/null +++ b/lark_channel/api/im/v1/model/forward_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .forward_message_response_body import ForwardMessageResponseBody + + +class ForwardMessageResponse(BaseResponse): + _types = { + "data": ForwardMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ForwardMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/forward_message_response_body.py b/lark_channel/api/im/v1/model/forward_message_response_body.py new file mode 100644 index 0000000..43971d1 --- /dev/null +++ b/lark_channel/api/im/v1/model/forward_message_response_body.py @@ -0,0 +1,109 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .sender import Sender +from .message_body import MessageBody +from .mention import Mention + + +class ForwardMessageResponseBody(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "thread_id": str, + "msg_type": str, + "create_time": int, + "update_time": int, + "deleted": bool, + "updated": bool, + "chat_id": str, + "sender": Sender, + "body": MessageBody, + "mentions": List[Mention], + "upper_message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.deleted: Optional[bool] = None + self.updated: Optional[bool] = None + self.chat_id: Optional[str] = None + self.sender: Optional[Sender] = None + self.body: Optional[MessageBody] = None + self.mentions: Optional[List[Mention]] = None + self.upper_message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ForwardMessageResponseBodyBuilder": + return ForwardMessageResponseBodyBuilder() + + +class ForwardMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._forward_message_response_body = ForwardMessageResponseBody() + + def message_id(self, message_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.message_id = message_id + return self + + def root_id(self, root_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.parent_id = parent_id + return self + + def thread_id(self, thread_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.thread_id = thread_id + return self + + def msg_type(self, msg_type: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.msg_type = msg_type + return self + + def create_time(self, create_time: int) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.create_time = create_time + return self + + def update_time(self, update_time: int) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.update_time = update_time + return self + + def deleted(self, deleted: bool) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.deleted = deleted + return self + + def updated(self, updated: bool) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.updated = updated + return self + + def chat_id(self, chat_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.chat_id = chat_id + return self + + def sender(self, sender: Sender) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.sender = sender + return self + + def body(self, body: MessageBody) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.body = body + return self + + def mentions(self, mentions: List[Mention]) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.mentions = mentions + return self + + def upper_message_id(self, upper_message_id: str) -> "ForwardMessageResponseBodyBuilder": + self._forward_message_response_body.upper_message_id = upper_message_id + return self + + def build(self) -> "ForwardMessageResponseBody": + return self._forward_message_response_body diff --git a/lark_channel/api/im/v1/model/get_chat_request.py b/lark_channel/api/im/v1/model/get_chat_request.py new file mode 100644 index 0000000..e4c394b --- /dev/null +++ b/lark_channel/api/im/v1/model/get_chat_request.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.chat_id: Optional[str] = None + + @staticmethod + def builder() -> "GetChatRequestBuilder": + return GetChatRequestBuilder() + + +class GetChatRequestBuilder(object): + + def __init__(self) -> None: + get_chat_request = GetChatRequest() + get_chat_request.http_method = HttpMethod.GET + get_chat_request.uri = "/open-apis/im/v1/chats/:chat_id" + get_chat_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._get_chat_request: GetChatRequest = get_chat_request + + def user_id_type(self, user_id_type: str) -> "GetChatRequestBuilder": + self._get_chat_request.user_id_type = user_id_type + self._get_chat_request.add_query("user_id_type", user_id_type) + return self + + def chat_id(self, chat_id: str) -> "GetChatRequestBuilder": + self._get_chat_request.chat_id = chat_id + self._get_chat_request.paths["chat_id"] = str(chat_id) + return self + + def build(self) -> GetChatRequest: + return self._get_chat_request diff --git a/lark_channel/api/im/v1/model/get_chat_response.py b/lark_channel/api/im/v1/model/get_chat_response.py new file mode 100644 index 0000000..00d800b --- /dev/null +++ b/lark_channel/api/im/v1/model/get_chat_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .get_chat_response_body import GetChatResponseBody + + +class GetChatResponse(BaseResponse): + _types = { + "data": GetChatResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[GetChatResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/get_chat_response_body.py b/lark_channel/api/im/v1/model/get_chat_response_body.py new file mode 100644 index 0000000..074a2f4 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_chat_response_body.py @@ -0,0 +1,216 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_names import I18nNames +from .restricted_mode_setting import RestrictedModeSetting + + +class GetChatResponseBody(object): + _types = { + "avatar": str, + "name": str, + "description": str, + "i18n_names": I18nNames, + "add_member_permission": str, + "share_card_permission": str, + "at_all_permission": str, + "edit_permission": str, + "owner_id_type": str, + "owner_id": str, + "user_manager_id_list": List[str], + "bot_manager_id_list": List[str], + "group_message_type": str, + "chat_mode": str, + "chat_type": str, + "chat_tag": str, + "join_message_visibility": str, + "leave_message_visibility": str, + "membership_approval": str, + "moderation_permission": str, + "external": bool, + "tenant_key": str, + "user_count": str, + "bot_count": str, + "labels": List[str], + "toolkit_ids": List[int], + "restricted_mode_setting": RestrictedModeSetting, + "urgent_setting": str, + "video_conference_setting": str, + "pin_manage_setting": str, + "hide_member_count_setting": str, + "chat_status": str, + } + + def __init__(self, d=None): + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + self.add_member_permission: Optional[str] = None + self.share_card_permission: Optional[str] = None + self.at_all_permission: Optional[str] = None + self.edit_permission: Optional[str] = None + self.owner_id_type: Optional[str] = None + self.owner_id: Optional[str] = None + self.user_manager_id_list: Optional[List[str]] = None + self.bot_manager_id_list: Optional[List[str]] = None + self.group_message_type: Optional[str] = None + self.chat_mode: Optional[str] = None + self.chat_type: Optional[str] = None + self.chat_tag: Optional[str] = None + self.join_message_visibility: Optional[str] = None + self.leave_message_visibility: Optional[str] = None + self.membership_approval: Optional[str] = None + self.moderation_permission: Optional[str] = None + self.external: Optional[bool] = None + self.tenant_key: Optional[str] = None + self.user_count: Optional[str] = None + self.bot_count: Optional[str] = None + self.labels: Optional[List[str]] = None + self.toolkit_ids: Optional[List[int]] = None + self.restricted_mode_setting: Optional[RestrictedModeSetting] = None + self.urgent_setting: Optional[str] = None + self.video_conference_setting: Optional[str] = None + self.pin_manage_setting: Optional[str] = None + self.hide_member_count_setting: Optional[str] = None + self.chat_status: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "GetChatResponseBodyBuilder": + return GetChatResponseBodyBuilder() + + +class GetChatResponseBodyBuilder(object): + def __init__(self) -> None: + self._get_chat_response_body = GetChatResponseBody() + + def avatar(self, avatar: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.avatar = avatar + return self + + def name(self, name: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.name = name + return self + + def description(self, description: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.description = description + return self + + def i18n_names(self, i18n_names: I18nNames) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.i18n_names = i18n_names + return self + + def add_member_permission(self, add_member_permission: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.add_member_permission = add_member_permission + return self + + def share_card_permission(self, share_card_permission: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.share_card_permission = share_card_permission + return self + + def at_all_permission(self, at_all_permission: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.at_all_permission = at_all_permission + return self + + def edit_permission(self, edit_permission: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.edit_permission = edit_permission + return self + + def owner_id_type(self, owner_id_type: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.owner_id_type = owner_id_type + return self + + def owner_id(self, owner_id: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.owner_id = owner_id + return self + + def user_manager_id_list(self, user_manager_id_list: List[str]) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.user_manager_id_list = user_manager_id_list + return self + + def bot_manager_id_list(self, bot_manager_id_list: List[str]) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.bot_manager_id_list = bot_manager_id_list + return self + + def group_message_type(self, group_message_type: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.group_message_type = group_message_type + return self + + def chat_mode(self, chat_mode: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.chat_mode = chat_mode + return self + + def chat_type(self, chat_type: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.chat_type = chat_type + return self + + def chat_tag(self, chat_tag: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.chat_tag = chat_tag + return self + + def join_message_visibility(self, join_message_visibility: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.join_message_visibility = join_message_visibility + return self + + def leave_message_visibility(self, leave_message_visibility: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.leave_message_visibility = leave_message_visibility + return self + + def membership_approval(self, membership_approval: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.membership_approval = membership_approval + return self + + def moderation_permission(self, moderation_permission: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.moderation_permission = moderation_permission + return self + + def external(self, external: bool) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.external = external + return self + + def tenant_key(self, tenant_key: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.tenant_key = tenant_key + return self + + def user_count(self, user_count: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.user_count = user_count + return self + + def bot_count(self, bot_count: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.bot_count = bot_count + return self + + def labels(self, labels: List[str]) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.labels = labels + return self + + def toolkit_ids(self, toolkit_ids: List[int]) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.toolkit_ids = toolkit_ids + return self + + def restricted_mode_setting(self, restricted_mode_setting: RestrictedModeSetting) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.restricted_mode_setting = restricted_mode_setting + return self + + def urgent_setting(self, urgent_setting: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.urgent_setting = urgent_setting + return self + + def video_conference_setting(self, video_conference_setting: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.video_conference_setting = video_conference_setting + return self + + def pin_manage_setting(self, pin_manage_setting: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.pin_manage_setting = pin_manage_setting + return self + + def hide_member_count_setting(self, hide_member_count_setting: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.hide_member_count_setting = hide_member_count_setting + return self + + def chat_status(self, chat_status: str) -> "GetChatResponseBodyBuilder": + self._get_chat_response_body.chat_status = chat_status + return self + + def build(self) -> "GetChatResponseBody": + return self._get_chat_response_body diff --git a/lark_channel/api/im/v1/model/get_file_request.py b/lark_channel/api/im/v1/model/get_file_request.py new file mode 100644 index 0000000..e2f833c --- /dev/null +++ b/lark_channel/api/im/v1/model/get_file_request.py @@ -0,0 +1,31 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetFileRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.file_key: Optional[str] = None + + @staticmethod + def builder() -> "GetFileRequestBuilder": + return GetFileRequestBuilder() + + +class GetFileRequestBuilder(object): + + def __init__(self) -> None: + get_file_request = GetFileRequest() + get_file_request.http_method = HttpMethod.GET + get_file_request.uri = "/open-apis/im/v1/files/:file_key" + get_file_request.token_types = {AccessTokenType.TENANT} + self._get_file_request: GetFileRequest = get_file_request + + def file_key(self, file_key: str) -> "GetFileRequestBuilder": + self._get_file_request.file_key = file_key + self._get_file_request.paths["file_key"] = str(file_key) + return self + + def build(self) -> GetFileRequest: + return self._get_file_request diff --git a/lark_channel/api/im/v1/model/get_file_response.py b/lark_channel/api/im/v1/model/get_file_response.py new file mode 100644 index 0000000..b67cea2 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_file_response.py @@ -0,0 +1,16 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class GetFileResponse(BaseResponse): + _types = { + "file": IO[Any], + "file_name": str, + } + + def __init__(self, d=None): + super().__init__(d) + self.file: Optional[IO[Any]] = None + self.file_name: Optional[str] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/get_image_request.py b/lark_channel/api/im/v1/model/get_image_request.py new file mode 100644 index 0000000..f868bee --- /dev/null +++ b/lark_channel/api/im/v1/model/get_image_request.py @@ -0,0 +1,31 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetImageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.image_key: Optional[str] = None + + @staticmethod + def builder() -> "GetImageRequestBuilder": + return GetImageRequestBuilder() + + +class GetImageRequestBuilder(object): + + def __init__(self) -> None: + get_image_request = GetImageRequest() + get_image_request.http_method = HttpMethod.GET + get_image_request.uri = "/open-apis/im/v1/images/:image_key" + get_image_request.token_types = {AccessTokenType.TENANT} + self._get_image_request: GetImageRequest = get_image_request + + def image_key(self, image_key: str) -> "GetImageRequestBuilder": + self._get_image_request.image_key = image_key + self._get_image_request.paths["image_key"] = str(image_key) + return self + + def build(self) -> GetImageRequest: + return self._get_image_request diff --git a/lark_channel/api/im/v1/model/get_image_response.py b/lark_channel/api/im/v1/model/get_image_response.py new file mode 100644 index 0000000..f550ae5 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_image_response.py @@ -0,0 +1,16 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class GetImageResponse(BaseResponse): + _types = { + "file": IO[Any], + "file_name": str, + } + + def __init__(self, d=None): + super().__init__(d) + self.file: Optional[IO[Any]] = None + self.file_name: Optional[str] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/get_message_request.py b/lark_channel/api/im/v1/model/get_message_request.py new file mode 100644 index 0000000..214b90d --- /dev/null +++ b/lark_channel/api/im/v1/model/get_message_request.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.message_id: Optional[str] = None + + @staticmethod + def builder() -> "GetMessageRequestBuilder": + return GetMessageRequestBuilder() + + +class GetMessageRequestBuilder(object): + + def __init__(self) -> None: + get_message_request = GetMessageRequest() + get_message_request.http_method = HttpMethod.GET + get_message_request.uri = "/open-apis/im/v1/messages/:message_id" + get_message_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._get_message_request: GetMessageRequest = get_message_request + + def user_id_type(self, user_id_type: str) -> "GetMessageRequestBuilder": + self._get_message_request.user_id_type = user_id_type + self._get_message_request.add_query("user_id_type", user_id_type) + return self + + def message_id(self, message_id: str) -> "GetMessageRequestBuilder": + self._get_message_request.message_id = message_id + self._get_message_request.paths["message_id"] = str(message_id) + return self + + def build(self) -> GetMessageRequest: + return self._get_message_request diff --git a/lark_channel/api/im/v1/model/get_message_resource_request.py b/lark_channel/api/im/v1/model/get_message_resource_request.py new file mode 100644 index 0000000..696d709 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_message_resource_request.py @@ -0,0 +1,43 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class GetMessageResourceRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.type: Optional[str] = None + self.message_id: Optional[str] = None + self.file_key: Optional[str] = None + + @staticmethod + def builder() -> "GetMessageResourceRequestBuilder": + return GetMessageResourceRequestBuilder() + + +class GetMessageResourceRequestBuilder(object): + + def __init__(self) -> None: + get_message_resource_request = GetMessageResourceRequest() + get_message_resource_request.http_method = HttpMethod.GET + get_message_resource_request.uri = "/open-apis/im/v1/messages/:message_id/resources/:file_key" + get_message_resource_request.token_types = {AccessTokenType.TENANT} + self._get_message_resource_request: GetMessageResourceRequest = get_message_resource_request + + def type(self, type: str) -> "GetMessageResourceRequestBuilder": + self._get_message_resource_request.type = type + self._get_message_resource_request.add_query("type", type) + return self + + def message_id(self, message_id: str) -> "GetMessageResourceRequestBuilder": + self._get_message_resource_request.message_id = message_id + self._get_message_resource_request.paths["message_id"] = str(message_id) + return self + + def file_key(self, file_key: str) -> "GetMessageResourceRequestBuilder": + self._get_message_resource_request.file_key = file_key + self._get_message_resource_request.paths["file_key"] = str(file_key) + return self + + def build(self) -> GetMessageResourceRequest: + return self._get_message_resource_request diff --git a/lark_channel/api/im/v1/model/get_message_resource_response.py b/lark_channel/api/im/v1/model/get_message_resource_response.py new file mode 100644 index 0000000..d1e5a39 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_message_resource_response.py @@ -0,0 +1,16 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class GetMessageResourceResponse(BaseResponse): + _types = { + "file": IO[Any], + "file_name": str, + } + + def __init__(self, d=None): + super().__init__(d) + self.file: Optional[IO[Any]] = None + self.file_name: Optional[str] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/get_message_response.py b/lark_channel/api/im/v1/model/get_message_response.py new file mode 100644 index 0000000..444fda2 --- /dev/null +++ b/lark_channel/api/im/v1/model/get_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .get_message_response_body import GetMessageResponseBody + + +class GetMessageResponse(BaseResponse): + _types = { + "data": GetMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[GetMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/get_message_response_body.py b/lark_channel/api/im/v1/model/get_message_response_body.py new file mode 100644 index 0000000..b77b48b --- /dev/null +++ b/lark_channel/api/im/v1/model/get_message_response_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .message import Message + + +class GetMessageResponseBody(object): + _types = { + "items": List[Message], + } + + def __init__(self, d=None): + self.items: Optional[List[Message]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "GetMessageResponseBodyBuilder": + return GetMessageResponseBodyBuilder() + + +class GetMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._get_message_response_body = GetMessageResponseBody() + + def items(self, items: List[Message]) -> "GetMessageResponseBodyBuilder": + self._get_message_response_body.items = items + return self + + def build(self) -> "GetMessageResponseBody": + return self._get_message_response_body diff --git a/lark_channel/api/im/v1/model/i18n_content.py b/lark_channel/api/im/v1/model/i18n_content.py new file mode 100644 index 0000000..5525c9c --- /dev/null +++ b/lark_channel/api/im/v1/model/i18n_content.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class I18nContent(object): + _types = { + "content": str, + "language": str, + } + + def __init__(self, d=None): + self.content: Optional[str] = None + self.language: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "I18nContentBuilder": + return I18nContentBuilder() + + +class I18nContentBuilder(object): + def __init__(self) -> None: + self._i18n_content = I18nContent() + + def content(self, content: str) -> "I18nContentBuilder": + self._i18n_content.content = content + return self + + def language(self, language: str) -> "I18nContentBuilder": + self._i18n_content.language = language + return self + + def build(self) -> "I18nContent": + return self._i18n_content diff --git a/lark_channel/api/im/v1/model/i18n_names.py b/lark_channel/api/im/v1/model/i18n_names.py new file mode 100644 index 0000000..3fb13a5 --- /dev/null +++ b/lark_channel/api/im/v1/model/i18n_names.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class I18nNames(object): + _types = { + "zh_cn": str, + "en_us": str, + "ja_jp": str, + } + + def __init__(self, d=None): + self.zh_cn: Optional[str] = None + self.en_us: Optional[str] = None + self.ja_jp: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "I18nNamesBuilder": + return I18nNamesBuilder() + + +class I18nNamesBuilder(object): + def __init__(self) -> None: + self._i18n_names = I18nNames() + + def zh_cn(self, zh_cn: str) -> "I18nNamesBuilder": + self._i18n_names.zh_cn = zh_cn + return self + + def en_us(self, en_us: str) -> "I18nNamesBuilder": + self._i18n_names.en_us = en_us + return self + + def ja_jp(self, ja_jp: str) -> "I18nNamesBuilder": + self._i18n_names.ja_jp = ja_jp + return self + + def build(self) -> "I18nNames": + return self._i18n_names diff --git a/lark_channel/api/im/v1/model/link_chat_request.py b/lark_channel/api/im/v1/model/link_chat_request.py new file mode 100644 index 0000000..0fa4194 --- /dev/null +++ b/lark_channel/api/im/v1/model/link_chat_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .link_chat_request_body import LinkChatRequestBody + + +class LinkChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.chat_id: Optional[str] = None + self.request_body: Optional[LinkChatRequestBody] = None + + @staticmethod + def builder() -> "LinkChatRequestBuilder": + return LinkChatRequestBuilder() + + +class LinkChatRequestBuilder(object): + + def __init__(self) -> None: + link_chat_request = LinkChatRequest() + link_chat_request.http_method = HttpMethod.POST + link_chat_request.uri = "/open-apis/im/v1/chats/:chat_id/link" + link_chat_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._link_chat_request: LinkChatRequest = link_chat_request + + def chat_id(self, chat_id: str) -> "LinkChatRequestBuilder": + self._link_chat_request.chat_id = chat_id + self._link_chat_request.paths["chat_id"] = str(chat_id) + return self + + def request_body(self, request_body: LinkChatRequestBody) -> "LinkChatRequestBuilder": + self._link_chat_request.request_body = request_body + self._link_chat_request.body = request_body + return self + + def build(self) -> LinkChatRequest: + return self._link_chat_request diff --git a/lark_channel/api/im/v1/model/link_chat_request_body.py b/lark_channel/api/im/v1/model/link_chat_request_body.py new file mode 100644 index 0000000..d100b91 --- /dev/null +++ b/lark_channel/api/im/v1/model/link_chat_request_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class LinkChatRequestBody(object): + _types = { + "validity_period": str, + } + + def __init__(self, d=None): + self.validity_period: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "LinkChatRequestBodyBuilder": + return LinkChatRequestBodyBuilder() + + +class LinkChatRequestBodyBuilder(object): + def __init__(self) -> None: + self._link_chat_request_body = LinkChatRequestBody() + + def validity_period(self, validity_period: str) -> "LinkChatRequestBodyBuilder": + self._link_chat_request_body.validity_period = validity_period + return self + + def build(self) -> "LinkChatRequestBody": + return self._link_chat_request_body diff --git a/lark_channel/api/im/v1/model/link_chat_response.py b/lark_channel/api/im/v1/model/link_chat_response.py new file mode 100644 index 0000000..f361dcc --- /dev/null +++ b/lark_channel/api/im/v1/model/link_chat_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .link_chat_response_body import LinkChatResponseBody + + +class LinkChatResponse(BaseResponse): + _types = { + "data": LinkChatResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[LinkChatResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/link_chat_response_body.py b/lark_channel/api/im/v1/model/link_chat_response_body.py new file mode 100644 index 0000000..310089c --- /dev/null +++ b/lark_channel/api/im/v1/model/link_chat_response_body.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class LinkChatResponseBody(object): + _types = { + "share_link": str, + "expire_time": str, + "is_permanent": bool, + } + + def __init__(self, d=None): + self.share_link: Optional[str] = None + self.expire_time: Optional[str] = None + self.is_permanent: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "LinkChatResponseBodyBuilder": + return LinkChatResponseBodyBuilder() + + +class LinkChatResponseBodyBuilder(object): + def __init__(self) -> None: + self._link_chat_response_body = LinkChatResponseBody() + + def share_link(self, share_link: str) -> "LinkChatResponseBodyBuilder": + self._link_chat_response_body.share_link = share_link + return self + + def expire_time(self, expire_time: str) -> "LinkChatResponseBodyBuilder": + self._link_chat_response_body.expire_time = expire_time + return self + + def is_permanent(self, is_permanent: bool) -> "LinkChatResponseBodyBuilder": + self._link_chat_response_body.is_permanent = is_permanent + return self + + def build(self) -> "LinkChatResponseBody": + return self._link_chat_response_body diff --git a/lark_channel/api/im/v1/model/list_chat.py b/lark_channel/api/im/v1/model/list_chat.py new file mode 100644 index 0000000..13139d9 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_chat.py @@ -0,0 +1,82 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ListChat(object): + _types = { + "chat_id": str, + "avatar": str, + "name": str, + "description": str, + "owner_id": str, + "owner_id_type": str, + "external": bool, + "tenant_key": str, + "labels": List[str], + "chat_status": str, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.owner_id: Optional[str] = None + self.owner_id_type: Optional[str] = None + self.external: Optional[bool] = None + self.tenant_key: Optional[str] = None + self.labels: Optional[List[str]] = None + self.chat_status: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListChatBuilder": + return ListChatBuilder() + + +class ListChatBuilder(object): + def __init__(self) -> None: + self._list_chat = ListChat() + + def chat_id(self, chat_id: str) -> "ListChatBuilder": + self._list_chat.chat_id = chat_id + return self + + def avatar(self, avatar: str) -> "ListChatBuilder": + self._list_chat.avatar = avatar + return self + + def name(self, name: str) -> "ListChatBuilder": + self._list_chat.name = name + return self + + def description(self, description: str) -> "ListChatBuilder": + self._list_chat.description = description + return self + + def owner_id(self, owner_id: str) -> "ListChatBuilder": + self._list_chat.owner_id = owner_id + return self + + def owner_id_type(self, owner_id_type: str) -> "ListChatBuilder": + self._list_chat.owner_id_type = owner_id_type + return self + + def external(self, external: bool) -> "ListChatBuilder": + self._list_chat.external = external + return self + + def tenant_key(self, tenant_key: str) -> "ListChatBuilder": + self._list_chat.tenant_key = tenant_key + return self + + def labels(self, labels: List[str]) -> "ListChatBuilder": + self._list_chat.labels = labels + return self + + def chat_status(self, chat_status: str) -> "ListChatBuilder": + self._list_chat.chat_status = chat_status + return self + + def build(self) -> "ListChat": + return self._list_chat diff --git a/lark_channel/api/im/v1/model/list_chat_request.py b/lark_channel/api/im/v1/model/list_chat_request.py new file mode 100644 index 0000000..bb524bc --- /dev/null +++ b/lark_channel/api/im/v1/model/list_chat_request.py @@ -0,0 +1,49 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class ListChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.sort_type: Optional[str] = None + self.page_token: Optional[str] = None + self.page_size: Optional[int] = None + + @staticmethod + def builder() -> "ListChatRequestBuilder": + return ListChatRequestBuilder() + + +class ListChatRequestBuilder(object): + + def __init__(self) -> None: + list_chat_request = ListChatRequest() + list_chat_request.http_method = HttpMethod.GET + list_chat_request.uri = "/open-apis/im/v1/chats" + list_chat_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._list_chat_request: ListChatRequest = list_chat_request + + def user_id_type(self, user_id_type: str) -> "ListChatRequestBuilder": + self._list_chat_request.user_id_type = user_id_type + self._list_chat_request.add_query("user_id_type", user_id_type) + return self + + def sort_type(self, sort_type: str) -> "ListChatRequestBuilder": + self._list_chat_request.sort_type = sort_type + self._list_chat_request.add_query("sort_type", sort_type) + return self + + def page_token(self, page_token: str) -> "ListChatRequestBuilder": + self._list_chat_request.page_token = page_token + self._list_chat_request.add_query("page_token", page_token) + return self + + def page_size(self, page_size: int) -> "ListChatRequestBuilder": + self._list_chat_request.page_size = page_size + self._list_chat_request.add_query("page_size", page_size) + return self + + def build(self) -> ListChatRequest: + return self._list_chat_request diff --git a/lark_channel/api/im/v1/model/list_chat_response.py b/lark_channel/api/im/v1/model/list_chat_response.py new file mode 100644 index 0000000..e830b47 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_chat_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .list_chat_response_body import ListChatResponseBody + + +class ListChatResponse(BaseResponse): + _types = { + "data": ListChatResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ListChatResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/list_chat_response_body.py b/lark_channel/api/im/v1/model/list_chat_response_body.py new file mode 100644 index 0000000..6c564bc --- /dev/null +++ b/lark_channel/api/im/v1/model/list_chat_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .list_chat import ListChat + + +class ListChatResponseBody(object): + _types = { + "items": List[ListChat], + "page_token": str, + "has_more": bool, + } + + def __init__(self, d=None): + self.items: Optional[List[ListChat]] = None + self.page_token: Optional[str] = None + self.has_more: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListChatResponseBodyBuilder": + return ListChatResponseBodyBuilder() + + +class ListChatResponseBodyBuilder(object): + def __init__(self) -> None: + self._list_chat_response_body = ListChatResponseBody() + + def items(self, items: List[ListChat]) -> "ListChatResponseBodyBuilder": + self._list_chat_response_body.items = items + return self + + def page_token(self, page_token: str) -> "ListChatResponseBodyBuilder": + self._list_chat_response_body.page_token = page_token + return self + + def has_more(self, has_more: bool) -> "ListChatResponseBodyBuilder": + self._list_chat_response_body.has_more = has_more + return self + + def build(self) -> "ListChatResponseBody": + return self._list_chat_response_body diff --git a/lark_channel/api/im/v1/model/list_event_moderator.py b/lark_channel/api/im/v1/model/list_event_moderator.py new file mode 100644 index 0000000..07df8a0 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_event_moderator.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_id import UserId + + +class ListEventModerator(object): + _types = { + "tenant_key": str, + "user_id": UserId, + } + + def __init__(self, d=None): + self.tenant_key: Optional[str] = None + self.user_id: Optional[UserId] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListEventModeratorBuilder": + return ListEventModeratorBuilder() + + +class ListEventModeratorBuilder(object): + def __init__(self) -> None: + self._list_event_moderator = ListEventModerator() + + def tenant_key(self, tenant_key: str) -> "ListEventModeratorBuilder": + self._list_event_moderator.tenant_key = tenant_key + return self + + def user_id(self, user_id: UserId) -> "ListEventModeratorBuilder": + self._list_event_moderator.user_id = user_id + return self + + def build(self) -> "ListEventModerator": + return self._list_event_moderator diff --git a/lark_channel/api/im/v1/model/list_message_reaction_request.py b/lark_channel/api/im/v1/model/list_message_reaction_request.py new file mode 100644 index 0000000..5c021a9 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_reaction_request.py @@ -0,0 +1,55 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class ListMessageReactionRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.reaction_type: Optional[str] = None + self.page_token: Optional[str] = None + self.page_size: Optional[int] = None + self.user_id_type: Optional[str] = None + self.message_id: Optional[str] = None + + @staticmethod + def builder() -> "ListMessageReactionRequestBuilder": + return ListMessageReactionRequestBuilder() + + +class ListMessageReactionRequestBuilder(object): + + def __init__(self) -> None: + list_message_reaction_request = ListMessageReactionRequest() + list_message_reaction_request.http_method = HttpMethod.GET + list_message_reaction_request.uri = "/open-apis/im/v1/messages/:message_id/reactions" + list_message_reaction_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._list_message_reaction_request: ListMessageReactionRequest = list_message_reaction_request + + def reaction_type(self, reaction_type: str) -> "ListMessageReactionRequestBuilder": + self._list_message_reaction_request.reaction_type = reaction_type + self._list_message_reaction_request.add_query("reaction_type", reaction_type) + return self + + def page_token(self, page_token: str) -> "ListMessageReactionRequestBuilder": + self._list_message_reaction_request.page_token = page_token + self._list_message_reaction_request.add_query("page_token", page_token) + return self + + def page_size(self, page_size: int) -> "ListMessageReactionRequestBuilder": + self._list_message_reaction_request.page_size = page_size + self._list_message_reaction_request.add_query("page_size", page_size) + return self + + def user_id_type(self, user_id_type: str) -> "ListMessageReactionRequestBuilder": + self._list_message_reaction_request.user_id_type = user_id_type + self._list_message_reaction_request.add_query("user_id_type", user_id_type) + return self + + def message_id(self, message_id: str) -> "ListMessageReactionRequestBuilder": + self._list_message_reaction_request.message_id = message_id + self._list_message_reaction_request.paths["message_id"] = str(message_id) + return self + + def build(self) -> ListMessageReactionRequest: + return self._list_message_reaction_request diff --git a/lark_channel/api/im/v1/model/list_message_reaction_response.py b/lark_channel/api/im/v1/model/list_message_reaction_response.py new file mode 100644 index 0000000..feaacd0 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_reaction_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .list_message_reaction_response_body import ListMessageReactionResponseBody + + +class ListMessageReactionResponse(BaseResponse): + _types = { + "data": ListMessageReactionResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ListMessageReactionResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/list_message_reaction_response_body.py b/lark_channel/api/im/v1/model/list_message_reaction_response_body.py new file mode 100644 index 0000000..c83fbd6 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_reaction_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .message_reaction import MessageReaction + + +class ListMessageReactionResponseBody(object): + _types = { + "items": List[MessageReaction], + "has_more": bool, + "page_token": str, + } + + def __init__(self, d=None): + self.items: Optional[List[MessageReaction]] = None + self.has_more: Optional[bool] = None + self.page_token: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListMessageReactionResponseBodyBuilder": + return ListMessageReactionResponseBodyBuilder() + + +class ListMessageReactionResponseBodyBuilder(object): + def __init__(self) -> None: + self._list_message_reaction_response_body = ListMessageReactionResponseBody() + + def items(self, items: List[MessageReaction]) -> "ListMessageReactionResponseBodyBuilder": + self._list_message_reaction_response_body.items = items + return self + + def has_more(self, has_more: bool) -> "ListMessageReactionResponseBodyBuilder": + self._list_message_reaction_response_body.has_more = has_more + return self + + def page_token(self, page_token: str) -> "ListMessageReactionResponseBodyBuilder": + self._list_message_reaction_response_body.page_token = page_token + return self + + def build(self) -> "ListMessageReactionResponseBody": + return self._list_message_reaction_response_body diff --git a/lark_channel/api/im/v1/model/list_message_request.py b/lark_channel/api/im/v1/model/list_message_request.py new file mode 100644 index 0000000..012cfce --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_request.py @@ -0,0 +1,67 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class ListMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.container_id_type: Optional[str] = None + self.container_id: Optional[str] = None + self.start_time: Optional[str] = None + self.end_time: Optional[str] = None + self.sort_type: Optional[str] = None + self.page_size: Optional[int] = None + self.page_token: Optional[str] = None + + @staticmethod + def builder() -> "ListMessageRequestBuilder": + return ListMessageRequestBuilder() + + +class ListMessageRequestBuilder(object): + + def __init__(self) -> None: + list_message_request = ListMessageRequest() + list_message_request.http_method = HttpMethod.GET + list_message_request.uri = "/open-apis/im/v1/messages" + list_message_request.token_types = {AccessTokenType.TENANT} + self._list_message_request: ListMessageRequest = list_message_request + + def container_id_type(self, container_id_type: str) -> "ListMessageRequestBuilder": + self._list_message_request.container_id_type = container_id_type + self._list_message_request.add_query("container_id_type", container_id_type) + return self + + def container_id(self, container_id: str) -> "ListMessageRequestBuilder": + self._list_message_request.container_id = container_id + self._list_message_request.add_query("container_id", container_id) + return self + + def start_time(self, start_time: str) -> "ListMessageRequestBuilder": + self._list_message_request.start_time = start_time + self._list_message_request.add_query("start_time", start_time) + return self + + def end_time(self, end_time: str) -> "ListMessageRequestBuilder": + self._list_message_request.end_time = end_time + self._list_message_request.add_query("end_time", end_time) + return self + + def sort_type(self, sort_type: str) -> "ListMessageRequestBuilder": + self._list_message_request.sort_type = sort_type + self._list_message_request.add_query("sort_type", sort_type) + return self + + def page_size(self, page_size: int) -> "ListMessageRequestBuilder": + self._list_message_request.page_size = page_size + self._list_message_request.add_query("page_size", page_size) + return self + + def page_token(self, page_token: str) -> "ListMessageRequestBuilder": + self._list_message_request.page_token = page_token + self._list_message_request.add_query("page_token", page_token) + return self + + def build(self) -> ListMessageRequest: + return self._list_message_request diff --git a/lark_channel/api/im/v1/model/list_message_response.py b/lark_channel/api/im/v1/model/list_message_response.py new file mode 100644 index 0000000..214213d --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .list_message_response_body import ListMessageResponseBody + + +class ListMessageResponse(BaseResponse): + _types = { + "data": ListMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ListMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/list_message_response_body.py b/lark_channel/api/im/v1/model/list_message_response_body.py new file mode 100644 index 0000000..e45e975 --- /dev/null +++ b/lark_channel/api/im/v1/model/list_message_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .message import Message + + +class ListMessageResponseBody(object): + _types = { + "has_more": bool, + "page_token": str, + "items": List[Message], + } + + def __init__(self, d=None): + self.has_more: Optional[bool] = None + self.page_token: Optional[str] = None + self.items: Optional[List[Message]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ListMessageResponseBodyBuilder": + return ListMessageResponseBodyBuilder() + + +class ListMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._list_message_response_body = ListMessageResponseBody() + + def has_more(self, has_more: bool) -> "ListMessageResponseBodyBuilder": + self._list_message_response_body.has_more = has_more + return self + + def page_token(self, page_token: str) -> "ListMessageResponseBodyBuilder": + self._list_message_response_body.page_token = page_token + return self + + def items(self, items: List[Message]) -> "ListMessageResponseBodyBuilder": + self._list_message_response_body.items = items + return self + + def build(self) -> "ListMessageResponseBody": + return self._list_message_response_body diff --git a/lark_channel/api/im/v1/model/mention.py b/lark_channel/api/im/v1/model/mention.py new file mode 100644 index 0000000..c0302c0 --- /dev/null +++ b/lark_channel/api/im/v1/model/mention.py @@ -0,0 +1,52 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class Mention(object): + _types = { + "key": str, + "id": str, + "id_type": str, + "name": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.key: Optional[str] = None + self.id: Optional[str] = None + self.id_type: Optional[str] = None + self.name: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MentionBuilder": + return MentionBuilder() + + +class MentionBuilder(object): + def __init__(self) -> None: + self._mention = Mention() + + def key(self, key: str) -> "MentionBuilder": + self._mention.key = key + return self + + def id(self, id: str) -> "MentionBuilder": + self._mention.id = id + return self + + def id_type(self, id_type: str) -> "MentionBuilder": + self._mention.id_type = id_type + return self + + def name(self, name: str) -> "MentionBuilder": + self._mention.name = name + return self + + def tenant_key(self, tenant_key: str) -> "MentionBuilder": + self._mention.tenant_key = tenant_key + return self + + def build(self) -> "Mention": + return self._mention diff --git a/lark_channel/api/im/v1/model/mention_event.py b/lark_channel/api/im/v1/model/mention_event.py new file mode 100644 index 0000000..eb4555a --- /dev/null +++ b/lark_channel/api/im/v1/model/mention_event.py @@ -0,0 +1,47 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .user_id import UserId + + +class MentionEvent(object): + _types = { + "key": str, + "id": UserId, + "name": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.key: Optional[str] = None + self.id: Optional[UserId] = None + self.name: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MentionEventBuilder": + return MentionEventBuilder() + + +class MentionEventBuilder(object): + def __init__(self) -> None: + self._mention_event = MentionEvent() + + def key(self, key: str) -> "MentionEventBuilder": + self._mention_event.key = key + return self + + def id(self, id: UserId) -> "MentionEventBuilder": + self._mention_event.id = id + return self + + def name(self, name: str) -> "MentionEventBuilder": + self._mention_event.name = name + return self + + def tenant_key(self, tenant_key: str) -> "MentionEventBuilder": + self._mention_event.tenant_key = tenant_key + return self + + def build(self) -> "MentionEvent": + return self._mention_event diff --git a/lark_channel/api/im/v1/model/merge_forward_message_request.py b/lark_channel/api/im/v1/model/merge_forward_message_request.py new file mode 100644 index 0000000..33b9fb7 --- /dev/null +++ b/lark_channel/api/im/v1/model/merge_forward_message_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .merge_forward_message_request_body import MergeForwardMessageRequestBody + + +class MergeForwardMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.receive_id_type: Optional[str] = None + self.uuid: Optional[str] = None + self.request_body: Optional[MergeForwardMessageRequestBody] = None + + @staticmethod + def builder() -> "MergeForwardMessageRequestBuilder": + return MergeForwardMessageRequestBuilder() + + +class MergeForwardMessageRequestBuilder(object): + + def __init__(self) -> None: + merge_forward_message_request = MergeForwardMessageRequest() + merge_forward_message_request.http_method = HttpMethod.POST + merge_forward_message_request.uri = "/open-apis/im/v1/messages/merge_forward" + merge_forward_message_request.token_types = {AccessTokenType.TENANT} + self._merge_forward_message_request: MergeForwardMessageRequest = merge_forward_message_request + + def receive_id_type(self, receive_id_type: str) -> "MergeForwardMessageRequestBuilder": + self._merge_forward_message_request.receive_id_type = receive_id_type + self._merge_forward_message_request.add_query("receive_id_type", receive_id_type) + return self + + def uuid(self, uuid: str) -> "MergeForwardMessageRequestBuilder": + self._merge_forward_message_request.uuid = uuid + self._merge_forward_message_request.add_query("uuid", uuid) + return self + + def request_body(self, request_body: MergeForwardMessageRequestBody) -> "MergeForwardMessageRequestBuilder": + self._merge_forward_message_request.request_body = request_body + self._merge_forward_message_request.body = request_body + return self + + def build(self) -> MergeForwardMessageRequest: + return self._merge_forward_message_request diff --git a/lark_channel/api/im/v1/model/merge_forward_message_request_body.py b/lark_channel/api/im/v1/model/merge_forward_message_request_body.py new file mode 100644 index 0000000..619f52b --- /dev/null +++ b/lark_channel/api/im/v1/model/merge_forward_message_request_body.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class MergeForwardMessageRequestBody(object): + _types = { + "receive_id": str, + "message_id_list": List[str], + } + + def __init__(self, d=None): + self.receive_id: Optional[str] = None + self.message_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MergeForwardMessageRequestBodyBuilder": + return MergeForwardMessageRequestBodyBuilder() + + +class MergeForwardMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._merge_forward_message_request_body = MergeForwardMessageRequestBody() + + def receive_id(self, receive_id: str) -> "MergeForwardMessageRequestBodyBuilder": + self._merge_forward_message_request_body.receive_id = receive_id + return self + + def message_id_list(self, message_id_list: List[str]) -> "MergeForwardMessageRequestBodyBuilder": + self._merge_forward_message_request_body.message_id_list = message_id_list + return self + + def build(self) -> "MergeForwardMessageRequestBody": + return self._merge_forward_message_request_body diff --git a/lark_channel/api/im/v1/model/merge_forward_message_response.py b/lark_channel/api/im/v1/model/merge_forward_message_response.py new file mode 100644 index 0000000..748d44a --- /dev/null +++ b/lark_channel/api/im/v1/model/merge_forward_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .merge_forward_message_response_body import MergeForwardMessageResponseBody + + +class MergeForwardMessageResponse(BaseResponse): + _types = { + "data": MergeForwardMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[MergeForwardMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/merge_forward_message_response_body.py b/lark_channel/api/im/v1/model/merge_forward_message_response_body.py new file mode 100644 index 0000000..92dc074 --- /dev/null +++ b/lark_channel/api/im/v1/model/merge_forward_message_response_body.py @@ -0,0 +1,35 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .message import Message + + +class MergeForwardMessageResponseBody(object): + _types = { + "message": Message, + "invalid_message_id_list": List[str], + } + + def __init__(self, d=None): + self.message: Optional[Message] = None + self.invalid_message_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MergeForwardMessageResponseBodyBuilder": + return MergeForwardMessageResponseBodyBuilder() + + +class MergeForwardMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._merge_forward_message_response_body = MergeForwardMessageResponseBody() + + def message(self, message: Message) -> "MergeForwardMessageResponseBodyBuilder": + self._merge_forward_message_response_body.message = message + return self + + def invalid_message_id_list(self, invalid_message_id_list: List[str]) -> "MergeForwardMessageResponseBodyBuilder": + self._merge_forward_message_response_body.invalid_message_id_list = invalid_message_id_list + return self + + def build(self) -> "MergeForwardMessageResponseBody": + return self._merge_forward_message_response_body diff --git a/lark_channel/api/im/v1/model/message.py b/lark_channel/api/im/v1/model/message.py new file mode 100644 index 0000000..cbb0501 --- /dev/null +++ b/lark_channel/api/im/v1/model/message.py @@ -0,0 +1,109 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .sender import Sender +from .message_body import MessageBody +from .mention import Mention + + +class Message(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "thread_id": str, + "msg_type": str, + "create_time": int, + "update_time": int, + "deleted": bool, + "updated": bool, + "chat_id": str, + "sender": Sender, + "body": MessageBody, + "mentions": List[Mention], + "upper_message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.deleted: Optional[bool] = None + self.updated: Optional[bool] = None + self.chat_id: Optional[str] = None + self.sender: Optional[Sender] = None + self.body: Optional[MessageBody] = None + self.mentions: Optional[List[Mention]] = None + self.upper_message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MessageBuilder": + return MessageBuilder() + + +class MessageBuilder(object): + def __init__(self) -> None: + self._message = Message() + + def message_id(self, message_id: str) -> "MessageBuilder": + self._message.message_id = message_id + return self + + def root_id(self, root_id: str) -> "MessageBuilder": + self._message.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "MessageBuilder": + self._message.parent_id = parent_id + return self + + def thread_id(self, thread_id: str) -> "MessageBuilder": + self._message.thread_id = thread_id + return self + + def msg_type(self, msg_type: str) -> "MessageBuilder": + self._message.msg_type = msg_type + return self + + def create_time(self, create_time: int) -> "MessageBuilder": + self._message.create_time = create_time + return self + + def update_time(self, update_time: int) -> "MessageBuilder": + self._message.update_time = update_time + return self + + def deleted(self, deleted: bool) -> "MessageBuilder": + self._message.deleted = deleted + return self + + def updated(self, updated: bool) -> "MessageBuilder": + self._message.updated = updated + return self + + def chat_id(self, chat_id: str) -> "MessageBuilder": + self._message.chat_id = chat_id + return self + + def sender(self, sender: Sender) -> "MessageBuilder": + self._message.sender = sender + return self + + def body(self, body: MessageBody) -> "MessageBuilder": + self._message.body = body + return self + + def mentions(self, mentions: List[Mention]) -> "MessageBuilder": + self._message.mentions = mentions + return self + + def upper_message_id(self, upper_message_id: str) -> "MessageBuilder": + self._message.upper_message_id = upper_message_id + return self + + def build(self) -> "Message": + return self._message diff --git a/lark_channel/api/im/v1/model/message_body.py b/lark_channel/api/im/v1/model/message_body.py new file mode 100644 index 0000000..bc07119 --- /dev/null +++ b/lark_channel/api/im/v1/model/message_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class MessageBody(object): + _types = { + "content": str, + } + + def __init__(self, d=None): + self.content: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MessageBodyBuilder": + return MessageBodyBuilder() + + +class MessageBodyBuilder(object): + def __init__(self) -> None: + self._message_body = MessageBody() + + def content(self, content: str) -> "MessageBodyBuilder": + self._message_body.content = content + return self + + def build(self) -> "MessageBody": + return self._message_body diff --git a/lark_channel/api/im/v1/model/message_reaction.py b/lark_channel/api/im/v1/model/message_reaction.py new file mode 100644 index 0000000..662a661 --- /dev/null +++ b/lark_channel/api/im/v1/model/message_reaction.py @@ -0,0 +1,48 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .operator import Operator +from .emoji import Emoji + + +class MessageReaction(object): + _types = { + "reaction_id": str, + "operator": Operator, + "action_time": int, + "reaction_type": Emoji, + } + + def __init__(self, d=None): + self.reaction_id: Optional[str] = None + self.operator: Optional[Operator] = None + self.action_time: Optional[int] = None + self.reaction_type: Optional[Emoji] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "MessageReactionBuilder": + return MessageReactionBuilder() + + +class MessageReactionBuilder(object): + def __init__(self) -> None: + self._message_reaction = MessageReaction() + + def reaction_id(self, reaction_id: str) -> "MessageReactionBuilder": + self._message_reaction.reaction_id = reaction_id + return self + + def operator(self, operator: Operator) -> "MessageReactionBuilder": + self._message_reaction.operator = operator + return self + + def action_time(self, action_time: int) -> "MessageReactionBuilder": + self._message_reaction.action_time = action_time + return self + + def reaction_type(self, reaction_type: Emoji) -> "MessageReactionBuilder": + self._message_reaction.reaction_type = reaction_type + return self + + def build(self) -> "MessageReaction": + return self._message_reaction diff --git a/lark_channel/api/im/v1/model/moderator_list.py b/lark_channel/api/im/v1/model/moderator_list.py new file mode 100644 index 0000000..fa4ba4c --- /dev/null +++ b/lark_channel/api/im/v1/model/moderator_list.py @@ -0,0 +1,36 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .list_event_moderator import ListEventModerator +from .list_event_moderator import ListEventModerator + + +class ModeratorList(object): + _types = { + "added_member_list": List[ListEventModerator], + "removed_member_list": List[ListEventModerator], + } + + def __init__(self, d=None): + self.added_member_list: Optional[List[ListEventModerator]] = None + self.removed_member_list: Optional[List[ListEventModerator]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ModeratorListBuilder": + return ModeratorListBuilder() + + +class ModeratorListBuilder(object): + def __init__(self) -> None: + self._moderator_list = ModeratorList() + + def added_member_list(self, added_member_list: List[ListEventModerator]) -> "ModeratorListBuilder": + self._moderator_list.added_member_list = added_member_list + return self + + def removed_member_list(self, removed_member_list: List[ListEventModerator]) -> "ModeratorListBuilder": + self._moderator_list.removed_member_list = removed_member_list + return self + + def build(self) -> "ModeratorList": + return self._moderator_list diff --git a/lark_channel/api/im/v1/model/operator.py b/lark_channel/api/im/v1/model/operator.py new file mode 100644 index 0000000..a728996 --- /dev/null +++ b/lark_channel/api/im/v1/model/operator.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class Operator(object): + _types = { + "operator_id": str, + "operator_type": str, + } + + def __init__(self, d=None): + self.operator_id: Optional[str] = None + self.operator_type: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "OperatorBuilder": + return OperatorBuilder() + + +class OperatorBuilder(object): + def __init__(self) -> None: + self._operator = Operator() + + def operator_id(self, operator_id: str) -> "OperatorBuilder": + self._operator.operator_id = operator_id + return self + + def operator_type(self, operator_type: str) -> "OperatorBuilder": + self._operator.operator_type = operator_type + return self + + def build(self) -> "Operator": + return self._operator diff --git a/lark_channel/api/im/v1/model/p2_im_chat_access_event_bot_p2p_chat_entered_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_access_event_bot_p2p_chat_entered_v1.py new file mode 100644 index 0000000..db9a399 --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_access_event_bot_p2p_chat_entered_v1.py @@ -0,0 +1,32 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId + + +class P2ImChatAccessEventBotP2pChatEnteredV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "last_message_id": str, + "last_message_create_time": str, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.last_message_id: Optional[str] = None + self.last_message_create_time: Optional[str] = None + init(self, d, self._types) + + +class P2ImChatAccessEventBotP2pChatEnteredV1(EventContext): + _types = { + "event": P2ImChatAccessEventBotP2pChatEnteredV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatAccessEventBotP2pChatEnteredV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_disbanded_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_disbanded_v1.py new file mode 100644 index 0000000..3af421e --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_disbanded_v1.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .i18n_names import I18nNames + + +class P2ImChatDisbandedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatDisbandedV1(EventContext): + _types = { + "event": P2ImChatDisbandedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatDisbandedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_member_bot_added_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_member_bot_added_v1.py new file mode 100644 index 0000000..1f619db --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_member_bot_added_v1.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .i18n_names import I18nNames + + +class P2ImChatMemberBotAddedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatMemberBotAddedV1(EventContext): + _types = { + "event": P2ImChatMemberBotAddedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatMemberBotAddedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_member_bot_deleted_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_member_bot_deleted_v1.py new file mode 100644 index 0000000..718b76c --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_member_bot_deleted_v1.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .i18n_names import I18nNames + + +class P2ImChatMemberBotDeletedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatMemberBotDeletedV1(EventContext): + _types = { + "event": P2ImChatMemberBotDeletedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatMemberBotDeletedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_member_user_added_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_member_user_added_v1.py new file mode 100644 index 0000000..f908a94 --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_member_user_added_v1.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .chat_member_user import ChatMemberUser +from .i18n_names import I18nNames + + +class P2ImChatMemberUserAddedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "users": List[ChatMemberUser], + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.users: Optional[List[ChatMemberUser]] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatMemberUserAddedV1(EventContext): + _types = { + "event": P2ImChatMemberUserAddedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatMemberUserAddedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_member_user_deleted_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_member_user_deleted_v1.py new file mode 100644 index 0000000..df928d9 --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_member_user_deleted_v1.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .chat_member_user import ChatMemberUser +from .i18n_names import I18nNames + + +class P2ImChatMemberUserDeletedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "users": List[ChatMemberUser], + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.users: Optional[List[ChatMemberUser]] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatMemberUserDeletedV1(EventContext): + _types = { + "event": P2ImChatMemberUserDeletedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatMemberUserDeletedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_member_user_withdrawn_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_member_user_withdrawn_v1.py new file mode 100644 index 0000000..e3aac40 --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_member_user_withdrawn_v1.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .chat_member_user import ChatMemberUser +from .i18n_names import I18nNames + + +class P2ImChatMemberUserWithdrawnV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "users": List[ChatMemberUser], + "name": str, + "i18n_names": I18nNames, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.users: Optional[List[ChatMemberUser]] = None + self.name: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + init(self, d, self._types) + + +class P2ImChatMemberUserWithdrawnV1(EventContext): + _types = { + "event": P2ImChatMemberUserWithdrawnV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatMemberUserWithdrawnV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_chat_updated_v1.py b/lark_channel/api/im/v1/model/p2_im_chat_updated_v1.py new file mode 100644 index 0000000..3cad90d --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_chat_updated_v1.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .user_id import UserId +from .chat_change import ChatChange +from .chat_change import ChatChange +from .moderator_list import ModeratorList + + +class P2ImChatUpdatedV1Data(object): + _types = { + "chat_id": str, + "operator_id": UserId, + "external": bool, + "operator_tenant_key": str, + "after_change": ChatChange, + "before_change": ChatChange, + "moderator_list": ModeratorList, + } + + def __init__(self, d=None): + self.chat_id: Optional[str] = None + self.operator_id: Optional[UserId] = None + self.external: Optional[bool] = None + self.operator_tenant_key: Optional[str] = None + self.after_change: Optional[ChatChange] = None + self.before_change: Optional[ChatChange] = None + self.moderator_list: Optional[ModeratorList] = None + init(self, d, self._types) + + +class P2ImChatUpdatedV1(EventContext): + _types = { + "event": P2ImChatUpdatedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImChatUpdatedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_message_message_read_v1.py b/lark_channel/api/im/v1/model/p2_im_message_message_read_v1.py new file mode 100644 index 0000000..77d180b --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_message_message_read_v1.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .event_message_reader import EventMessageReader + + +class P2ImMessageMessageReadV1Data(object): + _types = { + "reader": EventMessageReader, + "message_id_list": List[str], + } + + def __init__(self, d=None): + self.reader: Optional[EventMessageReader] = None + self.message_id_list: Optional[List[str]] = None + init(self, d, self._types) + + +class P2ImMessageMessageReadV1(EventContext): + _types = { + "event": P2ImMessageMessageReadV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImMessageMessageReadV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_message_reaction_created_v1.py b/lark_channel/api/im/v1/model/p2_im_message_reaction_created_v1.py new file mode 100644 index 0000000..30961e2 --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_message_reaction_created_v1.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .emoji import Emoji +from .user_id import UserId + + +class P2ImMessageReactionCreatedV1Data(object): + _types = { + "message_id": str, + "reaction_type": Emoji, + "operator_type": str, + "user_id": UserId, + "app_id": str, + "action_time": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.reaction_type: Optional[Emoji] = None + self.operator_type: Optional[str] = None + self.user_id: Optional[UserId] = None + self.app_id: Optional[str] = None + self.action_time: Optional[str] = None + init(self, d, self._types) + + +class P2ImMessageReactionCreatedV1(EventContext): + _types = { + "event": P2ImMessageReactionCreatedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImMessageReactionCreatedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_message_reaction_deleted_v1.py b/lark_channel/api/im/v1/model/p2_im_message_reaction_deleted_v1.py new file mode 100644 index 0000000..c895faf --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_message_reaction_deleted_v1.py @@ -0,0 +1,37 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .emoji import Emoji +from .user_id import UserId + + +class P2ImMessageReactionDeletedV1Data(object): + _types = { + "message_id": str, + "reaction_type": Emoji, + "operator_type": str, + "user_id": UserId, + "app_id": str, + "action_time": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.reaction_type: Optional[Emoji] = None + self.operator_type: Optional[str] = None + self.user_id: Optional[UserId] = None + self.app_id: Optional[str] = None + self.action_time: Optional[str] = None + init(self, d, self._types) + + +class P2ImMessageReactionDeletedV1(EventContext): + _types = { + "event": P2ImMessageReactionDeletedV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImMessageReactionDeletedV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_message_recalled_v1.py b/lark_channel/api/im/v1/model/p2_im_message_recalled_v1.py new file mode 100644 index 0000000..b47109c --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_message_recalled_v1.py @@ -0,0 +1,31 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext + + +class P2ImMessageRecalledV1Data(object): + _types = { + "message_id": str, + "chat_id": str, + "recall_time": str, + "recall_type": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.chat_id: Optional[str] = None + self.recall_time: Optional[str] = None + self.recall_type: Optional[str] = None + init(self, d, self._types) + + +class P2ImMessageRecalledV1(EventContext): + _types = { + "event": P2ImMessageRecalledV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImMessageRecalledV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/p2_im_message_receive_v1.py b/lark_channel/api/im/v1/model/p2_im_message_receive_v1.py new file mode 100644 index 0000000..a376b1d --- /dev/null +++ b/lark_channel/api/im/v1/model/p2_im_message_receive_v1.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.event.context import EventContext +from .event_sender import EventSender +from .event_message import EventMessage + + +class P2ImMessageReceiveV1Data(object): + _types = { + "sender": EventSender, + "message": EventMessage, + } + + def __init__(self, d=None): + self.sender: Optional[EventSender] = None + self.message: Optional[EventMessage] = None + init(self, d, self._types) + + +class P2ImMessageReceiveV1(EventContext): + _types = { + "event": P2ImMessageReceiveV1Data + } + + def __init__(self, d=None): + super().__init__(d) + self._types.update(super()._types) + self.event: Optional[P2ImMessageReceiveV1Data] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/patch_message_request.py b/lark_channel/api/im/v1/model/patch_message_request.py new file mode 100644 index 0000000..2d486fb --- /dev/null +++ b/lark_channel/api/im/v1/model/patch_message_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .patch_message_request_body import PatchMessageRequestBody + + +class PatchMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.request_body: Optional[PatchMessageRequestBody] = None + + @staticmethod + def builder() -> "PatchMessageRequestBuilder": + return PatchMessageRequestBuilder() + + +class PatchMessageRequestBuilder(object): + + def __init__(self) -> None: + patch_message_request = PatchMessageRequest() + patch_message_request.http_method = HttpMethod.PATCH + patch_message_request.uri = "/open-apis/im/v1/messages/:message_id" + patch_message_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._patch_message_request: PatchMessageRequest = patch_message_request + + def message_id(self, message_id: str) -> "PatchMessageRequestBuilder": + self._patch_message_request.message_id = message_id + self._patch_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: PatchMessageRequestBody) -> "PatchMessageRequestBuilder": + self._patch_message_request.request_body = request_body + self._patch_message_request.body = request_body + return self + + def build(self) -> PatchMessageRequest: + return self._patch_message_request diff --git a/lark_channel/api/im/v1/model/patch_message_request_body.py b/lark_channel/api/im/v1/model/patch_message_request_body.py new file mode 100644 index 0000000..2cc017c --- /dev/null +++ b/lark_channel/api/im/v1/model/patch_message_request_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class PatchMessageRequestBody(object): + _types = { + "content": str, + } + + def __init__(self, d=None): + self.content: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "PatchMessageRequestBodyBuilder": + return PatchMessageRequestBodyBuilder() + + +class PatchMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._patch_message_request_body = PatchMessageRequestBody() + + def content(self, content: str) -> "PatchMessageRequestBodyBuilder": + self._patch_message_request_body.content = content + return self + + def build(self) -> "PatchMessageRequestBody": + return self._patch_message_request_body diff --git a/lark_channel/api/im/v1/model/patch_message_response.py b/lark_channel/api/im/v1/model/patch_message_response.py new file mode 100644 index 0000000..ddba51a --- /dev/null +++ b/lark_channel/api/im/v1/model/patch_message_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class PatchMessageResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/push_follow_up_message_request.py b/lark_channel/api/im/v1/model/push_follow_up_message_request.py new file mode 100644 index 0000000..a0f1c48 --- /dev/null +++ b/lark_channel/api/im/v1/model/push_follow_up_message_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .push_follow_up_message_request_body import PushFollowUpMessageRequestBody + + +class PushFollowUpMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.request_body: Optional[PushFollowUpMessageRequestBody] = None + + @staticmethod + def builder() -> "PushFollowUpMessageRequestBuilder": + return PushFollowUpMessageRequestBuilder() + + +class PushFollowUpMessageRequestBuilder(object): + + def __init__(self) -> None: + push_follow_up_message_request = PushFollowUpMessageRequest() + push_follow_up_message_request.http_method = HttpMethod.POST + push_follow_up_message_request.uri = "/open-apis/im/v1/messages/:message_id/push_follow_up" + push_follow_up_message_request.token_types = {AccessTokenType.TENANT} + self._push_follow_up_message_request: PushFollowUpMessageRequest = push_follow_up_message_request + + def message_id(self, message_id: str) -> "PushFollowUpMessageRequestBuilder": + self._push_follow_up_message_request.message_id = message_id + self._push_follow_up_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: PushFollowUpMessageRequestBody) -> "PushFollowUpMessageRequestBuilder": + self._push_follow_up_message_request.request_body = request_body + self._push_follow_up_message_request.body = request_body + return self + + def build(self) -> PushFollowUpMessageRequest: + return self._push_follow_up_message_request diff --git a/lark_channel/api/im/v1/model/push_follow_up_message_request_body.py b/lark_channel/api/im/v1/model/push_follow_up_message_request_body.py new file mode 100644 index 0000000..be50cc4 --- /dev/null +++ b/lark_channel/api/im/v1/model/push_follow_up_message_request_body.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .follow_up import FollowUp + + +class PushFollowUpMessageRequestBody(object): + _types = { + "follow_ups": List[FollowUp], + } + + def __init__(self, d=None): + self.follow_ups: Optional[List[FollowUp]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "PushFollowUpMessageRequestBodyBuilder": + return PushFollowUpMessageRequestBodyBuilder() + + +class PushFollowUpMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._push_follow_up_message_request_body = PushFollowUpMessageRequestBody() + + def follow_ups(self, follow_ups: List[FollowUp]) -> "PushFollowUpMessageRequestBodyBuilder": + self._push_follow_up_message_request_body.follow_ups = follow_ups + return self + + def build(self) -> "PushFollowUpMessageRequestBody": + return self._push_follow_up_message_request_body diff --git a/lark_channel/api/im/v1/model/push_follow_up_message_response.py b/lark_channel/api/im/v1/model/push_follow_up_message_response.py new file mode 100644 index 0000000..c3b0133 --- /dev/null +++ b/lark_channel/api/im/v1/model/push_follow_up_message_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class PushFollowUpMessageResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/read_user.py b/lark_channel/api/im/v1/model/read_user.py new file mode 100644 index 0000000..e7e6e9e --- /dev/null +++ b/lark_channel/api/im/v1/model/read_user.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ReadUser(object): + _types = { + "user_id_type": str, + "user_id": str, + "timestamp": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.user_id_type: Optional[str] = None + self.user_id: Optional[str] = None + self.timestamp: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ReadUserBuilder": + return ReadUserBuilder() + + +class ReadUserBuilder(object): + def __init__(self) -> None: + self._read_user = ReadUser() + + def user_id_type(self, user_id_type: str) -> "ReadUserBuilder": + self._read_user.user_id_type = user_id_type + return self + + def user_id(self, user_id: str) -> "ReadUserBuilder": + self._read_user.user_id = user_id + return self + + def timestamp(self, timestamp: str) -> "ReadUserBuilder": + self._read_user.timestamp = timestamp + return self + + def tenant_key(self, tenant_key: str) -> "ReadUserBuilder": + self._read_user.tenant_key = tenant_key + return self + + def build(self) -> "ReadUser": + return self._read_user diff --git a/lark_channel/api/im/v1/model/read_users_message_request.py b/lark_channel/api/im/v1/model/read_users_message_request.py new file mode 100644 index 0000000..a1898cf --- /dev/null +++ b/lark_channel/api/im/v1/model/read_users_message_request.py @@ -0,0 +1,49 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class ReadUsersMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.page_size: Optional[int] = None + self.page_token: Optional[str] = None + self.message_id: Optional[str] = None + + @staticmethod + def builder() -> "ReadUsersMessageRequestBuilder": + return ReadUsersMessageRequestBuilder() + + +class ReadUsersMessageRequestBuilder(object): + + def __init__(self) -> None: + read_users_message_request = ReadUsersMessageRequest() + read_users_message_request.http_method = HttpMethod.GET + read_users_message_request.uri = "/open-apis/im/v1/messages/:message_id/read_users" + read_users_message_request.token_types = {AccessTokenType.TENANT} + self._read_users_message_request: ReadUsersMessageRequest = read_users_message_request + + def user_id_type(self, user_id_type: str) -> "ReadUsersMessageRequestBuilder": + self._read_users_message_request.user_id_type = user_id_type + self._read_users_message_request.add_query("user_id_type", user_id_type) + return self + + def page_size(self, page_size: int) -> "ReadUsersMessageRequestBuilder": + self._read_users_message_request.page_size = page_size + self._read_users_message_request.add_query("page_size", page_size) + return self + + def page_token(self, page_token: str) -> "ReadUsersMessageRequestBuilder": + self._read_users_message_request.page_token = page_token + self._read_users_message_request.add_query("page_token", page_token) + return self + + def message_id(self, message_id: str) -> "ReadUsersMessageRequestBuilder": + self._read_users_message_request.message_id = message_id + self._read_users_message_request.paths["message_id"] = str(message_id) + return self + + def build(self) -> ReadUsersMessageRequest: + return self._read_users_message_request diff --git a/lark_channel/api/im/v1/model/read_users_message_request_body.py b/lark_channel/api/im/v1/model/read_users_message_request_body.py new file mode 100644 index 0000000..43f83e1 --- /dev/null +++ b/lark_channel/api/im/v1/model/read_users_message_request_body.py @@ -0,0 +1,22 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ReadUsersMessageRequestBody(object): + _types = { + } + + def __init__(self, d=None): + init(self, d, self._types) + + @staticmethod + def builder() -> "ReadUsersMessageRequestBodyBuilder": + return ReadUsersMessageRequestBodyBuilder() + + +class ReadUsersMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._read_users_message_request_body = ReadUsersMessageRequestBody() + + def build(self) -> "ReadUsersMessageRequestBody": + return self._read_users_message_request_body diff --git a/lark_channel/api/im/v1/model/read_users_message_response.py b/lark_channel/api/im/v1/model/read_users_message_response.py new file mode 100644 index 0000000..e16660e --- /dev/null +++ b/lark_channel/api/im/v1/model/read_users_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .read_users_message_response_body import ReadUsersMessageResponseBody + + +class ReadUsersMessageResponse(BaseResponse): + _types = { + "data": ReadUsersMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ReadUsersMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/read_users_message_response_body.py b/lark_channel/api/im/v1/model/read_users_message_response_body.py new file mode 100644 index 0000000..8dc1fee --- /dev/null +++ b/lark_channel/api/im/v1/model/read_users_message_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .read_user import ReadUser + + +class ReadUsersMessageResponseBody(object): + _types = { + "items": List[ReadUser], + "has_more": bool, + "page_token": str, + } + + def __init__(self, d=None): + self.items: Optional[List[ReadUser]] = None + self.has_more: Optional[bool] = None + self.page_token: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ReadUsersMessageResponseBodyBuilder": + return ReadUsersMessageResponseBodyBuilder() + + +class ReadUsersMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._read_users_message_response_body = ReadUsersMessageResponseBody() + + def items(self, items: List[ReadUser]) -> "ReadUsersMessageResponseBodyBuilder": + self._read_users_message_response_body.items = items + return self + + def has_more(self, has_more: bool) -> "ReadUsersMessageResponseBodyBuilder": + self._read_users_message_response_body.has_more = has_more + return self + + def page_token(self, page_token: str) -> "ReadUsersMessageResponseBodyBuilder": + self._read_users_message_response_body.page_token = page_token + return self + + def build(self) -> "ReadUsersMessageResponseBody": + return self._read_users_message_response_body diff --git a/lark_channel/api/im/v1/model/reply_message_request.py b/lark_channel/api/im/v1/model/reply_message_request.py new file mode 100644 index 0000000..2ed0543 --- /dev/null +++ b/lark_channel/api/im/v1/model/reply_message_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .reply_message_request_body import ReplyMessageRequestBody + + +class ReplyMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.request_body: Optional[ReplyMessageRequestBody] = None + + @staticmethod + def builder() -> "ReplyMessageRequestBuilder": + return ReplyMessageRequestBuilder() + + +class ReplyMessageRequestBuilder(object): + + def __init__(self) -> None: + reply_message_request = ReplyMessageRequest() + reply_message_request.http_method = HttpMethod.POST + reply_message_request.uri = "/open-apis/im/v1/messages/:message_id/reply" + reply_message_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._reply_message_request: ReplyMessageRequest = reply_message_request + + def message_id(self, message_id: str) -> "ReplyMessageRequestBuilder": + self._reply_message_request.message_id = message_id + self._reply_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: ReplyMessageRequestBody) -> "ReplyMessageRequestBuilder": + self._reply_message_request.request_body = request_body + self._reply_message_request.body = request_body + return self + + def build(self) -> ReplyMessageRequest: + return self._reply_message_request diff --git a/lark_channel/api/im/v1/model/reply_message_request_body.py b/lark_channel/api/im/v1/model/reply_message_request_body.py new file mode 100644 index 0000000..4656f8b --- /dev/null +++ b/lark_channel/api/im/v1/model/reply_message_request_body.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class ReplyMessageRequestBody(object): + _types = { + "content": str, + "msg_type": str, + "reply_in_thread": bool, + "uuid": str, + } + + def __init__(self, d=None): + self.content: Optional[str] = None + self.msg_type: Optional[str] = None + self.reply_in_thread: Optional[bool] = None + self.uuid: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ReplyMessageRequestBodyBuilder": + return ReplyMessageRequestBodyBuilder() + + +class ReplyMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._reply_message_request_body = ReplyMessageRequestBody() + + def content(self, content: str) -> "ReplyMessageRequestBodyBuilder": + self._reply_message_request_body.content = content + return self + + def msg_type(self, msg_type: str) -> "ReplyMessageRequestBodyBuilder": + self._reply_message_request_body.msg_type = msg_type + return self + + def reply_in_thread(self, reply_in_thread: bool) -> "ReplyMessageRequestBodyBuilder": + self._reply_message_request_body.reply_in_thread = reply_in_thread + return self + + def uuid(self, uuid: str) -> "ReplyMessageRequestBodyBuilder": + self._reply_message_request_body.uuid = uuid + return self + + def build(self) -> "ReplyMessageRequestBody": + return self._reply_message_request_body diff --git a/lark_channel/api/im/v1/model/reply_message_response.py b/lark_channel/api/im/v1/model/reply_message_response.py new file mode 100644 index 0000000..a302017 --- /dev/null +++ b/lark_channel/api/im/v1/model/reply_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .reply_message_response_body import ReplyMessageResponseBody + + +class ReplyMessageResponse(BaseResponse): + _types = { + "data": ReplyMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[ReplyMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/reply_message_response_body.py b/lark_channel/api/im/v1/model/reply_message_response_body.py new file mode 100644 index 0000000..c77cd91 --- /dev/null +++ b/lark_channel/api/im/v1/model/reply_message_response_body.py @@ -0,0 +1,109 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .sender import Sender +from .message_body import MessageBody +from .mention import Mention + + +class ReplyMessageResponseBody(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "thread_id": str, + "msg_type": str, + "create_time": int, + "update_time": int, + "deleted": bool, + "updated": bool, + "chat_id": str, + "sender": Sender, + "body": MessageBody, + "mentions": List[Mention], + "upper_message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.deleted: Optional[bool] = None + self.updated: Optional[bool] = None + self.chat_id: Optional[str] = None + self.sender: Optional[Sender] = None + self.body: Optional[MessageBody] = None + self.mentions: Optional[List[Mention]] = None + self.upper_message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "ReplyMessageResponseBodyBuilder": + return ReplyMessageResponseBodyBuilder() + + +class ReplyMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._reply_message_response_body = ReplyMessageResponseBody() + + def message_id(self, message_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.message_id = message_id + return self + + def root_id(self, root_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.parent_id = parent_id + return self + + def thread_id(self, thread_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.thread_id = thread_id + return self + + def msg_type(self, msg_type: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.msg_type = msg_type + return self + + def create_time(self, create_time: int) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.create_time = create_time + return self + + def update_time(self, update_time: int) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.update_time = update_time + return self + + def deleted(self, deleted: bool) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.deleted = deleted + return self + + def updated(self, updated: bool) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.updated = updated + return self + + def chat_id(self, chat_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.chat_id = chat_id + return self + + def sender(self, sender: Sender) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.sender = sender + return self + + def body(self, body: MessageBody) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.body = body + return self + + def mentions(self, mentions: List[Mention]) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.mentions = mentions + return self + + def upper_message_id(self, upper_message_id: str) -> "ReplyMessageResponseBodyBuilder": + self._reply_message_response_body.upper_message_id = upper_message_id + return self + + def build(self) -> "ReplyMessageResponseBody": + return self._reply_message_response_body diff --git a/lark_channel/api/im/v1/model/restricted_mode_setting.py b/lark_channel/api/im/v1/model/restricted_mode_setting.py new file mode 100644 index 0000000..86a0cd1 --- /dev/null +++ b/lark_channel/api/im/v1/model/restricted_mode_setting.py @@ -0,0 +1,47 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class RestrictedModeSetting(object): + _types = { + "status": bool, + "screenshot_has_permission_setting": str, + "download_has_permission_setting": str, + "message_has_permission_setting": str, + } + + def __init__(self, d=None): + self.status: Optional[bool] = None + self.screenshot_has_permission_setting: Optional[str] = None + self.download_has_permission_setting: Optional[str] = None + self.message_has_permission_setting: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "RestrictedModeSettingBuilder": + return RestrictedModeSettingBuilder() + + +class RestrictedModeSettingBuilder(object): + def __init__(self) -> None: + self._restricted_mode_setting = RestrictedModeSetting() + + def status(self, status: bool) -> "RestrictedModeSettingBuilder": + self._restricted_mode_setting.status = status + return self + + def screenshot_has_permission_setting(self, + screenshot_has_permission_setting: str) -> "RestrictedModeSettingBuilder": + self._restricted_mode_setting.screenshot_has_permission_setting = screenshot_has_permission_setting + return self + + def download_has_permission_setting(self, download_has_permission_setting: str) -> "RestrictedModeSettingBuilder": + self._restricted_mode_setting.download_has_permission_setting = download_has_permission_setting + return self + + def message_has_permission_setting(self, message_has_permission_setting: str) -> "RestrictedModeSettingBuilder": + self._restricted_mode_setting.message_has_permission_setting = message_has_permission_setting + return self + + def build(self) -> "RestrictedModeSetting": + return self._restricted_mode_setting diff --git a/lark_channel/api/im/v1/model/search_chat_request.py b/lark_channel/api/im/v1/model/search_chat_request.py new file mode 100644 index 0000000..fe5d933 --- /dev/null +++ b/lark_channel/api/im/v1/model/search_chat_request.py @@ -0,0 +1,49 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType + + +class SearchChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.query: Optional[str] = None + self.page_token: Optional[str] = None + self.page_size: Optional[int] = None + + @staticmethod + def builder() -> "SearchChatRequestBuilder": + return SearchChatRequestBuilder() + + +class SearchChatRequestBuilder(object): + + def __init__(self) -> None: + search_chat_request = SearchChatRequest() + search_chat_request.http_method = HttpMethod.GET + search_chat_request.uri = "/open-apis/im/v1/chats/search" + search_chat_request.token_types = {AccessTokenType.USER, AccessTokenType.TENANT} + self._search_chat_request: SearchChatRequest = search_chat_request + + def user_id_type(self, user_id_type: str) -> "SearchChatRequestBuilder": + self._search_chat_request.user_id_type = user_id_type + self._search_chat_request.add_query("user_id_type", user_id_type) + return self + + def query(self, query: str) -> "SearchChatRequestBuilder": + self._search_chat_request.query = query + self._search_chat_request.add_query("query", query) + return self + + def page_token(self, page_token: str) -> "SearchChatRequestBuilder": + self._search_chat_request.page_token = page_token + self._search_chat_request.add_query("page_token", page_token) + return self + + def page_size(self, page_size: int) -> "SearchChatRequestBuilder": + self._search_chat_request.page_size = page_size + self._search_chat_request.add_query("page_size", page_size) + return self + + def build(self) -> SearchChatRequest: + return self._search_chat_request diff --git a/lark_channel/api/im/v1/model/search_chat_response.py b/lark_channel/api/im/v1/model/search_chat_response.py new file mode 100644 index 0000000..480785c --- /dev/null +++ b/lark_channel/api/im/v1/model/search_chat_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .search_chat_response_body import SearchChatResponseBody + + +class SearchChatResponse(BaseResponse): + _types = { + "data": SearchChatResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[SearchChatResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/search_chat_response_body.py b/lark_channel/api/im/v1/model/search_chat_response_body.py new file mode 100644 index 0000000..029b434 --- /dev/null +++ b/lark_channel/api/im/v1/model/search_chat_response_body.py @@ -0,0 +1,41 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .list_chat import ListChat + + +class SearchChatResponseBody(object): + _types = { + "items": List[ListChat], + "page_token": str, + "has_more": bool, + } + + def __init__(self, d=None): + self.items: Optional[List[ListChat]] = None + self.page_token: Optional[str] = None + self.has_more: Optional[bool] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "SearchChatResponseBodyBuilder": + return SearchChatResponseBodyBuilder() + + +class SearchChatResponseBodyBuilder(object): + def __init__(self) -> None: + self._search_chat_response_body = SearchChatResponseBody() + + def items(self, items: List[ListChat]) -> "SearchChatResponseBodyBuilder": + self._search_chat_response_body.items = items + return self + + def page_token(self, page_token: str) -> "SearchChatResponseBodyBuilder": + self._search_chat_response_body.page_token = page_token + return self + + def has_more(self, has_more: bool) -> "SearchChatResponseBodyBuilder": + self._search_chat_response_body.has_more = has_more + return self + + def build(self) -> "SearchChatResponseBody": + return self._search_chat_response_body diff --git a/lark_channel/api/im/v1/model/sender.py b/lark_channel/api/im/v1/model/sender.py new file mode 100644 index 0000000..36589e3 --- /dev/null +++ b/lark_channel/api/im/v1/model/sender.py @@ -0,0 +1,46 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class Sender(object): + _types = { + "id": str, + "id_type": str, + "sender_type": str, + "tenant_key": str, + } + + def __init__(self, d=None): + self.id: Optional[str] = None + self.id_type: Optional[str] = None + self.sender_type: Optional[str] = None + self.tenant_key: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "SenderBuilder": + return SenderBuilder() + + +class SenderBuilder(object): + def __init__(self) -> None: + self._sender = Sender() + + def id(self, id: str) -> "SenderBuilder": + self._sender.id = id + return self + + def id_type(self, id_type: str) -> "SenderBuilder": + self._sender.id_type = id_type + return self + + def sender_type(self, sender_type: str) -> "SenderBuilder": + self._sender.sender_type = sender_type + return self + + def tenant_key(self, tenant_key: str) -> "SenderBuilder": + self._sender.tenant_key = tenant_key + return self + + def build(self) -> "Sender": + return self._sender diff --git a/lark_channel/api/im/v1/model/update_chat_request.py b/lark_channel/api/im/v1/model/update_chat_request.py new file mode 100644 index 0000000..24ec4ac --- /dev/null +++ b/lark_channel/api/im/v1/model/update_chat_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .update_chat_request_body import UpdateChatRequestBody + + +class UpdateChatRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.chat_id: Optional[str] = None + self.request_body: Optional[UpdateChatRequestBody] = None + + @staticmethod + def builder() -> "UpdateChatRequestBuilder": + return UpdateChatRequestBuilder() + + +class UpdateChatRequestBuilder(object): + + def __init__(self) -> None: + update_chat_request = UpdateChatRequest() + update_chat_request.http_method = HttpMethod.PUT + update_chat_request.uri = "/open-apis/im/v1/chats/:chat_id" + update_chat_request.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + self._update_chat_request: UpdateChatRequest = update_chat_request + + def user_id_type(self, user_id_type: str) -> "UpdateChatRequestBuilder": + self._update_chat_request.user_id_type = user_id_type + self._update_chat_request.add_query("user_id_type", user_id_type) + return self + + def chat_id(self, chat_id: str) -> "UpdateChatRequestBuilder": + self._update_chat_request.chat_id = chat_id + self._update_chat_request.paths["chat_id"] = str(chat_id) + return self + + def request_body(self, request_body: UpdateChatRequestBody) -> "UpdateChatRequestBuilder": + self._update_chat_request.request_body = request_body + self._update_chat_request.body = request_body + return self + + def build(self) -> UpdateChatRequest: + return self._update_chat_request diff --git a/lark_channel/api/im/v1/model/update_chat_request_body.py b/lark_channel/api/im/v1/model/update_chat_request_body.py new file mode 100644 index 0000000..b31a0f9 --- /dev/null +++ b/lark_channel/api/im/v1/model/update_chat_request_body.py @@ -0,0 +1,150 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .i18n_names import I18nNames +from .restricted_mode_setting import RestrictedModeSetting + + +class UpdateChatRequestBody(object): + _types = { + "avatar": str, + "name": str, + "description": str, + "i18n_names": I18nNames, + "add_member_permission": str, + "share_card_permission": str, + "at_all_permission": str, + "edit_permission": str, + "owner_id": str, + "join_message_visibility": str, + "leave_message_visibility": str, + "membership_approval": str, + "labels": List[str], + "toolkit_ids": List[int], + "restricted_mode_setting": RestrictedModeSetting, + "chat_type": str, + "group_message_type": str, + "urgent_setting": str, + "video_conference_setting": str, + "pin_manage_setting": str, + "hide_member_count_setting": str, + } + + def __init__(self, d=None): + self.avatar: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.i18n_names: Optional[I18nNames] = None + self.add_member_permission: Optional[str] = None + self.share_card_permission: Optional[str] = None + self.at_all_permission: Optional[str] = None + self.edit_permission: Optional[str] = None + self.owner_id: Optional[str] = None + self.join_message_visibility: Optional[str] = None + self.leave_message_visibility: Optional[str] = None + self.membership_approval: Optional[str] = None + self.labels: Optional[List[str]] = None + self.toolkit_ids: Optional[List[int]] = None + self.restricted_mode_setting: Optional[RestrictedModeSetting] = None + self.chat_type: Optional[str] = None + self.group_message_type: Optional[str] = None + self.urgent_setting: Optional[str] = None + self.video_conference_setting: Optional[str] = None + self.pin_manage_setting: Optional[str] = None + self.hide_member_count_setting: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateChatRequestBodyBuilder": + return UpdateChatRequestBodyBuilder() + + +class UpdateChatRequestBodyBuilder(object): + def __init__(self) -> None: + self._update_chat_request_body = UpdateChatRequestBody() + + def avatar(self, avatar: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.avatar = avatar + return self + + def name(self, name: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.name = name + return self + + def description(self, description: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.description = description + return self + + def i18n_names(self, i18n_names: I18nNames) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.i18n_names = i18n_names + return self + + def add_member_permission(self, add_member_permission: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.add_member_permission = add_member_permission + return self + + def share_card_permission(self, share_card_permission: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.share_card_permission = share_card_permission + return self + + def at_all_permission(self, at_all_permission: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.at_all_permission = at_all_permission + return self + + def edit_permission(self, edit_permission: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.edit_permission = edit_permission + return self + + def owner_id(self, owner_id: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.owner_id = owner_id + return self + + def join_message_visibility(self, join_message_visibility: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.join_message_visibility = join_message_visibility + return self + + def leave_message_visibility(self, leave_message_visibility: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.leave_message_visibility = leave_message_visibility + return self + + def membership_approval(self, membership_approval: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.membership_approval = membership_approval + return self + + def labels(self, labels: List[str]) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.labels = labels + return self + + def toolkit_ids(self, toolkit_ids: List[int]) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.toolkit_ids = toolkit_ids + return self + + def restricted_mode_setting(self, restricted_mode_setting: RestrictedModeSetting) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.restricted_mode_setting = restricted_mode_setting + return self + + def chat_type(self, chat_type: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.chat_type = chat_type + return self + + def group_message_type(self, group_message_type: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.group_message_type = group_message_type + return self + + def urgent_setting(self, urgent_setting: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.urgent_setting = urgent_setting + return self + + def video_conference_setting(self, video_conference_setting: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.video_conference_setting = video_conference_setting + return self + + def pin_manage_setting(self, pin_manage_setting: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.pin_manage_setting = pin_manage_setting + return self + + def hide_member_count_setting(self, hide_member_count_setting: str) -> "UpdateChatRequestBodyBuilder": + self._update_chat_request_body.hide_member_count_setting = hide_member_count_setting + return self + + def build(self) -> "UpdateChatRequestBody": + return self._update_chat_request_body diff --git a/lark_channel/api/im/v1/model/update_chat_response.py b/lark_channel/api/im/v1/model/update_chat_response.py new file mode 100644 index 0000000..3db817a --- /dev/null +++ b/lark_channel/api/im/v1/model/update_chat_response.py @@ -0,0 +1,14 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse + + +class UpdateChatResponse(BaseResponse): + _types = { + + } + + def __init__(self, d=None): + super().__init__(d) + + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/update_message_request.py b/lark_channel/api/im/v1/model/update_message_request.py new file mode 100644 index 0000000..e9946c2 --- /dev/null +++ b/lark_channel/api/im/v1/model/update_message_request.py @@ -0,0 +1,38 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .update_message_request_body import UpdateMessageRequestBody + + +class UpdateMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.message_id: Optional[str] = None + self.request_body: Optional[UpdateMessageRequestBody] = None + + @staticmethod + def builder() -> "UpdateMessageRequestBuilder": + return UpdateMessageRequestBuilder() + + +class UpdateMessageRequestBuilder(object): + + def __init__(self) -> None: + update_message_request = UpdateMessageRequest() + update_message_request.http_method = HttpMethod.PUT + update_message_request.uri = "/open-apis/im/v1/messages/:message_id" + update_message_request.token_types = {AccessTokenType.TENANT} + self._update_message_request: UpdateMessageRequest = update_message_request + + def message_id(self, message_id: str) -> "UpdateMessageRequestBuilder": + self._update_message_request.message_id = message_id + self._update_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: UpdateMessageRequestBody) -> "UpdateMessageRequestBuilder": + self._update_message_request.request_body = request_body + self._update_message_request.body = request_body + return self + + def build(self) -> UpdateMessageRequest: + return self._update_message_request diff --git a/lark_channel/api/im/v1/model/update_message_request_body.py b/lark_channel/api/im/v1/model/update_message_request_body.py new file mode 100644 index 0000000..7da0c9d --- /dev/null +++ b/lark_channel/api/im/v1/model/update_message_request_body.py @@ -0,0 +1,34 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UpdateMessageRequestBody(object): + _types = { + "msg_type": str, + "content": str, + } + + def __init__(self, d=None): + self.msg_type: Optional[str] = None + self.content: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateMessageRequestBodyBuilder": + return UpdateMessageRequestBodyBuilder() + + +class UpdateMessageRequestBodyBuilder(object): + def __init__(self) -> None: + self._update_message_request_body = UpdateMessageRequestBody() + + def msg_type(self, msg_type: str) -> "UpdateMessageRequestBodyBuilder": + self._update_message_request_body.msg_type = msg_type + return self + + def content(self, content: str) -> "UpdateMessageRequestBodyBuilder": + self._update_message_request_body.content = content + return self + + def build(self) -> "UpdateMessageRequestBody": + return self._update_message_request_body diff --git a/lark_channel/api/im/v1/model/update_message_response.py b/lark_channel/api/im/v1/model/update_message_response.py new file mode 100644 index 0000000..4173d98 --- /dev/null +++ b/lark_channel/api/im/v1/model/update_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .update_message_response_body import UpdateMessageResponseBody + + +class UpdateMessageResponse(BaseResponse): + _types = { + "data": UpdateMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[UpdateMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/update_message_response_body.py b/lark_channel/api/im/v1/model/update_message_response_body.py new file mode 100644 index 0000000..e74733c --- /dev/null +++ b/lark_channel/api/im/v1/model/update_message_response_body.py @@ -0,0 +1,109 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from .sender import Sender +from .message_body import MessageBody +from .mention import Mention + + +class UpdateMessageResponseBody(object): + _types = { + "message_id": str, + "root_id": str, + "parent_id": str, + "thread_id": str, + "msg_type": str, + "create_time": int, + "update_time": int, + "deleted": bool, + "updated": bool, + "chat_id": str, + "sender": Sender, + "body": MessageBody, + "mentions": List[Mention], + "upper_message_id": str, + } + + def __init__(self, d=None): + self.message_id: Optional[str] = None + self.root_id: Optional[str] = None + self.parent_id: Optional[str] = None + self.thread_id: Optional[str] = None + self.msg_type: Optional[str] = None + self.create_time: Optional[int] = None + self.update_time: Optional[int] = None + self.deleted: Optional[bool] = None + self.updated: Optional[bool] = None + self.chat_id: Optional[str] = None + self.sender: Optional[Sender] = None + self.body: Optional[MessageBody] = None + self.mentions: Optional[List[Mention]] = None + self.upper_message_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UpdateMessageResponseBodyBuilder": + return UpdateMessageResponseBodyBuilder() + + +class UpdateMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._update_message_response_body = UpdateMessageResponseBody() + + def message_id(self, message_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.message_id = message_id + return self + + def root_id(self, root_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.root_id = root_id + return self + + def parent_id(self, parent_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.parent_id = parent_id + return self + + def thread_id(self, thread_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.thread_id = thread_id + return self + + def msg_type(self, msg_type: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.msg_type = msg_type + return self + + def create_time(self, create_time: int) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.create_time = create_time + return self + + def update_time(self, update_time: int) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.update_time = update_time + return self + + def deleted(self, deleted: bool) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.deleted = deleted + return self + + def updated(self, updated: bool) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.updated = updated + return self + + def chat_id(self, chat_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.chat_id = chat_id + return self + + def sender(self, sender: Sender) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.sender = sender + return self + + def body(self, body: MessageBody) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.body = body + return self + + def mentions(self, mentions: List[Mention]) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.mentions = mentions + return self + + def upper_message_id(self, upper_message_id: str) -> "UpdateMessageResponseBodyBuilder": + self._update_message_response_body.upper_message_id = upper_message_id + return self + + def build(self) -> "UpdateMessageResponseBody": + return self._update_message_response_body diff --git a/lark_channel/api/im/v1/model/urgent_app_message_request.py b/lark_channel/api/im/v1/model/urgent_app_message_request.py new file mode 100644 index 0000000..8f2513d --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_app_message_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .urgent_receivers import UrgentReceivers + + +class UrgentAppMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.message_id: Optional[str] = None + self.request_body: Optional[UrgentReceivers] = None + + @staticmethod + def builder() -> "UrgentAppMessageRequestBuilder": + return UrgentAppMessageRequestBuilder() + + +class UrgentAppMessageRequestBuilder(object): + + def __init__(self) -> None: + urgent_app_message_request = UrgentAppMessageRequest() + urgent_app_message_request.http_method = HttpMethod.PATCH + urgent_app_message_request.uri = "/open-apis/im/v1/messages/:message_id/urgent_app" + urgent_app_message_request.token_types = {AccessTokenType.TENANT} + self._urgent_app_message_request: UrgentAppMessageRequest = urgent_app_message_request + + def user_id_type(self, user_id_type: str) -> "UrgentAppMessageRequestBuilder": + self._urgent_app_message_request.user_id_type = user_id_type + self._urgent_app_message_request.add_query("user_id_type", user_id_type) + return self + + def message_id(self, message_id: str) -> "UrgentAppMessageRequestBuilder": + self._urgent_app_message_request.message_id = message_id + self._urgent_app_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: UrgentReceivers) -> "UrgentAppMessageRequestBuilder": + self._urgent_app_message_request.request_body = request_body + self._urgent_app_message_request.body = request_body + return self + + def build(self) -> UrgentAppMessageRequest: + return self._urgent_app_message_request diff --git a/lark_channel/api/im/v1/model/urgent_app_message_response.py b/lark_channel/api/im/v1/model/urgent_app_message_response.py new file mode 100644 index 0000000..b217a5e --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_app_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .urgent_app_message_response_body import UrgentAppMessageResponseBody + + +class UrgentAppMessageResponse(BaseResponse): + _types = { + "data": UrgentAppMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[UrgentAppMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/urgent_app_message_response_body.py b/lark_channel/api/im/v1/model/urgent_app_message_response_body.py new file mode 100644 index 0000000..6ad0db1 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_app_message_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UrgentAppMessageResponseBody(object): + _types = { + "invalid_user_id_list": List[str], + } + + def __init__(self, d=None): + self.invalid_user_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UrgentAppMessageResponseBodyBuilder": + return UrgentAppMessageResponseBodyBuilder() + + +class UrgentAppMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._urgent_app_message_response_body = UrgentAppMessageResponseBody() + + def invalid_user_id_list(self, invalid_user_id_list: List[str]) -> "UrgentAppMessageResponseBodyBuilder": + self._urgent_app_message_response_body.invalid_user_id_list = invalid_user_id_list + return self + + def build(self) -> "UrgentAppMessageResponseBody": + return self._urgent_app_message_response_body diff --git a/lark_channel/api/im/v1/model/urgent_phone_message_request.py b/lark_channel/api/im/v1/model/urgent_phone_message_request.py new file mode 100644 index 0000000..fc12567 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_phone_message_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .urgent_receivers import UrgentReceivers + + +class UrgentPhoneMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.message_id: Optional[str] = None + self.request_body: Optional[UrgentReceivers] = None + + @staticmethod + def builder() -> "UrgentPhoneMessageRequestBuilder": + return UrgentPhoneMessageRequestBuilder() + + +class UrgentPhoneMessageRequestBuilder(object): + + def __init__(self) -> None: + urgent_phone_message_request = UrgentPhoneMessageRequest() + urgent_phone_message_request.http_method = HttpMethod.PATCH + urgent_phone_message_request.uri = "/open-apis/im/v1/messages/:message_id/urgent_phone" + urgent_phone_message_request.token_types = {AccessTokenType.TENANT} + self._urgent_phone_message_request: UrgentPhoneMessageRequest = urgent_phone_message_request + + def user_id_type(self, user_id_type: str) -> "UrgentPhoneMessageRequestBuilder": + self._urgent_phone_message_request.user_id_type = user_id_type + self._urgent_phone_message_request.add_query("user_id_type", user_id_type) + return self + + def message_id(self, message_id: str) -> "UrgentPhoneMessageRequestBuilder": + self._urgent_phone_message_request.message_id = message_id + self._urgent_phone_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: UrgentReceivers) -> "UrgentPhoneMessageRequestBuilder": + self._urgent_phone_message_request.request_body = request_body + self._urgent_phone_message_request.body = request_body + return self + + def build(self) -> UrgentPhoneMessageRequest: + return self._urgent_phone_message_request diff --git a/lark_channel/api/im/v1/model/urgent_phone_message_response.py b/lark_channel/api/im/v1/model/urgent_phone_message_response.py new file mode 100644 index 0000000..876e4c9 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_phone_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .urgent_phone_message_response_body import UrgentPhoneMessageResponseBody + + +class UrgentPhoneMessageResponse(BaseResponse): + _types = { + "data": UrgentPhoneMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[UrgentPhoneMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/urgent_phone_message_response_body.py b/lark_channel/api/im/v1/model/urgent_phone_message_response_body.py new file mode 100644 index 0000000..54aab50 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_phone_message_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UrgentPhoneMessageResponseBody(object): + _types = { + "invalid_user_id_list": List[str], + } + + def __init__(self, d=None): + self.invalid_user_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UrgentPhoneMessageResponseBodyBuilder": + return UrgentPhoneMessageResponseBodyBuilder() + + +class UrgentPhoneMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._urgent_phone_message_response_body = UrgentPhoneMessageResponseBody() + + def invalid_user_id_list(self, invalid_user_id_list: List[str]) -> "UrgentPhoneMessageResponseBodyBuilder": + self._urgent_phone_message_response_body.invalid_user_id_list = invalid_user_id_list + return self + + def build(self) -> "UrgentPhoneMessageResponseBody": + return self._urgent_phone_message_response_body diff --git a/lark_channel/api/im/v1/model/urgent_receivers.py b/lark_channel/api/im/v1/model/urgent_receivers.py new file mode 100644 index 0000000..4312850 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_receivers.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UrgentReceivers(object): + _types = { + "user_id_list": List[str], + } + + def __init__(self, d=None): + self.user_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UrgentReceiversBuilder": + return UrgentReceiversBuilder() + + +class UrgentReceiversBuilder(object): + def __init__(self) -> None: + self._urgent_receivers = UrgentReceivers() + + def user_id_list(self, user_id_list: List[str]) -> "UrgentReceiversBuilder": + self._urgent_receivers.user_id_list = user_id_list + return self + + def build(self) -> "UrgentReceivers": + return self._urgent_receivers diff --git a/lark_channel/api/im/v1/model/urgent_sms_message_request.py b/lark_channel/api/im/v1/model/urgent_sms_message_request.py new file mode 100644 index 0000000..a936513 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_sms_message_request.py @@ -0,0 +1,44 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.model import BaseRequest +from lark_channel.core.enum import HttpMethod, AccessTokenType +from .urgent_receivers import UrgentReceivers + + +class UrgentSmsMessageRequest(BaseRequest): + def __init__(self) -> None: + super().__init__() + self.user_id_type: Optional[str] = None + self.message_id: Optional[str] = None + self.request_body: Optional[UrgentReceivers] = None + + @staticmethod + def builder() -> "UrgentSmsMessageRequestBuilder": + return UrgentSmsMessageRequestBuilder() + + +class UrgentSmsMessageRequestBuilder(object): + + def __init__(self) -> None: + urgent_sms_message_request = UrgentSmsMessageRequest() + urgent_sms_message_request.http_method = HttpMethod.PATCH + urgent_sms_message_request.uri = "/open-apis/im/v1/messages/:message_id/urgent_sms" + urgent_sms_message_request.token_types = {AccessTokenType.TENANT} + self._urgent_sms_message_request: UrgentSmsMessageRequest = urgent_sms_message_request + + def user_id_type(self, user_id_type: str) -> "UrgentSmsMessageRequestBuilder": + self._urgent_sms_message_request.user_id_type = user_id_type + self._urgent_sms_message_request.add_query("user_id_type", user_id_type) + return self + + def message_id(self, message_id: str) -> "UrgentSmsMessageRequestBuilder": + self._urgent_sms_message_request.message_id = message_id + self._urgent_sms_message_request.paths["message_id"] = str(message_id) + return self + + def request_body(self, request_body: UrgentReceivers) -> "UrgentSmsMessageRequestBuilder": + self._urgent_sms_message_request.request_body = request_body + self._urgent_sms_message_request.body = request_body + return self + + def build(self) -> UrgentSmsMessageRequest: + return self._urgent_sms_message_request diff --git a/lark_channel/api/im/v1/model/urgent_sms_message_response.py b/lark_channel/api/im/v1/model/urgent_sms_message_response.py new file mode 100644 index 0000000..740b7b5 --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_sms_message_response.py @@ -0,0 +1,15 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init +from lark_channel.core.model import BaseResponse +from .urgent_sms_message_response_body import UrgentSmsMessageResponseBody + + +class UrgentSmsMessageResponse(BaseResponse): + _types = { + "data": UrgentSmsMessageResponseBody + } + + def __init__(self, d=None): + super().__init__(d) + self.data: Optional[UrgentSmsMessageResponseBody] = None + init(self, d, self._types) diff --git a/lark_channel/api/im/v1/model/urgent_sms_message_response_body.py b/lark_channel/api/im/v1/model/urgent_sms_message_response_body.py new file mode 100644 index 0000000..2803cab --- /dev/null +++ b/lark_channel/api/im/v1/model/urgent_sms_message_response_body.py @@ -0,0 +1,28 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UrgentSmsMessageResponseBody(object): + _types = { + "invalid_user_id_list": List[str], + } + + def __init__(self, d=None): + self.invalid_user_id_list: Optional[List[str]] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UrgentSmsMessageResponseBodyBuilder": + return UrgentSmsMessageResponseBodyBuilder() + + +class UrgentSmsMessageResponseBodyBuilder(object): + def __init__(self) -> None: + self._urgent_sms_message_response_body = UrgentSmsMessageResponseBody() + + def invalid_user_id_list(self, invalid_user_id_list: List[str]) -> "UrgentSmsMessageResponseBodyBuilder": + self._urgent_sms_message_response_body.invalid_user_id_list = invalid_user_id_list + return self + + def build(self) -> "UrgentSmsMessageResponseBody": + return self._urgent_sms_message_response_body diff --git a/lark_channel/api/im/v1/model/user_id.py b/lark_channel/api/im/v1/model/user_id.py new file mode 100644 index 0000000..67ae276 --- /dev/null +++ b/lark_channel/api/im/v1/model/user_id.py @@ -0,0 +1,40 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.construct import init + + +class UserId(object): + _types = { + "user_id": str, + "open_id": str, + "union_id": str, + } + + def __init__(self, d=None): + self.user_id: Optional[str] = None + self.open_id: Optional[str] = None + self.union_id: Optional[str] = None + init(self, d, self._types) + + @staticmethod + def builder() -> "UserIdBuilder": + return UserIdBuilder() + + +class UserIdBuilder(object): + def __init__(self) -> None: + self._user_id = UserId() + + def user_id(self, user_id: str) -> "UserIdBuilder": + self._user_id.user_id = user_id + return self + + def open_id(self, open_id: str) -> "UserIdBuilder": + self._user_id.open_id = open_id + return self + + def union_id(self, union_id: str) -> "UserIdBuilder": + self._user_id.union_id = union_id + return self + + def build(self) -> "UserId": + return self._user_id diff --git a/lark_channel/api/im/v1/processor.py b/lark_channel/api/im/v1/processor.py new file mode 100644 index 0000000..f4443c8 --- /dev/null +++ b/lark_channel/api/im/v1/processor.py @@ -0,0 +1,159 @@ +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type + +from lark_channel.event.processor import IEventProcessor +from .model.p2_im_chat_disbanded_v1 import P2ImChatDisbandedV1 +from .model.p2_im_chat_updated_v1 import P2ImChatUpdatedV1 +from .model.p2_im_chat_access_event_bot_p2p_chat_entered_v1 import P2ImChatAccessEventBotP2pChatEnteredV1 +from .model.p2_im_chat_member_bot_added_v1 import P2ImChatMemberBotAddedV1 +from .model.p2_im_chat_member_bot_deleted_v1 import P2ImChatMemberBotDeletedV1 +from .model.p2_im_chat_member_user_added_v1 import P2ImChatMemberUserAddedV1 +from .model.p2_im_chat_member_user_deleted_v1 import P2ImChatMemberUserDeletedV1 +from .model.p2_im_chat_member_user_withdrawn_v1 import P2ImChatMemberUserWithdrawnV1 +from .model.p2_im_message_message_read_v1 import P2ImMessageMessageReadV1 +from .model.p2_im_message_recalled_v1 import P2ImMessageRecalledV1 +from .model.p2_im_message_receive_v1 import P2ImMessageReceiveV1 +from .model.p2_im_message_reaction_created_v1 import P2ImMessageReactionCreatedV1 +from .model.p2_im_message_reaction_deleted_v1 import P2ImMessageReactionDeletedV1 + + +class P2ImChatDisbandedV1Processor(IEventProcessor[P2ImChatDisbandedV1]): + def __init__(self, f: Callable[[P2ImChatDisbandedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatDisbandedV1]: + return P2ImChatDisbandedV1 + + def do(self, data: P2ImChatDisbandedV1) -> None: + self.f(data) + + +class P2ImChatUpdatedV1Processor(IEventProcessor[P2ImChatUpdatedV1]): + def __init__(self, f: Callable[[P2ImChatUpdatedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatUpdatedV1]: + return P2ImChatUpdatedV1 + + def do(self, data: P2ImChatUpdatedV1) -> None: + self.f(data) + + +class P2ImChatAccessEventBotP2pChatEnteredV1Processor(IEventProcessor[P2ImChatAccessEventBotP2pChatEnteredV1]): + def __init__(self, f: Callable[[P2ImChatAccessEventBotP2pChatEnteredV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatAccessEventBotP2pChatEnteredV1]: + return P2ImChatAccessEventBotP2pChatEnteredV1 + + def do(self, data: P2ImChatAccessEventBotP2pChatEnteredV1) -> None: + self.f(data) + + +class P2ImChatMemberBotAddedV1Processor(IEventProcessor[P2ImChatMemberBotAddedV1]): + def __init__(self, f: Callable[[P2ImChatMemberBotAddedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatMemberBotAddedV1]: + return P2ImChatMemberBotAddedV1 + + def do(self, data: P2ImChatMemberBotAddedV1) -> None: + self.f(data) + + +class P2ImChatMemberBotDeletedV1Processor(IEventProcessor[P2ImChatMemberBotDeletedV1]): + def __init__(self, f: Callable[[P2ImChatMemberBotDeletedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatMemberBotDeletedV1]: + return P2ImChatMemberBotDeletedV1 + + def do(self, data: P2ImChatMemberBotDeletedV1) -> None: + self.f(data) + + +class P2ImChatMemberUserAddedV1Processor(IEventProcessor[P2ImChatMemberUserAddedV1]): + def __init__(self, f: Callable[[P2ImChatMemberUserAddedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatMemberUserAddedV1]: + return P2ImChatMemberUserAddedV1 + + def do(self, data: P2ImChatMemberUserAddedV1) -> None: + self.f(data) + + +class P2ImChatMemberUserDeletedV1Processor(IEventProcessor[P2ImChatMemberUserDeletedV1]): + def __init__(self, f: Callable[[P2ImChatMemberUserDeletedV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatMemberUserDeletedV1]: + return P2ImChatMemberUserDeletedV1 + + def do(self, data: P2ImChatMemberUserDeletedV1) -> None: + self.f(data) + + +class P2ImChatMemberUserWithdrawnV1Processor(IEventProcessor[P2ImChatMemberUserWithdrawnV1]): + def __init__(self, f: Callable[[P2ImChatMemberUserWithdrawnV1], None]): + self.f = f + + def type(self) -> Type[P2ImChatMemberUserWithdrawnV1]: + return P2ImChatMemberUserWithdrawnV1 + + def do(self, data: P2ImChatMemberUserWithdrawnV1) -> None: + self.f(data) + + +class P2ImMessageMessageReadV1Processor(IEventProcessor[P2ImMessageMessageReadV1]): + def __init__(self, f: Callable[[P2ImMessageMessageReadV1], None]): + self.f = f + + def type(self) -> Type[P2ImMessageMessageReadV1]: + return P2ImMessageMessageReadV1 + + def do(self, data: P2ImMessageMessageReadV1) -> None: + self.f(data) + + +class P2ImMessageRecalledV1Processor(IEventProcessor[P2ImMessageRecalledV1]): + def __init__(self, f: Callable[[P2ImMessageRecalledV1], None]): + self.f = f + + def type(self) -> Type[P2ImMessageRecalledV1]: + return P2ImMessageRecalledV1 + + def do(self, data: P2ImMessageRecalledV1) -> None: + self.f(data) + + +class P2ImMessageReceiveV1Processor(IEventProcessor[P2ImMessageReceiveV1]): + def __init__(self, f: Callable[[P2ImMessageReceiveV1], None]): + self.f = f + + def type(self) -> Type[P2ImMessageReceiveV1]: + return P2ImMessageReceiveV1 + + def do(self, data: P2ImMessageReceiveV1) -> None: + self.f(data) + + +class P2ImMessageReactionCreatedV1Processor(IEventProcessor[P2ImMessageReactionCreatedV1]): + def __init__(self, f: Callable[[P2ImMessageReactionCreatedV1], None]): + self.f = f + + def type(self) -> Type[P2ImMessageReactionCreatedV1]: + return P2ImMessageReactionCreatedV1 + + def do(self, data: P2ImMessageReactionCreatedV1) -> None: + self.f(data) + + +class P2ImMessageReactionDeletedV1Processor(IEventProcessor[P2ImMessageReactionDeletedV1]): + def __init__(self, f: Callable[[P2ImMessageReactionDeletedV1], None]): + self.f = f + + def type(self) -> Type[P2ImMessageReactionDeletedV1]: + return P2ImMessageReactionDeletedV1 + + def do(self, data: P2ImMessageReactionDeletedV1) -> None: + self.f(data) diff --git a/lark_channel/api/im/v1/resource/__init__.py b/lark_channel/api/im/v1/resource/__init__.py new file mode 100644 index 0000000..8424da1 --- /dev/null +++ b/lark_channel/api/im/v1/resource/__init__.py @@ -0,0 +1,15 @@ +from .chat import Chat +from .file import File +from .image import Image +from .message import Message +from .message_reaction import MessageReaction +from .message_resource import MessageResource + +__all__ = [ + "Chat", + "File", + "Image", + "Message", + "MessageReaction", + "MessageResource", +] diff --git a/lark_channel/api/im/v1/resource/chat.py b/lark_channel/api/im/v1/resource/chat.py new file mode 100644 index 0000000..dcc0a41 --- /dev/null +++ b/lark_channel/api/im/v1/resource/chat.py @@ -0,0 +1,280 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.create_chat_request import CreateChatRequest +from ..model.create_chat_response import CreateChatResponse +from ..model.delete_chat_request import DeleteChatRequest +from ..model.delete_chat_response import DeleteChatResponse +from ..model.get_chat_request import GetChatRequest +from ..model.get_chat_response import GetChatResponse +from ..model.link_chat_request import LinkChatRequest +from ..model.link_chat_response import LinkChatResponse +from ..model.list_chat_request import ListChatRequest +from ..model.list_chat_response import ListChatResponse +from ..model.search_chat_request import SearchChatRequest +from ..model.search_chat_response import SearchChatResponse +from ..model.update_chat_request import UpdateChatRequest +from ..model.update_chat_response import UpdateChatResponse + + +class Chat(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def create(self, request: CreateChatRequest, option: Optional[RequestOption] = None) -> CreateChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateChatResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateChatResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateChatRequest, option: Optional[RequestOption] = None) -> CreateChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateChatResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateChatResponse) + response.raw = resp + + return response + + def delete(self, request: DeleteChatRequest, option: Optional[RequestOption] = None) -> DeleteChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: DeleteChatResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteChatResponse) + response.raw = resp + + return response + + async def adelete(self, request: DeleteChatRequest, option: Optional[RequestOption] = None) -> DeleteChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: DeleteChatResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteChatResponse) + response.raw = resp + + return response + + def get(self, request: GetChatRequest, option: Optional[RequestOption] = None) -> GetChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: GetChatResponse = JSON.unmarshal(str(resp.content, UTF_8), GetChatResponse) + response.raw = resp + + return response + + async def aget(self, request: GetChatRequest, option: Optional[RequestOption] = None) -> GetChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: GetChatResponse = JSON.unmarshal(str(resp.content, UTF_8), GetChatResponse) + response.raw = resp + + return response + + def link(self, request: LinkChatRequest, option: Optional[RequestOption] = None) -> LinkChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: LinkChatResponse = JSON.unmarshal(str(resp.content, UTF_8), LinkChatResponse) + response.raw = resp + + return response + + async def alink(self, request: LinkChatRequest, option: Optional[RequestOption] = None) -> LinkChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: LinkChatResponse = JSON.unmarshal(str(resp.content, UTF_8), LinkChatResponse) + response.raw = resp + + return response + + def list(self, request: ListChatRequest, option: Optional[RequestOption] = None) -> ListChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ListChatResponse = JSON.unmarshal(str(resp.content, UTF_8), ListChatResponse) + response.raw = resp + + return response + + async def alist(self, request: ListChatRequest, option: Optional[RequestOption] = None) -> ListChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ListChatResponse = JSON.unmarshal(str(resp.content, UTF_8), ListChatResponse) + response.raw = resp + + return response + + def search(self, request: SearchChatRequest, option: Optional[RequestOption] = None) -> SearchChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: SearchChatResponse = JSON.unmarshal(str(resp.content, UTF_8), SearchChatResponse) + response.raw = resp + + return response + + async def asearch(self, request: SearchChatRequest, option: Optional[RequestOption] = None) -> SearchChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: SearchChatResponse = JSON.unmarshal(str(resp.content, UTF_8), SearchChatResponse) + response.raw = resp + + return response + + def update(self, request: UpdateChatRequest, option: Optional[RequestOption] = None) -> UpdateChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateChatResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateChatResponse) + response.raw = resp + + return response + + async def aupdate(self, request: UpdateChatRequest, option: Optional[RequestOption] = None) -> UpdateChatResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateChatResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateChatResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/im/v1/resource/file.py b/lark_channel/api/im/v1/resource/file.py new file mode 100644 index 0000000..daaf7b9 --- /dev/null +++ b/lark_channel/api/im/v1/resource/file.py @@ -0,0 +1,109 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.create_file_request import CreateFileRequest +from ..model.create_file_response import CreateFileResponse +from ..model.get_file_request import GetFileRequest +from ..model.get_file_response import GetFileResponse + + +class File(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def create(self, request: CreateFileRequest, option: Optional[RequestOption] = None) -> CreateFileResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + form_data = MultipartEncoder(Files.parse_form_data(request.body)) + request.body = form_data + option.headers[CONTENT_TYPE] = form_data.content_type + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateFileResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateFileResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateFileRequest, option: Optional[RequestOption] = None) -> CreateFileResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Parse the file + request.files = Files.extract_files(request.body) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateFileResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateFileResponse) + response.raw = resp + + return response + + def get(self, request: GetFileRequest, option: Optional[RequestOption] = None) -> GetFileResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetFileResponse = GetFileResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetFileResponse) + + response.raw = resp + return response + + async def aget(self, request: GetFileRequest, option: Optional[RequestOption] = None) -> GetFileResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetFileResponse = GetFileResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetFileResponse) + + response.raw = resp + return response diff --git a/lark_channel/api/im/v1/resource/image.py b/lark_channel/api/im/v1/resource/image.py new file mode 100644 index 0000000..f805648 --- /dev/null +++ b/lark_channel/api/im/v1/resource/image.py @@ -0,0 +1,109 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.create_image_request import CreateImageRequest +from ..model.create_image_response import CreateImageResponse +from ..model.get_image_request import GetImageRequest +from ..model.get_image_response import GetImageResponse + + +class Image(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def create(self, request: CreateImageRequest, option: Optional[RequestOption] = None) -> CreateImageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + form_data = MultipartEncoder(Files.parse_form_data(request.body)) + request.body = form_data + option.headers[CONTENT_TYPE] = form_data.content_type + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateImageResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateImageResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateImageRequest, option: Optional[RequestOption] = None) -> CreateImageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Parse the file + request.files = Files.extract_files(request.body) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateImageResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateImageResponse) + response.raw = resp + + return response + + def get(self, request: GetImageRequest, option: Optional[RequestOption] = None) -> GetImageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetImageResponse = GetImageResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetImageResponse) + + response.raw = resp + return response + + async def aget(self, request: GetImageRequest, option: Optional[RequestOption] = None) -> GetImageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetImageResponse = GetImageResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetImageResponse) + + response.raw = resp + return response diff --git a/lark_channel/api/im/v1/resource/message.py b/lark_channel/api/im/v1/resource/message.py new file mode 100644 index 0000000..ce0af81 --- /dev/null +++ b/lark_channel/api/im/v1/resource/message.py @@ -0,0 +1,564 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.create_message_request import CreateMessageRequest +from ..model.create_message_response import CreateMessageResponse +from ..model.delete_message_request import DeleteMessageRequest +from ..model.delete_message_response import DeleteMessageResponse +from ..model.forward_message_request import ForwardMessageRequest +from ..model.forward_message_response import ForwardMessageResponse +from ..model.get_message_request import GetMessageRequest +from ..model.get_message_response import GetMessageResponse +from ..model.list_message_request import ListMessageRequest +from ..model.list_message_response import ListMessageResponse +from ..model.merge_forward_message_request import MergeForwardMessageRequest +from ..model.merge_forward_message_response import MergeForwardMessageResponse +from ..model.patch_message_request import PatchMessageRequest +from ..model.patch_message_response import PatchMessageResponse +from ..model.push_follow_up_message_request import PushFollowUpMessageRequest +from ..model.push_follow_up_message_response import PushFollowUpMessageResponse +from ..model.read_users_message_request import ReadUsersMessageRequest +from ..model.read_users_message_response import ReadUsersMessageResponse +from ..model.reply_message_request import ReplyMessageRequest +from ..model.reply_message_response import ReplyMessageResponse +from ..model.update_message_request import UpdateMessageRequest +from ..model.update_message_response import UpdateMessageResponse +from ..model.urgent_app_message_request import UrgentAppMessageRequest +from ..model.urgent_app_message_response import UrgentAppMessageResponse +from ..model.urgent_phone_message_request import UrgentPhoneMessageRequest +from ..model.urgent_phone_message_response import UrgentPhoneMessageResponse +from ..model.urgent_sms_message_request import UrgentSmsMessageRequest +from ..model.urgent_sms_message_response import UrgentSmsMessageResponse + + +class Message(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def create(self, request: CreateMessageRequest, option: Optional[RequestOption] = None) -> CreateMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateMessageResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateMessageRequest, + option: Optional[RequestOption] = None) -> CreateMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), CreateMessageResponse) + response.raw = resp + + return response + + def delete(self, request: DeleteMessageRequest, option: Optional[RequestOption] = None) -> DeleteMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: DeleteMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteMessageResponse) + response.raw = resp + + return response + + async def adelete(self, request: DeleteMessageRequest, + option: Optional[RequestOption] = None) -> DeleteMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: DeleteMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), DeleteMessageResponse) + response.raw = resp + + return response + + def forward(self, request: ForwardMessageRequest, option: Optional[RequestOption] = None) -> ForwardMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ForwardMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ForwardMessageResponse) + response.raw = resp + + return response + + async def aforward(self, request: ForwardMessageRequest, + option: Optional[RequestOption] = None) -> ForwardMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ForwardMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ForwardMessageResponse) + response.raw = resp + + return response + + def get(self, request: GetMessageRequest, option: Optional[RequestOption] = None) -> GetMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: GetMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), GetMessageResponse) + response.raw = resp + + return response + + async def aget(self, request: GetMessageRequest, option: Optional[RequestOption] = None) -> GetMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: GetMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), GetMessageResponse) + response.raw = resp + + return response + + def list(self, request: ListMessageRequest, option: Optional[RequestOption] = None) -> ListMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ListMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ListMessageResponse) + response.raw = resp + + return response + + async def alist(self, request: ListMessageRequest, option: Optional[RequestOption] = None) -> ListMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ListMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ListMessageResponse) + response.raw = resp + + return response + + def merge_forward(self, request: MergeForwardMessageRequest, + option: Optional[RequestOption] = None) -> MergeForwardMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: MergeForwardMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), MergeForwardMessageResponse) + response.raw = resp + + return response + + async def amerge_forward(self, request: MergeForwardMessageRequest, + option: Optional[RequestOption] = None) -> MergeForwardMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: MergeForwardMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), MergeForwardMessageResponse) + response.raw = resp + + return response + + def patch(self, request: PatchMessageRequest, option: Optional[RequestOption] = None) -> PatchMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: PatchMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchMessageResponse) + response.raw = resp + + return response + + async def apatch(self, request: PatchMessageRequest, + option: Optional[RequestOption] = None) -> PatchMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: PatchMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), PatchMessageResponse) + response.raw = resp + + return response + + def push_follow_up(self, request: PushFollowUpMessageRequest, + option: Optional[RequestOption] = None) -> PushFollowUpMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: PushFollowUpMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), PushFollowUpMessageResponse) + response.raw = resp + + return response + + async def apush_follow_up(self, request: PushFollowUpMessageRequest, + option: Optional[RequestOption] = None) -> PushFollowUpMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: PushFollowUpMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), PushFollowUpMessageResponse) + response.raw = resp + + return response + + def read_users(self, request: ReadUsersMessageRequest, + option: Optional[RequestOption] = None) -> ReadUsersMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ReadUsersMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ReadUsersMessageResponse) + response.raw = resp + + return response + + async def aread_users(self, request: ReadUsersMessageRequest, + option: Optional[RequestOption] = None) -> ReadUsersMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ReadUsersMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ReadUsersMessageResponse) + response.raw = resp + + return response + + def reply(self, request: ReplyMessageRequest, option: Optional[RequestOption] = None) -> ReplyMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ReplyMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ReplyMessageResponse) + response.raw = resp + + return response + + async def areply(self, request: ReplyMessageRequest, + option: Optional[RequestOption] = None) -> ReplyMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ReplyMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), ReplyMessageResponse) + response.raw = resp + + return response + + def update(self, request: UpdateMessageRequest, option: Optional[RequestOption] = None) -> UpdateMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UpdateMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateMessageResponse) + response.raw = resp + + return response + + async def aupdate(self, request: UpdateMessageRequest, + option: Optional[RequestOption] = None) -> UpdateMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UpdateMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UpdateMessageResponse) + response.raw = resp + + return response + + def urgent_app(self, request: UrgentAppMessageRequest, + option: Optional[RequestOption] = None) -> UrgentAppMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UrgentAppMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentAppMessageResponse) + response.raw = resp + + return response + + async def aurgent_app(self, request: UrgentAppMessageRequest, + option: Optional[RequestOption] = None) -> UrgentAppMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UrgentAppMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentAppMessageResponse) + response.raw = resp + + return response + + def urgent_phone(self, request: UrgentPhoneMessageRequest, + option: Optional[RequestOption] = None) -> UrgentPhoneMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UrgentPhoneMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentPhoneMessageResponse) + response.raw = resp + + return response + + async def aurgent_phone(self, request: UrgentPhoneMessageRequest, + option: Optional[RequestOption] = None) -> UrgentPhoneMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UrgentPhoneMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentPhoneMessageResponse) + response.raw = resp + + return response + + def urgent_sms(self, request: UrgentSmsMessageRequest, + option: Optional[RequestOption] = None) -> UrgentSmsMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: UrgentSmsMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentSmsMessageResponse) + response.raw = resp + + return response + + async def aurgent_sms(self, request: UrgentSmsMessageRequest, + option: Optional[RequestOption] = None) -> UrgentSmsMessageResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: UrgentSmsMessageResponse = JSON.unmarshal(str(resp.content, UTF_8), UrgentSmsMessageResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/im/v1/resource/message_reaction.py b/lark_channel/api/im/v1/resource/message_reaction.py new file mode 100644 index 0000000..dbcace3 --- /dev/null +++ b/lark_channel/api/im/v1/resource/message_reaction.py @@ -0,0 +1,138 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.create_message_reaction_request import CreateMessageReactionRequest +from ..model.create_message_reaction_response import CreateMessageReactionResponse +from ..model.delete_message_reaction_request import DeleteMessageReactionRequest +from ..model.delete_message_reaction_response import DeleteMessageReactionResponse +from ..model.list_message_reaction_request import ListMessageReactionRequest +from ..model.list_message_reaction_response import ListMessageReactionResponse + + +class MessageReaction(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def create(self, request: CreateMessageReactionRequest, + option: Optional[RequestOption] = None) -> CreateMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: CreateMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), + CreateMessageReactionResponse) + response.raw = resp + + return response + + async def acreate(self, request: CreateMessageReactionRequest, + option: Optional[RequestOption] = None) -> CreateMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: CreateMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), + CreateMessageReactionResponse) + response.raw = resp + + return response + + def delete(self, request: DeleteMessageReactionRequest, + option: Optional[RequestOption] = None) -> DeleteMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: DeleteMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), + DeleteMessageReactionResponse) + response.raw = resp + + return response + + async def adelete(self, request: DeleteMessageReactionRequest, + option: Optional[RequestOption] = None) -> DeleteMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: DeleteMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), + DeleteMessageReactionResponse) + response.raw = resp + + return response + + def list(self, request: ListMessageReactionRequest, + option: Optional[RequestOption] = None) -> ListMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Deserialize the response + response: ListMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), ListMessageReactionResponse) + response.raw = resp + + return response + + async def alist(self, request: ListMessageReactionRequest, + option: Optional[RequestOption] = None) -> ListMessageReactionResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Deserialize the response + response: ListMessageReactionResponse = JSON.unmarshal(str(resp.content, UTF_8), ListMessageReactionResponse) + response.raw = resp + + return response diff --git a/lark_channel/api/im/v1/resource/message_resource.py b/lark_channel/api/im/v1/resource/message_resource.py new file mode 100644 index 0000000..600a318 --- /dev/null +++ b/lark_channel/api/im/v1/resource/message_resource.py @@ -0,0 +1,68 @@ +import io +from typing import Any, Optional, Union, Dict, List, Set, IO, Callable, Type +from lark_channel.core.const import UTF_8, CONTENT_TYPE, APPLICATION_JSON +from lark_channel.core import JSON +from lark_channel.core.token import verify +from lark_channel.core.http import Transport +from lark_channel.core.model import Config, RequestOption, RawResponse +from lark_channel.core.utils import Files +from requests_toolbelt import MultipartEncoder +from ..model.get_message_resource_request import GetMessageResourceRequest +from ..model.get_message_resource_response import GetMessageResourceResponse + + +class MessageResource(object): + def __init__(self, config: Config) -> None: + self.config: Config = config + + def get(self, request: GetMessageResourceRequest, + option: Optional[RequestOption] = None) -> GetMessageResourceResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Add content-type + if request.body is not None: + option.headers[CONTENT_TYPE] = f"{APPLICATION_JSON}; charset=utf-8" + + # Send the request + resp: RawResponse = Transport.execute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetMessageResourceResponse = GetMessageResourceResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetMessageResourceResponse) + + response.raw = resp + return response + + async def aget(self, request: GetMessageResourceRequest, + option: Optional[RequestOption] = None) -> GetMessageResourceResponse: + if option is None: + option = RequestOption() + + # Authenticate and obtain a token + verify(self.config, request, option) + + # Send the request + resp: RawResponse = await Transport.aexecute(self.config, request, option) + + # Handle the binary stream + content_type = resp.headers.get(CONTENT_TYPE) + response: GetMessageResourceResponse = GetMessageResourceResponse() + if 200 <= resp.status_code < 300: + response.code = 0 + response.file = io.BytesIO(resp.content) + response.file_name = Files.parse_file_name(resp.headers) + elif content_type is not None and content_type.startswith(APPLICATION_JSON): + response = JSON.unmarshal(str(resp.content, UTF_8), GetMessageResourceResponse) + + response.raw = resp + return response diff --git a/lark_channel/api/im/v1/version.py b/lark_channel/api/im/v1/version.py new file mode 100644 index 0000000..4ccdfba --- /dev/null +++ b/lark_channel/api/im/v1/version.py @@ -0,0 +1,13 @@ +from lark_channel.core.model import Config + +from .resource import Chat, File, Image, Message, MessageReaction, MessageResource + + +class V1(object): + def __init__(self, config: Config) -> None: + self.chat: Chat = Chat(config) + self.file: File = File(config) + self.image: Image = Image(config) + self.message: Message = Message(config) + self.message_reaction: MessageReaction = MessageReaction(config) + self.message_resource: MessageResource = MessageResource(config) diff --git a/lark_channel/api/wiki/__init__.py b/lark_channel/api/wiki/__init__.py new file mode 100644 index 0000000..ec73528 --- /dev/null +++ b/lark_channel/api/wiki/__init__.py @@ -0,0 +1 @@ +"""Minimal Wiki primitives required by lark_channel.""" diff --git a/lark_channel/api/wiki/node.py b/lark_channel/api/wiki/node.py new file mode 100644 index 0000000..34faeda --- /dev/null +++ b/lark_channel/api/wiki/node.py @@ -0,0 +1,11 @@ +from lark_channel.core.enum import AccessTokenType, HttpMethod +from lark_channel.core.model import BaseRequest + + +def build_wiki_node_get_request(*, token: str) -> BaseRequest: + req = BaseRequest() + req.http_method = HttpMethod.GET + req.uri = "/open-apis/wiki/v2/spaces/get_node" + req.token_types = {AccessTokenType.TENANT, AccessTokenType.USER} + req.add_query("token", token) + return req diff --git a/lark_channel/card/__init__.py b/lark_channel/card/__init__.py new file mode 100644 index 0000000..00c2159 --- /dev/null +++ b/lark_channel/card/__init__.py @@ -0,0 +1,2 @@ +from .action_handler import CardActionHandler +from .model import * diff --git a/lark_channel/card/action_handler.py b/lark_channel/card/action_handler.py new file mode 100644 index 0000000..4dd556c --- /dev/null +++ b/lark_channel/card/action_handler.py @@ -0,0 +1,262 @@ +import hashlib +import hmac +import json +import logging +from typing import Optional, Callable, Any, TYPE_CHECKING + +from lark_channel.core.const import * +from lark_channel.core.enum import LogLevel +from lark_channel.core.exception import InvalidArgsException, AccessDeniedException, CardException, \ + NoAuthorizationException +from lark_channel.core.http import HttpHandler +from lark_channel.core.json import JSON +from lark_channel.core.log import logger, redact_for_log +from lark_channel.core.model import RawRequest, RawResponse +from lark_channel.core.utils import Strings, AESCipher +from lark_channel.event.security import ( + REASON_CARD_SIGNATURE_INVALID, + REASON_CARD_SIGNATURE_MISSING, + build_error_response_content, + should_record_security_audit, +) +from .model import Card + +if TYPE_CHECKING: + from lark_channel.channel.config import SecurityConfig + + +class CardActionHandler(HttpHandler): + + def __init__(self, security: Optional["SecurityConfig"] = None) -> None: + self._encrypt_key: Optional[str] = None + self._verification_token: Optional[str] = None + self._processor: Optional[Callable[[Card], Any]] = None + self._security = security or _default_security_config() + + def do(self, req: RawRequest) -> RawResponse: + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"card access, uri: {req.uri}, " + f"headers: {JSON.marshal(redact_for_log(req.headers))}, " + f"body: {JSON.marshal(redact_for_log(req.body)) if req.body is not None else None}") + + resp = RawResponse() + resp.status_code = 200 + resp.set_content_type(f"{APPLICATION_JSON}; charset=utf-8") + + try: + if req.body is None: + raise InvalidArgsException("request body is null") + + signature_preverified = False + if self._is_encrypted_payload(req.body): + signature_preverified = self._preverify_encrypted_request(req) + + # Decrypt the message + plaintext = self._decrypt(req.body) + + # Deserialize the response + card = JSON.unmarshal(plaintext, Card) + card.raw = req + + if URL_VERIFICATION == card.type: + # URL verification: constant-time token compare. + if self._verification_token is None or card.token is None or not hmac.compare_digest( + self._verification_token, card.token + ): + raise AccessDeniedException("invalid verification_token") + + # Echo the challenge (JSON-escaped). + resp.content = JSON.marshal({"challenge": card.challenge}).encode(UTF_8) + return resp + else: + # Otherwise verify the signature + if not signature_preverified: + self._verify_sign(req) + + if self._processor is None: + raise CardException("processor not found") + + # Handle the business logic + result: Any = self._processor(card) + + # Return the result + if result is None: + resp.content = "{\"msg\":\"success\"}".encode(UTF_8) + elif isinstance(result, bytes): + resp.content = result + elif isinstance(result, str): + resp.content = bytes(result, UTF_8) + elif isinstance(result, RawResponse): + resp = result + else: + resp.content = JSON.marshal(result).encode(UTF_8) + + return resp + + except Exception as e: + if self._security.enforce_strict_error_response: + logger.error( + "handle card failed, uri: %s, request_id: %s, err: %s", + req.uri, + req.headers.get(X_REQUEST_ID), + redact_for_log(str(e)), + ) + else: + logger.exception( + f"handle card failed, uri: {req.uri}, request_id: {req.headers.get(X_REQUEST_ID)}, err: {e}") + resp.status_code = 500 + resp.content = build_error_response_content(e, security=self._security) + + return resp + + def _decrypt(self, content: bytes) -> str: + plaintext: str + encrypt = json.loads(content).get("encrypt") + if Strings.is_not_empty(encrypt): + if Strings.is_empty(self._encrypt_key): + raise NoAuthorizationException("encrypt_key not found") + plaintext = AESCipher(self._encrypt_key).decrypt_str(encrypt) + else: + plaintext = str(content, UTF_8) + + return plaintext + + def _is_encrypted_payload(self, content: bytes) -> bool: + try: + encrypt = json.loads(content).get("encrypt") + except (TypeError, ValueError, AttributeError): + return False + return Strings.is_not_empty(encrypt) + + def _preverify_encrypted_request(self, request: RawRequest) -> bool: + if Strings.is_empty(self._verification_token): + if ( + self._security.is_strict + and not self._security.allow_unsigned_encrypted_webhook + ): + self._record_security_audit( + REASON_CARD_SIGNATURE_MISSING, + action="block", + request=request, + ) + raise AccessDeniedException("signature verification failed") + if self._security.is_strict: + self._record_security_audit( + REASON_CARD_SIGNATURE_MISSING, + action="allow", + request=request, + ) + return False + if not self._has_signature_headers(request): + action = "legacy_flow" + if self._security.is_strict: + action = ( + "allow" + if self._security.allow_unsigned_encrypted_webhook + else "block" + ) + self._record_security_audit( + REASON_CARD_SIGNATURE_MISSING, + action=action, + request=request, + ) + if ( + self._security.is_strict + and not self._security.allow_unsigned_encrypted_webhook + ): + raise AccessDeniedException("signature verification failed") + return True + try: + self._verify_sign(request) + except Exception: + self._record_security_audit( + REASON_CARD_SIGNATURE_INVALID, + action="block", + request=request, + ) + raise + return True + + def _has_signature_headers(self, request: RawRequest) -> bool: + return ( + Strings.is_not_empty(request.headers.get(LARK_REQUEST_TIMESTAMP)) + and Strings.is_not_empty(request.headers.get(LARK_REQUEST_NONCE)) + and Strings.is_not_empty(request.headers.get(LARK_REQUEST_SIGNATURE)) + ) + + def _record_security_audit( + self, + reason: str, + *, + action: str, + request: RawRequest, + ) -> None: + if not should_record_security_audit(self._security): + return + self._security.audit_recorder.record( + reason, + mode=self._security.mode, + action=action, + details={ + "uri": request.uri, + "request_id": request.headers.get(X_REQUEST_ID), + }, + ) + + def _verify_sign(self, request: RawRequest) -> None: + if self._verification_token is None or self._verification_token == "": + return + timestamp = request.headers.get(LARK_REQUEST_TIMESTAMP) + nonce = request.headers.get(LARK_REQUEST_NONCE) + signature = request.headers.get(LARK_REQUEST_SIGNATURE) + bs = (timestamp + nonce + self._verification_token).encode(UTF_8) + request.body + h = hashlib.sha1(bs) + if signature != h.hexdigest(): + raise AccessDeniedException("signature verification failed") + + @staticmethod + def builder( + encrypt_key: str, + verification_token: str, + level: LogLevel = None, + *, + security: Optional["SecurityConfig"] = None, + ) -> "CardActionHandlerBuilder": + if level is not None: + logger.setLevel(int(level.value)) + return CardActionHandlerBuilder( + encrypt_key, + verification_token, + security=security, + ) + + +class CardActionHandlerBuilder(object): + def __init__( + self, + encrypt_key: str, + verification_token: str, + *, + security: Optional["SecurityConfig"] = None, + ) -> None: + self._encrypt_key = encrypt_key + self._verification_token = verification_token + self._processor: Optional[Callable[[Card], Any]] = None + self._security = security or _default_security_config() + + def register(self, f: Callable[[Card], Any]) -> "CardActionHandlerBuilder": + self._processor = f + return self + + def build(self) -> CardActionHandler: + card_action_handler = CardActionHandler(security=self._security) + card_action_handler._encrypt_key = self._encrypt_key + card_action_handler._verification_token = self._verification_token + card_action_handler._processor = self._processor + return card_action_handler + + +def _default_security_config(): + from lark_channel.channel.config import SecurityConfig + + return SecurityConfig() diff --git a/lark_channel/card/model.py b/lark_channel/card/model.py new file mode 100644 index 0000000..b606e82 --- /dev/null +++ b/lark_channel/card/model.py @@ -0,0 +1,39 @@ +from typing import Dict, Optional, Any, List + +from lark_channel.core.construct import init +from lark_channel.core.model import RawRequest + + +class Action(object): + _types = {} + + def __init__(self, d=None) -> None: + self.value: Dict[str, Any] = {} + self.tag: Optional[str] = None + self.option: Optional[str] = None + self.timezone: Optional[str] = None + self.name: Optional[str] = None + self.form_value: Dict[str, Any] = {} + self.input_value: Optional[str] = None + self.options: Optional[List[str]] = [] + self.checked: Optional[bool] = None + init(self, d, self._types) + + +class Card(object): + _types = { + "action": Action + } + + def __init__(self, d=None) -> None: + self.open_id: Optional[str] = None + self.user_id: Optional[str] = None + self.tenant_key: Optional[str] = None + self.open_message_id: Optional[str] = None + self.open_chat_id: Optional[str] = None + self.token: Optional[str] = None + self.challenge: Optional[str] = None + self.type: Optional[str] = None + self.action: Optional[Action] = None + self.raw: Optional[RawRequest] = None + init(self, d, self._types) diff --git a/lark_channel/channel/__init__.py b/lark_channel/channel/__init__.py new file mode 100644 index 0000000..5af8bea --- /dev/null +++ b/lark_channel/channel/__init__.py @@ -0,0 +1,339 @@ +"""lark_channel.channel — high-level Feishu Channel capability layer. + +Typical usage:: + + import asyncio + from lark_channel import FeishuChannel + + channel = FeishuChannel(app_id="cli_xxx", app_secret="***") + + async def on_message(msg): + await channel.send(msg.conversation.chat_id, {"text": f"echo: {msg.content_text}"}) + + channel.on("message", on_message) + + asyncio.run(channel.connect()) + +The public surface is organized around :class:`FeishuChannel`. All events fire +through ``channel.on(event_name, handler)``; all sends/updates go through +explicit channel methods (``send`` / ``stream`` / ``update_card`` / ...). +""" + +from .auth import ( + DeviceFlowClient, + DeviceFlowInit, + FileTokenStore, + InMemoryTokenStore, + TokenStore, +) +from .bot_identity import BotIdentity, fetch_bot_identity +from .card import CardBuilder, new_card +from .channel import FeishuChannel +from .config import ( + ChannelConfig, + ChatModeCacheConfig, + ChatQueueConfig, + DedupConfig, + DmPolicy, + FooterConfig, + GroupOverride, + GroupPolicy, + InboundConfig, + KeepaliveConfig, + MarkdownConverter, + MediaCacheConfig, + MediaCapabilities, + NameCacheConfig, + OutboundConfig, + OversizeContext, + PerChatReplyMode, + PolicyConfig, + RetryConfig, + ResourceOverflowPolicy, + SafetyConfig, + SenderIdentityField, + SecurityConfig, + SecurityMode, + StreamThrottleConfig, + TextBatchConfig, + TransportConfig, + UATConfig, +) +from .events import ChannelEventName, Events +from .errors import ( + FeishuChannelError, + FeishuChannelErrorCode, + OutboundSendError, + SendError, + UATAuthError, + classify_api_error, + classify_error, + classify_http_status, + is_format_error, + is_reply_target_gone, + is_retryable, +) +from .identity import IdentityResolver, NameCache +from .normalize import ( + CommentEvent, + CommentOperator, + DedupStore, + InMemoryDedupStore, + InboundPipeline, + flatten as flatten_content, + make_event_key, + make_message_key, + normalize_comment, + parse_message_content, +) +from .outbound import OutboundSender, infer_receive_id_type, markdown_to_post_ast +from .outbound.media.ssrf_guard import assert_public_url +from .outbound.streaming import Throttle, UpdateQueue, merge_streaming_text +from .outbound.streaming.card_stream import CardStreamController +from .outbound.streaming.markdown_stream import MarkdownStreamController +from .safety import ( + ChatPipeline, + ChatPipelineManager, + MediaBatchConfig, + PolicyDecision, + PolicyGate, + ProcessingLock, + RejectEvent, + RejectReason, + SafetyPipeline, + SeenCache, + is_stale, + merge_batch, +) +from .types import ( + AudioContent, + BotAddedEvent, + BotLeaveEvent, + CalendarContent, + CardActionEvent, + CardActionPayload, + CardPayload, + CachedResource, + ChatInfo, + CommentContext, + CommentTarget, + ConnectionSnapshot, + Conversation, + EventOperator, + FileContent, + FolderContent, + GeneralCalendarContent, + HongbaoContent, + RedPacketContent, + Identity, + ImageContent, + InboundMessage, + InteractiveContent, + LocationContent, + MediaContent, + MediaRef, + MediaSource, + Mention, + MergeForwardContent, + MergeForwardItem, + MessageContent, + MessageReadEvent, + OutboundAudio, + OutboundCard, + OutboundFile, + OutboundImage, + OutboundMessage, + OutboundPost, + OutboundText, + OutboundShareChat, + OutboundShareUser, + OutboundSticker, + OutboundVideo, + PostContent, + QuotedContext, + QuoteResolution, + ReactionEvent, + ReplyRef, + ResourceDescriptor, + ResourceType, + ReplyTargetGoneBehavior, + SendOpts, + SendResult, + ShareCalendarEventContent, + ShareChatContent, + ShareUserContent, + StickerContent, + SystemContent, + Target, + TextContent, + TodoContent, + UAT, + UnknownContent, + UserAccessToken, + VideoChatContent, + VoteContent, +) + +__all__ = [ + # Entry points + "FeishuChannel", + # Config + "ChannelConfig", + "ChatModeCacheConfig", + "ChatQueueConfig", + "DedupConfig", + "DmPolicy", + "FooterConfig", + "GroupOverride", + "GroupPolicy", + "InboundConfig", + "KeepaliveConfig", + "MarkdownConverter", + "MediaBatchConfig", + "MediaCacheConfig", + "MediaCapabilities", + "NameCacheConfig", + "OutboundConfig", + "OversizeContext", + "PerChatReplyMode", + "PolicyConfig", + "RetryConfig", + "ResourceOverflowPolicy", + "SafetyConfig", + "SenderIdentityField", + "SecurityConfig", + "SecurityMode", + "StreamThrottleConfig", + "TextBatchConfig", + "TransportConfig", + "UATConfig", + # Events + "ChannelEventName", + "Events", + # Card builder (Python ergonomics helper) + "CardBuilder", + "CardStreamController", + "MarkdownStreamController", + "Throttle", + "UpdateQueue", + "new_card", + "merge_streaming_text", + # Auth + "DeviceFlowClient", + "DeviceFlowInit", + "FileTokenStore", + "InMemoryTokenStore", + "TokenStore", + # Dedup + "DedupStore", + "InMemoryDedupStore", + "make_event_key", + "make_message_key", + # Identity + "IdentityResolver", + "NameCache", + # Pipeline helpers + "InboundPipeline", + "OutboundSender", + "infer_receive_id_type", + "markdown_to_post_ast", + "parse_message_content", + # Safety + "BotIdentity", + "ChatPipeline", + "ChatPipelineManager", + "PolicyDecision", + "PolicyGate", + "ProcessingLock", + "RejectEvent", + "RejectReason", + "SafetyPipeline", + "SeenCache", + "fetch_bot_identity", + "is_stale", + "merge_batch", + # Errors + "FeishuChannelError", + "FeishuChannelErrorCode", + "OutboundSendError", + "SendError", + "UATAuthError", + "assert_public_url", + "classify_api_error", + "classify_error", + "classify_http_status", + "is_format_error", + "is_reply_target_gone", + "is_retryable", + # Types + "AudioContent", + "BotAddedEvent", + "BotLeaveEvent", + "CalendarContent", + "CardActionEvent", + "CardActionPayload", + "CardPayload", + "CachedResource", + "ChatInfo", + "CommentContext", + "CommentTarget", + "CommentEvent", + "CommentOperator", + "ConnectionSnapshot", + "Conversation", + "EventOperator", + "FileContent", + "FolderContent", + "GeneralCalendarContent", + "HongbaoContent", + "RedPacketContent", + "Identity", + "ImageContent", + "InboundMessage", + "InteractiveContent", + "LocationContent", + "MediaContent", + "MediaRef", + "MediaSource", + "Mention", + "MergeForwardContent", + "MergeForwardItem", + "MessageContent", + "MessageReadEvent", + "OutboundAudio", + "OutboundCard", + "OutboundFile", + "OutboundImage", + "OutboundMessage", + "OutboundPost", + "OutboundText", + "OutboundShareChat", + "OutboundShareUser", + "OutboundSticker", + "OutboundVideo", + "PostContent", + "QuotedContext", + "QuoteResolution", + "ReactionEvent", + "ReplyRef", + "ResourceDescriptor", + "ResourceType", + "ReplyTargetGoneBehavior", + "SendOpts", + "SendResult", + "ShareCalendarEventContent", + "ShareChatContent", + "ShareUserContent", + "StickerContent", + "SystemContent", + "Target", + "TextContent", + "TodoContent", + "UAT", + "UnknownContent", + "UserAccessToken", + "VideoChatContent", + "VoteContent", + "flatten_content", + "normalize_comment", +] diff --git a/lark_channel/channel/_api_helpers.py b/lark_channel/channel/_api_helpers.py new file mode 100644 index 0000000..c37d966 --- /dev/null +++ b/lark_channel/channel/_api_helpers.py @@ -0,0 +1,358 @@ +"""Helpers around raw Lark API calls used by :class:`FeishuChannel`. + +**Internal module — do not import from outside ``lark_channel.channel``.** The +leading underscore in the module name is Python's convention for "package +private". Names, signatures, and behaviour here are free to change between +minor versions without deprecation warnings. + +Pure async functions taking the underlying ``lark_channel.Client``. Extracted +from :mod:`.channel` to keep the main class focused on lifecycle and +dispatch; boilerplate around ``contact.v3.user.batch`` / ``im.v1.chat.aget`` +lives here instead. +""" + +import asyncio +from typing import Any, Dict, List, Optional + +from lark_channel.core.log import logger + +from .types import ChatInfo, Identity + + +async def default_name_lookup( + lark_client: Any, open_ids: List[str] +) -> Dict[str, Identity]: + """Resolve open_ids to :class:`Identity` via ``contact.v3.user.batch``. + + Returns ``{}`` on any error so callers degrade gracefully. + """ + if not open_ids: + return {} + try: + from lark_channel.api.contact.v3.model.batch_user_request import ( + BatchUserRequest, + ) + except ImportError: # pragma: no cover + return {} + try: + req_b = ( + BatchUserRequest.builder() + .user_id_type("open_id") + .user_ids(list(open_ids)) + ) + resp = await lark_client.contact.v3.user.abatch(req_b.build()) + data = getattr(resp, "data", None) + items = getattr(data, "items", None) or [] + out: Dict[str, Identity] = {} + for item in items: + oid = getattr(item, "open_id", None) + if not oid: + continue + out[oid] = Identity( + open_id=oid, + union_id=getattr(item, "union_id", None), + user_id=getattr(item, "user_id", None), + display_name=getattr(item, "name", None) + or getattr(item, "en_name", None), + ) + return out + except Exception as e: # pragma: no cover + logger.debug("default_name_lookup failed: %s", e) + return {} + + +async def fetch_chat_info(lark_client: Any, chat_id: str) -> Optional[ChatInfo]: + """Fetch chat metadata via ``im.v1.chat.aget``. Returns ``None`` on failure. + + Public-API callers expect ``Optional[ChatInfo]``, so failures are logged + rather than raised. The log always includes ``chat_id`` so operators can + correlate "this lookup came back empty" with the actual upstream reason + (403 / 404 / token expired / network). + """ + try: + from lark_channel.api.im.v1.model.get_chat_request import GetChatRequest + + req = GetChatRequest.builder().chat_id(chat_id).build() + resp = await lark_client.im.v1.chat.aget(req) + code = getattr(resp, "code", None) + if code is not None and code != 0: + logger.warning( + "fetch_chat_info: chat_id=%s code=%s msg=%s", + chat_id, code, getattr(resp, "msg", ""), + ) + return None + data = getattr(resp, "data", None) + if data is None: + return None + raw_dict: Dict[str, Any] = {} + for attr in ("name", "description", "chat_type", "chat_mode", "owner_id", "user_count"): + v = getattr(data, attr, None) + if v is not None: + raw_dict[attr] = v + member_count = None + uc = getattr(data, "user_count", None) + if uc is not None: + try: + member_count = int(uc) + except (TypeError, ValueError): + pass + return ChatInfo( + chat_id=chat_id, + name=getattr(data, "name", None), + description=getattr(data, "description", None), + chat_type=getattr(data, "chat_type", "unknown") or "unknown", + owner_id=getattr(data, "owner_id", None), + member_count=member_count, + raw=raw_dict, + ) + except Exception as e: # pragma: no cover + logger.warning( + "fetch_chat_info: chat_id=%s raised: %s", chat_id, e, + ) + return None + + +async def fetch_message_raw( + lark_client: Any, + message_id: str, + *, + card_content_type: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + try: + from lark_channel.api.im.v1.model.get_message_request import GetMessageRequest + + req = GetMessageRequest.builder().message_id(message_id).build() + if card_content_type: + req.add_query("card_msg_content_type", card_content_type) + resp = await lark_client.im.v1.message.aget(req) + code = resp.get("code") if isinstance(resp, dict) else getattr(resp, "code", None) + if code is not None and code != 0: + return None + return _object_to_dict(resp) + except Exception: + return None + + +async def fetch_history( + lark_client: Any, + *, + chat_id: str, + limit: Optional[int] = None, + before_id: Optional[str] = None, +) -> List[Any]: + """Fetch recent messages for ``chat_id``. + + ``before_id`` is accepted for source compatibility with earlier internal + call sites, but the generated list-message builder available here does not + expose cursoring by message id, so this helper currently ignores it. + """ + _ = before_id + try: + from lark_channel.api.im.v1.model.list_message_request import ListMessageRequest + + req_b = ( + ListMessageRequest.builder() + .container_id_type("chat") + .container_id(chat_id) + ) + if limit: + req_b = req_b.page_size(limit) + resp = await lark_client.im.v1.message.alist(req_b.build()) + data = getattr(resp, "data", None) + return list(getattr(data, "items", None) or []) + except Exception as e: # pragma: no cover + logger.warning("fetch_history failed: %s", e) + return [] + + +def _object_to_dict(value: Any) -> Any: + if value is None: + return None + if isinstance(value, dict): + return {key: _object_to_dict(val) for key, val in value.items()} + if isinstance(value, list): + return [_object_to_dict(item) for item in value] + if hasattr(value, "__dict__"): + return { + key: _object_to_dict(val) + for key, val in value.__dict__.items() + if not key.startswith("_") + } + return value + + +async def download_media( + lark_client: Any, + *, + message_id: str, + file_key: str, + resource_type: str, +) -> Optional[bytes]: + """Download a message resource (image / file / audio / video attachment). + + Returns the raw bytes, or ``None`` on any failure. The ``None`` contract + is preserved for back-compat with the public + :meth:`FeishuChannel.download_resource`; failures are logged with the + ``message_id`` + ``file_key`` + ``resource_type`` triple so operators + can pin down which download dropped. + """ + try: + # Route by whether the caller supplied a message_id: + # * With message_id — the key belongs to a message attachment, so the + # correct endpoint is `GET /im/v1/messages/:message_id/resources/:file_key`. + # * Without message_id — the key was minted by a standalone upload + # (`POST /im/v1/images` or `POST /im/v1/files`), which is served + # by `GET /im/v1/images/:image_key` or `GET /im/v1/files/:file_key`. + # An empty ``message_id`` against the message-resource endpoint + # returns 200 with no body, so route to the standalone endpoints in + # that case. + if message_id: + from lark_channel.api.im.v1.model.get_message_resource_request import ( + GetMessageResourceRequest, + ) + req = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + resp = await _get_off_thread(lark_client.im.v1.message_resource, req) + elif resource_type == "image": + from lark_channel.api.im.v1.model.get_image_request import GetImageRequest + req = GetImageRequest.builder().image_key(file_key).build() + resp = await _get_off_thread(lark_client.im.v1.image, req) + else: + from lark_channel.api.im.v1.model.get_file_request import GetFileRequest + req = GetFileRequest.builder().file_key(file_key).build() + resp = await _get_off_thread(lark_client.im.v1.file, req) + + code = getattr(resp, "code", None) + if code is not None and code != 0: + logger.warning( + "download_media: message_id=%s file_key=%s type=%s " + "code=%s msg=%s", + message_id, file_key, resource_type, + code, getattr(resp, "msg", ""), + ) + return None + f = getattr(resp, "file", None) + if hasattr(f, "read"): + return f.read() + if isinstance(f, (bytes, bytearray)): + return bytes(f) + logger.warning( + "download_media: message_id=%s file_key=%s type=%s — " + "response succeeded but has no file payload", + message_id, file_key, resource_type, + ) + return None + except Exception as e: # pragma: no cover + logger.warning( + "download_media: message_id=%s file_key=%s type=%s raised: %s", + message_id, file_key, resource_type, e, + ) + return None + + +async def download_media_with_meta( + lark_client: Any, + *, + message_id: str, + file_key: str, + resource_type: str, +) -> "tuple[Optional[bytes], Optional[str]]": + """Like :func:`download_media` but also returns a content-type / extension hint. + + The second element of the tuple is one of: + + - the response's ``content_type`` field (preferred, when surfaced); + - the response's ``file_name`` (used to derive a suffix via mimetypes); + - ``None`` when nothing is available — the caller falls back to a + generic suffix. + + Returns ``(None, None)`` on any failure, mirroring ``download_media``'s + None-on-failure shape. + """ + try: + if message_id: + from lark_channel.api.im.v1.model.get_message_resource_request import ( + GetMessageResourceRequest, + ) + req = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + resp = await _get_off_thread(lark_client.im.v1.message_resource, req) + elif resource_type == "image": + from lark_channel.api.im.v1.model.get_image_request import GetImageRequest + req = GetImageRequest.builder().image_key(file_key).build() + resp = await _get_off_thread(lark_client.im.v1.image, req) + else: + from lark_channel.api.im.v1.model.get_file_request import GetFileRequest + req = GetFileRequest.builder().file_key(file_key).build() + resp = await _get_off_thread(lark_client.im.v1.file, req) + + code = getattr(resp, "code", None) + if code is not None and code != 0: + logger.warning( + "download_media_with_meta: message_id=%s file_key=%s type=%s " + "code=%s msg=%s", + message_id, file_key, resource_type, + code, getattr(resp, "msg", ""), + ) + return None, None + + f = getattr(resp, "file", None) + body: Optional[bytes] + if hasattr(f, "read"): + body = f.read() + elif isinstance(f, (bytes, bytearray)): + body = bytes(f) + else: + body = None + + content_type: Optional[str] = ( + getattr(resp, "content_type", None) + or getattr(resp, "mime_type", None) + or getattr(resp, "file_name", None) + ) + + return body, content_type + except Exception as e: # pragma: no cover + logger.warning( + "download_media_with_meta: message_id=%s file_key=%s type=%s raised: %s", + message_id, file_key, resource_type, e, + ) + return None, None + + +async def mark_read(lark_client: Any, *, message_ids: List[str]) -> Dict[str, Any]: + try: + from lark_channel.api.im.v1.model.read_users_message_request import ( + ReadUsersMessageRequest, + ) + from lark_channel.api.im.v1.model.read_users_message_request_body import ( + ReadUsersMessageRequestBody, + ) + + body = ( + ReadUsersMessageRequestBody.builder() + .message_id_list(message_ids) + .build() + ) + req = ReadUsersMessageRequest.builder().request_body(body).build() + resp = await lark_client.im.v1.message.aread_users(req) + return { + "code": getattr(resp, "code", 0), + "msg": getattr(resp, "msg", "") or "", + } + except Exception as e: # pragma: no cover + return {"code": -1, "msg": str(e)} + + +async def _get_off_thread(resource: Any, request: Any) -> Any: + return await asyncio.to_thread(resource.get, request) diff --git a/lark_channel/channel/_coerce.py b/lark_channel/channel/_coerce.py new file mode 100644 index 0000000..94757e5 --- /dev/null +++ b/lark_channel/channel/_coerce.py @@ -0,0 +1,252 @@ +"""Input coercion helpers shared by :class:`FeishuChannel`. + +**Internal module — do not import from outside ``lark_channel.channel``.** The +leading underscore in the module name is Python's convention for "package +private". Names, signatures, and behaviour here are free to change between +minor versions without deprecation warnings. ``FeishuChannel.send`` / +``.stream`` etc. are the public surface; use them instead of ``coerce_*`` +helpers directly. + +These turn node-style dict / string / dataclass inputs into the internal +:class:`OutboundMessage` / :class:`MediaSource` / :class:`SendOpts` shapes the +sender expects. Extracted from :mod:`.channel` to keep the main class focused. +""" + +import inspect +import json +from typing import Any, Dict, Optional, Union + +from lark_channel.core.json import JSON + +from .errors import FeishuChannelErrorCode, SendError +from .types import ( + MediaSource, + OutboundAudio, + OutboundCard, + OutboundFile, + OutboundImage, + OutboundMessage, + OutboundPost, + OutboundShareChat, + OutboundShareUser, + OutboundSticker, + OutboundText, + OutboundVideo, + SendOpts, + SendResult, +) + + +# -------------------------------------------------------------------------- +# Event-name normalization (node-aligned) +# -------------------------------------------------------------------------- + +VALID_EVENTS = { + "message", + "interaction", "cardAction", "card_action", + "reaction", + "bot_added", "botAdded", + "bot_left", "botLeave", "bot_leave", + "message_read", "messageRead", + "reject", + "comment", + "raw", "raw_event", + "reconnecting", + "reconnected", + "error", +} + +_EVENT_ALIASES = { + "card_action": "cardAction", + "interaction": "cardAction", + "bot_added": "botAdded", + "bot_join": "botAdded", + "bot_left": "botLeave", + "bot_leave": "botLeave", + "message_read": "messageRead", + "raw_event": "raw", +} + + +def normalize_event_name(name: str) -> str: + return _EVENT_ALIASES.get(name, name) + + +# -------------------------------------------------------------------------- +# Input coercion +# -------------------------------------------------------------------------- + + +def coerce_outbound( + input_: Union[Dict[str, Any], OutboundMessage, str], +) -> OutboundMessage: + if isinstance(input_, str): + return OutboundPost(markdown=input_) + if isinstance( + input_, + ( + OutboundText, OutboundPost, OutboundCard, OutboundImage, + OutboundFile, OutboundAudio, OutboundVideo, + OutboundShareChat, OutboundShareUser, OutboundSticker, + ), + ): + return input_ + if not isinstance(input_, dict): + raise TypeError(f"Unsupported send input: {type(input_).__name__}") + + if "markdown" in input_: + return OutboundPost(markdown=input_["markdown"]) + if "text" in input_: + return OutboundText(text=input_["text"]) + if "post" in input_: + return OutboundPost(post=input_["post"]) + if "card" in input_: + return OutboundCard(card=input_["card"]) + if "image" in input_: + return OutboundImage( + source=coerce_media_source(input_["image"], kind="image"), + caption=_coerce_caption(input_.get("caption")), + ) + if "file" in input_: + return OutboundFile( + source=coerce_media_source(input_["file"], kind="file"), + file_name=_dict_get_any(input_["file"], ("fileName", "file_name")), + caption=_coerce_caption(input_.get("caption")), + ) + if "audio" in input_: + return OutboundAudio( + source=coerce_media_source(input_["audio"], kind="audio"), + caption=_coerce_caption(input_.get("caption")), + ) + if "video" in input_: + return OutboundVideo( + source=coerce_media_source(input_["video"], kind="video"), + caption=_coerce_caption(input_.get("caption")), + ) + if "share_chat" in input_ or "shareChat" in input_: + spec = input_.get("share_chat") or input_.get("shareChat") or {} + chat_id = spec if isinstance(spec, str) else _dict_get_any( + spec, ("chat_id", "chatId") + ) + if not chat_id: + raise TypeError("share_chat requires a chat_id (str or {chat_id: ...})") + return OutboundShareChat(chat_id=chat_id) + if "share_user" in input_ or "shareUser" in input_: + spec = input_.get("share_user") or input_.get("shareUser") or {} + user_id = spec if isinstance(spec, str) else _dict_get_any( + spec, ("user_id", "userId", "open_id", "openId") + ) + if not user_id: + raise TypeError("share_user requires a user_id (str or {user_id: ...})") + return OutboundShareUser(user_id=user_id) + if "sticker" in input_: + spec = input_["sticker"] + file_key = spec if isinstance(spec, str) else _dict_get_any( + spec, ("file_key", "fileKey") + ) + if not file_key: + raise TypeError("sticker requires a file_key (str or {file_key: ...})") + return OutboundSticker(file_key=file_key) + raise TypeError(f"send: unrecognized input keys {list(input_.keys())}") + + +def coerce_media_source(spec: Any, *, kind: str) -> MediaSource: + if isinstance(spec, MediaSource): + return spec + if isinstance(spec, bytes): + return MediaSource(kind="buffer", buffer=spec) + if not isinstance(spec, dict): + raise TypeError(f"{kind} input must be a dict or MediaSource") + src = spec.get("source") + if isinstance(src, MediaSource): + return src + if isinstance(src, bytes): + return MediaSource(kind="buffer", buffer=src) + if isinstance(src, str): + if src.startswith(("http://", "https://")): + return MediaSource(kind="url", url=src) + if src.startswith(("img_", "file_", "st_")): + return MediaSource(kind="key", key=src) + return MediaSource(kind="file", path=src) + raise TypeError(f"{kind}.source must be str (url/path) or bytes") + + +def coerce_send_opts( + opts: Optional[Union[SendOpts, Dict[str, Any]]], +) -> SendOpts: + if opts is None: + return SendOpts() + if isinstance(opts, SendOpts): + _coerce_reply_target_gone(opts.reply_target_gone) + return opts + if not isinstance(opts, dict): + raise TypeError("send opts must be SendOpts or dict") + return SendOpts( + reply_to=_dict_get_any(opts, ("replyTo", "reply_to")), + reply_in_thread=_dict_get_any(opts, ("replyInThread", "reply_in_thread")), + receive_id=_dict_get_any(opts, ("receiveId", "receive_id")), + receive_id_type=_dict_get_any(opts, ("receiveIdType", "receive_id_type")), + uuid=opts.get("uuid"), + reply_target_gone=_coerce_reply_target_gone( + _dict_get_any(opts, ("replyTargetGone", "reply_target_gone")) + ), + ) + + +def _dict_get_any(d: Dict[str, Any], keys) -> Any: + for k in keys: + if k in d: + return d[k] + return None + + +def _coerce_reply_target_gone(value: Any) -> str: + value = value or "fresh" + if value not in ("fresh", "fail"): + raise ValueError(f"invalid reply_target_gone: {value}") + return value + + +def _coerce_caption(value: Any) -> Optional[str]: + if value is None or value == "": + return None + if not isinstance(value, str): + raise TypeError("caption must be a string") + return value + + +def result_from_raw(raw: Any, *, message_id: Optional[str] = None) -> SendResult: + if not isinstance(raw, dict): + return SendResult.ok(message_id=message_id) + code = raw.get("code", 0) + if code == 0: + return SendResult.ok(message_id=message_id, raw=raw) + return SendResult.fail( + SendError( + code=FeishuChannelErrorCode.UNKNOWN, + retryable=False, + raw_code=int(code), + hint=raw.get("msg"), + ), + raw=raw, + ) + + +async def maybe_await(v: Any) -> Any: + if inspect.isawaitable(v): + return await v + return v + + +def obj_to_dict(obj: Any) -> Dict[str, Any]: + """Marshal SDK event objects to plain dicts via the shared JSON encoder.""" + if isinstance(obj, dict): + return obj + try: + s = JSON.marshal(obj) + if not s: + return {} + parsed = json.loads(s) + return parsed if isinstance(parsed, dict) else {} + except Exception: # pragma: no cover + return {} diff --git a/lark_channel/channel/auth/__init__.py b/lark_channel/channel/auth/__init__.py new file mode 100644 index 0000000..58eeeb9 --- /dev/null +++ b/lark_channel/channel/auth/__init__.py @@ -0,0 +1,12 @@ +"""UAT auth: TokenStore + Device Flow helpers.""" + +from .device_flow import DeviceFlowClient, DeviceFlowInit +from .token_store import FileTokenStore, InMemoryTokenStore, TokenStore + +__all__ = [ + "DeviceFlowClient", + "DeviceFlowInit", + "FileTokenStore", + "InMemoryTokenStore", + "TokenStore", +] diff --git a/lark_channel/channel/auth/device_flow.py b/lark_channel/channel/auth/device_flow.py new file mode 100644 index 0000000..75e4c31 --- /dev/null +++ b/lark_channel/channel/auth/device_flow.py @@ -0,0 +1,208 @@ +"""Lark User Access Token Device Flow. + +Implements the three steps: + 1. start: POST /open-apis/authen/v2/oauth/device_authorization → get + verification_uri + user_code + device_code + expires_in + interval + 2. wait: the user clicks the URL in Lark, enters the user_code, grants + scopes + 3. poll: POST /open-apis/authen/v2/oauth/token with grant_type=device_code + until we get access_token / refresh_token + +Refresh uses grant_type=refresh_token on the same /token endpoint. + +This client deliberately does NOT import the Lark HTTP transport — we go +straight to `httpx` so it's easy to unit-test with `respx` / mock. +""" + +import asyncio +import re +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import httpx + +from lark_channel.core.const import FEISHU_DOMAIN +from lark_channel.core.log import logger + +from ..errors import UATAuthError +from ..types import UAT + + +_HTML_TAG_RE = re.compile(r"<[^>]*>") + + +def _safe_body_snippet(text: str, limit: int = 200) -> str: + """Strip HTML tags and truncate, so an unexpected upstream response body + can't flow verbatim into a user-visible card prompt.""" + if not text: + return "" + stripped = _HTML_TAG_RE.sub(" ", text) + collapsed = " ".join(stripped.split()) + if len(collapsed) > limit: + return collapsed[:limit] + "…" + return collapsed + + +@dataclass +class DeviceFlowInit: + verification_uri: str + verification_uri_complete: str + user_code: str + device_code: str + expires_in: int + interval: int = 5 + + +class DeviceFlowClient: + def __init__( + self, + app_id: str, + app_secret: str, + *, + domain: str = FEISHU_DOMAIN, + timeout: float = 30.0, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + self._app_id = app_id + self._app_secret = app_secret + self._domain = domain.rstrip("/") + self._timeout = timeout + self._http = http_client + # Whether we own the client (and therefore must close it in `close()`). + # This is locked at construction — if a user-supplied client later + # appears closed, we fall back to a new internally-owned client (so the + # SDK stays usable) but we never *downgrade* an internally-owned + # client's ownership or *claim* ownership of the user's original. + self._owns_http = http_client is None + + async def _client(self) -> httpx.AsyncClient: + if self._http is None: + self._http = httpx.AsyncClient(timeout=self._timeout) + self._owns_http = True + elif getattr(self._http, "is_closed", False): + # User-supplied client closed out from under us, or our own client + # was closed by some other path. Either way we must fall back to a + # fresh internally-owned client; we do NOT touch the user's + # original object again and we claim ownership of the *new* one. + self._http = httpx.AsyncClient(timeout=self._timeout) + self._owns_http = True + return self._http + + async def close(self) -> None: + if self._http is not None and self._owns_http: + try: + await self._http.aclose() + except Exception: # pragma: no cover - defensive + pass + self._http = None + + async def start(self, scopes: List[str]) -> DeviceFlowInit: + body = { + "client_id": self._app_id, + "scope": " ".join(scopes) if scopes else "", + } + data = await self._post("/open-apis/authen/v2/oauth/device_authorization", body) + if data.get("code") and data.get("code") != 0: + raise UATAuthError(f"device_authorization failed: {data.get('msg')}") + # Some deployments return top-level fields; others wrap in data. + payload = data.get("data") or data + return DeviceFlowInit( + verification_uri=payload.get("verification_uri") or "", + verification_uri_complete=payload.get("verification_uri_complete") + or payload.get("verification_uri") or "", + user_code=payload.get("user_code") or "", + device_code=payload.get("device_code") or "", + expires_in=int(payload.get("expires_in") or 600), + interval=int(payload.get("interval") or 5), + ) + + async def poll( + self, + device_code: str, + *, + interval: int = 5, + timeout_seconds: Optional[int] = None, + ) -> UAT: + """Poll until authorization completes, scopes are denied, or timeout.""" + deadline = time.time() + (timeout_seconds or 600) + delay = max(1, interval) + while time.time() < deadline: + body = { + "client_id": self._app_id, + "client_secret": self._app_secret, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + } + resp = await self._post("/open-apis/authen/v2/oauth/token", body) + code = resp.get("code") + if code == 0 or "access_token" in resp or "access_token" in (resp.get("data") or {}): + return self._to_uat(resp) + err = resp.get("error") or resp.get("msg") or "" + if err in ("authorization_pending", "slow_down"): + if err == "slow_down": + delay += 2 + await asyncio.sleep(delay) + continue + if err in ("access_denied", "expired_token"): + raise UATAuthError(f"device flow failed: {err}") + # Lark-specific non-zero code + if isinstance(code, int) and code != 0: + raise UATAuthError(f"device flow error code={code} msg={resp.get('msg')}") + await asyncio.sleep(delay) + raise UATAuthError("device flow timed out") + + async def refresh(self, refresh_token: str) -> UAT: + body = { + "client_id": self._app_id, + "client_secret": self._app_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + resp = await self._post("/open-apis/authen/v2/oauth/token", body) + code = resp.get("code") + if isinstance(code, int) and code != 0 and "access_token" not in resp: + raise UATAuthError(f"refresh failed: {resp.get('msg')}") + return self._to_uat(resp) + + def _to_uat(self, resp: Dict[str, Any]) -> UAT: + payload = resp.get("data") if isinstance(resp.get("data"), dict) else resp + now = time.time() + expires_in = int(payload.get("expires_in") or 0) + refresh_expires_in = int(payload.get("refresh_token_expires_in") or 0) + scope_str = payload.get("scope") or "" + return UAT( + access_token=payload.get("access_token") or "", + refresh_token=payload.get("refresh_token"), + expires_at=now + expires_in if expires_in else None, + refresh_expires_at=now + refresh_expires_in if refresh_expires_in else None, + scopes=scope_str.split() if scope_str else [], + raw=payload if isinstance(payload, dict) else {}, + ) + + async def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + url = self._domain + path + client = await self._client() + r = await client.post(url, json=body) + content_type = r.headers.get("content-type") or "" + try: + data = r.json() + if isinstance(data, dict): + return data + except Exception: # pragma: no cover + pass + # Non-JSON response (HTML error page, WAF block, etc.). Log the full + # body at WARNING for diagnosis but surface a *truncated + stripped* + # version upstream so it cannot flow verbatim into user-visible card + # prompts (`uat_runner` embeds resp["msg"] into a card). + logger.warning( + "device flow: unexpected response %s %s body=%r", + r.status_code, content_type, r.text[:1024], + ) + return {"code": r.status_code, "msg": _safe_body_snippet(r.text)} + + +def uat_needs_refresh(uat: UAT, *, slack_seconds: int = 300) -> bool: + if uat.expires_at is None: + return False + return uat.expires_at - time.time() <= slack_seconds diff --git a/lark_channel/channel/auth/token_store.py b/lark_channel/channel/auth/token_store.py new file mode 100644 index 0000000..1ca2754 --- /dev/null +++ b/lark_channel/channel/auth/token_store.py @@ -0,0 +1,165 @@ +"""TokenStore interface + two built-in implementations. + +- `InMemoryTokenStore`: dict-based, process-local, ephemeral. Default. +- `FileTokenStore`: plaintext JSON file. *Dev only* — constructor warns + loudly. + +Production users should implement their own TokenStore backed by Vault / +KMS / managed database; see the `TokenStore` protocol below. +""" + +import asyncio +import json +import os +import threading +import warnings +from typing import Dict, Optional, Protocol, runtime_checkable + +from lark_channel.core.log import logger + +from ..types import UAT + + +@runtime_checkable +class TokenStore(Protocol): + """Async interface for UAT storage.""" + + async def get(self, user_id: str) -> Optional[UAT]: ... + async def set(self, user_id: str, token: UAT) -> None: ... + async def delete(self, user_id: str) -> None: ... + + +class InMemoryTokenStore: + """Thread-safe dict-backed TokenStore. Loses state when the process dies.""" + + def __init__(self) -> None: + self._data: Dict[str, UAT] = {} + self._lock = threading.Lock() + + async def get(self, user_id: str) -> Optional[UAT]: + with self._lock: + return self._data.get(user_id) + + async def set(self, user_id: str, token: UAT) -> None: + with self._lock: + self._data[user_id] = token + + async def delete(self, user_id: str) -> None: + with self._lock: + self._data.pop(user_id, None) + + def clear(self) -> None: + with self._lock: + self._data.clear() + + +class FileTokenStore: + """Plaintext JSON file TokenStore — development only. + + Persists UATs across process restarts so you don't have to re-authorize on + every run. Emits a prominent warning so anybody reading the logs in a + production environment realizes this is not for them. + """ + + def __init__(self, path: str) -> None: + warnings.warn( + "FileTokenStore is not for production. Use a custom TokenStore " + "backed by Vault / KMS / encrypted database.", + UserWarning, + stacklevel=2, + ) + logger.warning( + "FileTokenStore: storing UATs in plaintext at %s — dev only", + path, + ) + self._path = path + # Defer lock creation to first use so the lock binds to the event loop + # that actually performs async I/O, not the loop that happened to be + # running at construction time (which may be a different loop when the + # store is shared with FeishuChannel's bg loop). + self._lock: Optional[asyncio.Lock] = None + self._mem: Dict[str, UAT] = self._load() + + def _load(self) -> Dict[str, UAT]: + if not os.path.exists(self._path): + return {} + try: + with open(self._path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + out: Dict[str, UAT] = {} + for k, v in (raw or {}).items(): + if isinstance(v, dict): + out[k] = UAT( + access_token=v.get("access_token") or "", + refresh_token=v.get("refresh_token"), + expires_at=v.get("expires_at"), + refresh_expires_at=v.get("refresh_expires_at"), + scopes=list(v.get("scopes") or []), + open_id=v.get("open_id"), + raw=v.get("raw") or {}, + ) + return out + except (OSError, ValueError) as e: + logger.warning("FileTokenStore: failed to load %s: %s", self._path, e) + return {} + + def _persist(self) -> None: + serializable = { + k: { + "access_token": v.access_token, + "refresh_token": v.refresh_token, + "expires_at": v.expires_at, + "refresh_expires_at": v.refresh_expires_at, + "scopes": v.scopes, + "open_id": v.open_id, + "raw": v.raw, + } + for k, v in self._mem.items() + } + tmp = self._path + ".tmp" + # Open with explicit 0o600 permissions so the file is never + # world-readable on Linux, regardless of the caller's umask. Tokens + # are sensitive credentials. + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(tmp, flags, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(serializable, fh, ensure_ascii=False, indent=2) + except Exception: + # Best-effort cleanup of tmp on failure. + try: + os.unlink(tmp) + except OSError: + pass + raise + os.replace(tmp, self._path) + # Re-assert permissions on the final file in case `os.replace` didn't + # preserve them (some filesystems / prior-existing file with looser + # perms). + try: + os.chmod(self._path, 0o600) + except OSError as e: + logger.warning( + "FileTokenStore: failed to chmod 0600 on %s: %s", self._path, e + ) + + def _get_lock(self) -> asyncio.Lock: + if self._lock is None: + self._lock = asyncio.Lock() + return self._lock + + async def get(self, user_id: str) -> Optional[UAT]: + async with self._get_lock(): + return self._mem.get(user_id) + + async def set(self, user_id: str, token: UAT) -> None: + async with self._get_lock(): + self._mem[user_id] = token + # Offload the synchronous JSON write + os.replace to a worker + # thread so the event loop stays responsive. + await asyncio.get_running_loop().run_in_executor(None, self._persist) + + async def delete(self, user_id: str) -> None: + async with self._get_lock(): + self._mem.pop(user_id, None) + await asyncio.get_running_loop().run_in_executor(None, self._persist) diff --git a/lark_channel/channel/auth/uat_runner.py b/lark_channel/channel/auth/uat_runner.py new file mode 100644 index 0000000..7a1eeca --- /dev/null +++ b/lark_channel/channel/auth/uat_runner.py @@ -0,0 +1,114 @@ +"""UAT device-flow runner — extracted from :class:`FeishuChannel`. + +Performs the "check cache → refresh if needed → start device flow → prompt +user → poll until authorized" dance. Separated so :mod:`..channel` can stay +focused on lifecycle. +""" + +import asyncio +from typing import Any, Dict, List + +from lark_channel.core.log import logger + +from ..card.builder import new_card as _card_factory +from ..errors import UATAuthError +from ..types import UAT +from .device_flow import DeviceFlowClient, uat_needs_refresh +from .token_store import TokenStore + + +# Per-user-open-id asyncio locks so concurrent handler invocations for the +# same user don't both try to refresh an expiring token simultaneously. The +# interactive device-flow prompt/poll step runs outside this lock so a waiting +# authorization does not block unrelated cache reads forever. +# The locks bind to the loop of the first caller; callers on other loops fall +# back to lock-less behaviour (rare; same-user concurrency across loops is not +# a supported configuration). +_user_locks: Dict[str, asyncio.Lock] = {} + + +def _get_user_lock(user_open_id: str) -> asyncio.Lock: + """Lazily create + memoize a per-user asyncio.Lock on the current loop.""" + return _user_locks.setdefault(user_open_id, asyncio.Lock()) + + +async def require_user_auth( + *, + device_flow: DeviceFlowClient, + token_store: TokenStore, + uat_config: Any, + user_open_id: str, + scopes: List[str], + context: Any, +) -> UAT: + """Resolve a usable UAT for ``user_open_id``, running device flow if needed. + + ``uat_config`` is a :class:`~..config.UATConfig` with scope allow/block + lists and the refresh slack; ``context`` is the object used to prompt the + user and should expose ``respond(card)``. + + A per-user asyncio.Lock serializes concurrent callers for the same user + through cache lookup and refresh. The prompt/poll device-flow phase is + intentionally outside that lock. + """ + ub = uat_config + if ub.allowed_scopes is not None: + for s in scopes: + if s not in ub.allowed_scopes: + raise UATAuthError(f"scope {s} not in allowed_scopes") + if ub.blocked_scopes: + for s in scopes: + if s in ub.blocked_scopes: + raise UATAuthError(f"scope {s} is blocked") + + async with _get_user_lock(user_open_id or ""): + existing = await token_store.get(user_open_id or "") + if existing is not None: + missing = [s for s in scopes if s and s not in (existing.scopes or [])] + if not missing: + if uat_needs_refresh( + existing, slack_seconds=ub.refresh_before_expiry_seconds + ): + if existing.refresh_token: + try: + refreshed = await device_flow.refresh(existing.refresh_token) + refreshed.open_id = user_open_id + if not refreshed.scopes and existing.scopes: + refreshed.scopes = existing.scopes + await token_store.set(user_open_id, refreshed) + return refreshed + except UATAuthError: + await token_store.delete(user_open_id) + else: + await token_store.delete(user_open_id) + else: + return existing + + init = await device_flow.start(scopes) + try: + prompt_card = ( + _card_factory() + .header(title="Authorization required", template="blue") + .markdown( + f"Please click the link to complete authorization: " + f"{init.verification_uri_complete}\n\n" + f"User code: `{init.user_code}`\n" + f"Expires in: {init.expires_in}s" + ) + .build() + ) + if context is not None and hasattr(context, "respond"): + await context.respond(prompt_card) + except Exception as e: + logger.warning("require_user_auth: failed to send prompt card: %s", e) + + uat = await device_flow.poll( + init.device_code, + interval=init.interval or ub.device_poll_interval_seconds, + timeout_seconds=init.expires_in, + ) + uat.open_id = user_open_id + if not uat.scopes: + uat.scopes = list(scopes) + await token_store.set(user_open_id, uat) + return uat diff --git a/lark_channel/channel/bot_identity.py b/lark_channel/channel/bot_identity.py new file mode 100644 index 0000000..886dd87 --- /dev/null +++ b/lark_channel/channel/bot_identity.py @@ -0,0 +1,141 @@ +"""Bot identity auto-fetch. + +Resolution flow: + 1. Try `GET /open-apis/bot/v3/info` (returns app_id / activate_status / name / open_id). + 2. Fall back to `GET /open-apis/application/v6/applications/:app_id` (richer data). + 3. Return `BotIdentity(open_id, user_id?, name?)`; the caller persists it on + the channel so group `@Bot` detection works correctly. + +The SDK does not ship a `bot.v3` generated resource, so we go through the raw +Transport / BaseRequest primitives with a tenant token. +""" + +import json +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from lark_channel.core.const import UTF_8 +from lark_channel.core.enum import AccessTokenType, HttpMethod +from lark_channel.core.http import Transport +from lark_channel.core.log import logger +from lark_channel.core.model import BaseRequest, Config, RawResponse, RequestOption +from lark_channel.core.token.auth import verify as _verify_auth + + +@dataclass +class BotIdentity: + """Resolved bot identity: ``open_id`` plus optional ``user_id`` / ``name``. + + ``name`` defaults to empty string because the ``/bot/v3/info`` fallback + path may not surface it in some tenant configurations — at which point + we'd rather degrade than crash. ``app_id`` is included for convenience + when callers need to correlate identities with their app registration. + """ + + open_id: str + user_id: Optional[str] = None + name: str = "" + app_id: Optional[str] = None + + +def _raw_request(uri: str, method: HttpMethod = HttpMethod.GET) -> BaseRequest: + req = BaseRequest() + req.http_method = method + req.uri = uri + req.token_types = {AccessTokenType.TENANT} + return req + + +async def fetch_bot_identity(config: Config) -> Optional[BotIdentity]: + """Return the bot's identity tuple, or None on failure. + + Preferred path: /bot/v3/info — cheap and always available when the bot + capability is enabled. Falls back to application info. + """ + primary = await _try_bot_v3_info(config) + if primary is not None: + return primary + return await _try_application_get(config) + + +async def _try_bot_v3_info(config: Config) -> Optional[BotIdentity]: + try: + req = _raw_request("/open-apis/bot/v3/info") + option = RequestOption() + # Raw `Transport.aexecute` bypasses the Chain that normally injects + # the tenant token via `core.token.auth.verify`. Call it explicitly + # so `option.tenant_access_token` is populated before the request + # goes out (otherwise the header becomes literal `Bearer None` and + # Feishu rejects with 400). + _verify_auth(config, req, option) + resp = await Transport.aexecute(config, req, option) + data = _parse_data(resp) + if not data: + return None + bot = data.get("bot") or data + open_id = bot.get("open_id") or bot.get("openid") or "" + if not open_id: + return None + return BotIdentity( + open_id=open_id, + user_id=bot.get("user_id"), + name=bot.get("app_name") or bot.get("name"), + app_id=bot.get("app_id") or config.app_id, + ) + except Exception as e: + logger.debug("fetch_bot_identity: /bot/v3/info failed: %s", e) + return None + + +async def _try_application_get(config: Config) -> Optional[BotIdentity]: + if not config.app_id: + return None + try: + uri = f"/open-apis/application/v6/applications/{config.app_id}?lang=zh_cn" + req = _raw_request(uri) + option = RequestOption() + _verify_auth(config, req, option) # inject tenant token; see _try_bot_v3_info + resp = await Transport.aexecute(config, req, option) + data = _parse_data(resp) + if not data: + return None + app = data.get("app") or data.get("application") or data + # application.v6 doesn't directly return open_id; some tenants expose + # `bot_info.open_id` though. Best-effort extraction. + open_id = "" + bot_info = app.get("bot_info") or app.get("bot") or {} + if isinstance(bot_info, dict): + open_id = bot_info.get("open_id") or bot_info.get("openid") or "" + if not open_id: + return None + return BotIdentity( + open_id=open_id, + name=app.get("app_name") or app.get("name"), + app_id=config.app_id, + ) + except Exception as e: + logger.debug("fetch_bot_identity: /applications fallback failed: %s", e) + return None + + +def _parse_data(resp: RawResponse) -> Optional[Dict[str, Any]]: + if resp is None or resp.content is None: + return None + try: + body = json.loads(resp.content.decode(UTF_8)) + except (ValueError, UnicodeDecodeError): + return None + if not isinstance(body, dict): + return None + if body.get("code") and body.get("code") != 0: + logger.debug("fetch_bot_identity: api returned code=%s msg=%s", body.get("code"), body.get("msg")) + return None + # Most Feishu OpenAPI endpoints wrap payload in a `data` envelope, but + # `/bot/v3/info` puts `bot` directly at the top level (alongside the + # `code` / `msg` envelope keys). Support both shapes: + # {"data": {"bot": {...}}, "code": 0} → return data + # {"bot": {...}, "code": 0, "msg": ""} → return body minus envelope + data = body.get("data") + if isinstance(data, dict): + return data + return {k: v for k, v in body.items() if k not in ("code", "msg")} diff --git a/lark_channel/channel/card/__init__.py b/lark_channel/channel/card/__init__.py new file mode 100644 index 0000000..a55d6ce --- /dev/null +++ b/lark_channel/channel/card/__init__.py @@ -0,0 +1,9 @@ +"""CardKit v2 card builder (Python ergonomics helper). + +No streaming controller here — streaming is handled by +:mod:`lark_channel.channel.outbound.streaming` (node-aligned ``update()`` API). +""" + +from .builder import CardBuilder, new_card + +__all__ = ["CardBuilder", "new_card"] diff --git a/lark_channel/channel/card/builder.py b/lark_channel/channel/card/builder.py new file mode 100644 index 0000000..106e95c --- /dev/null +++ b/lark_channel/channel/card/builder.py @@ -0,0 +1,337 @@ +"""CardKit v2 builder — fluent chain to compose interactive cards. + +Common elements are typed; `.raw()` provides an escape hatch for JSON not +covered by the builder methods. +""" + +import re +from copy import deepcopy +from typing import Any, Dict, List, Optional + +from ..types import CardPayload + + +# Cell values containing these constructs should be rendered via `lark_md` +# so bold/italic/code/link stay formatted; plain cells use `text`. +_INLINE_MD_IN_CELL_RE = re.compile( + r"(\*\*[^*]+\*\*)|(\*[^*\s][^*]*\*)|(`[^`\n]+`)|(\[[^\]]+\]\([^)]+\))" +) + + +HeaderTemplate = str # 'blue' | 'red' | 'green' | ... + + +class CardBuilder: + """Fluent v2 card builder. + + Example: + >>> c = (new_card() + ... .header(title="Deploy", template="blue") + ... .markdown("Running **step 2**") + ... .divider() + ... .button(label="Approve", action={"type": "approve"}, style="primary") + ... .build()) + """ + + def __init__(self) -> None: + self._header: Optional[Dict[str, Any]] = None + self._body_elements: List[Dict[str, Any]] = [] + self._config: Dict[str, Any] = {} + self._variables: Dict[str, Any] = {} + self._streaming: bool = False + + # ---- meta ----------------------------------------------------------------- + def streaming(self, enabled: bool = True) -> "CardBuilder": + """Mark this card for use with the streaming card helpers.""" + self._streaming = enabled + return self + + def config(self, **kwargs: Any) -> "CardBuilder": + self._config.update(kwargs) + return self + + def variable(self, name: str, value: Any) -> "CardBuilder": + self._variables[name] = value + return self + + # ---- header --------------------------------------------------------------- + def header( + self, + title: str, + *, + subtitle: Optional[str] = None, + template: HeaderTemplate = "blue", + icon: Optional[Dict[str, Any]] = None, + ) -> "CardBuilder": + h: Dict[str, Any] = {"title": {"tag": "plain_text", "content": title}} + if subtitle: + h["subtitle"] = {"tag": "plain_text", "content": subtitle} + if template: + h["template"] = template + if icon: + h["icon"] = icon + self._header = h + return self + + # ---- body element helpers ------------------------------------------------ + def markdown(self, content: str) -> "CardBuilder": + self._body_elements.append({"tag": "markdown", "content": content}) + return self + + def text(self, content: str) -> "CardBuilder": + self._body_elements.append( + {"tag": "div", "text": {"tag": "plain_text", "content": content}} + ) + return self + + def divider(self) -> "CardBuilder": + self._body_elements.append({"tag": "hr"}) + return self + + def image(self, img_key: str, *, alt: Optional[str] = None, title: Optional[str] = None) -> "CardBuilder": + el: Dict[str, Any] = {"tag": "img", "img_key": img_key} + if alt: + el["alt"] = {"tag": "plain_text", "content": alt} + if title: + el["title"] = {"tag": "plain_text", "content": title} + self._body_elements.append(el) + return self + + def note(self, elements: List[Dict[str, Any]]) -> "CardBuilder": + """Emit a subtle-styled line. + + The v1 `note` tag is gone in CardKit v2; we flatten plain_text children + into a single grey markdown line so existing builder chains keep working. + """ + if isinstance(elements, list): + parts = [] + for el in elements: + if isinstance(el, dict) and el.get("tag") == "plain_text": + parts.append(el.get("content", "")) + elif isinstance(el, dict): + parts.append(el.get("content", "") or el.get("text", "")) + else: + parts.append(str(el)) + text = " · ".join(p for p in parts if p) + else: + text = str(elements) + self._body_elements.append( + {"tag": "markdown", "content": f"{text}"} + ) + return self + + def code_block(self, content: str, language: str = "text") -> "CardBuilder": + self._body_elements.append( + { + "tag": "markdown", + "content": f"```{language}\n{content}\n```", + } + ) + return self + + # ---- interactive elements ----------------------------------------------- + # CardKit v2 note: the `action` container tag from v1 is gone. Interactive + # widgets (button / select_static / etc.) are emitted as top-level body + # elements; multiple buttons on one row use `column_set`. + + def _build_button( + self, + *, + label: str, + action: Optional[Dict[str, Any]] = None, + style: str = "default", + url: Optional[str] = None, + confirm: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + btn: Dict[str, Any] = { + "tag": "button", + "text": {"tag": "plain_text", "content": label}, + "type": style, + } + if url: + btn["url"] = url + if action is not None: + btn["value"] = action + if confirm: + btn["confirm"] = confirm + return btn + + def button( + self, + label: str, + *, + action: Optional[Dict[str, Any]] = None, + style: str = "default", + url: Optional[str] = None, + confirm: Optional[Dict[str, Any]] = None, + ) -> "CardBuilder": + self._body_elements.append( + self._build_button(label=label, action=action, style=style, url=url, confirm=confirm) + ) + return self + + def buttons(self, items: List[Dict[str, Any]]) -> "CardBuilder": + """Render several buttons in one horizontal row via column_set. + + Each item uses `button`-style keys: label, action, style, url, confirm. + """ + cols: List[Dict[str, Any]] = [] + for it in items: + btn = self._build_button( + label=it.get("label", ""), + action=it.get("action"), + style=it.get("style", "default"), + url=it.get("url"), + confirm=it.get("confirm"), + ) + cols.append({"tag": "column", "elements": [btn]}) + self._body_elements.append({"tag": "column_set", "columns": cols}) + return self + + def select( + self, + placeholder: str, + *, + options: List[Dict[str, str]], + action: Optional[Dict[str, Any]] = None, + ) -> "CardBuilder": + sel: Dict[str, Any] = { + "tag": "select_static", + "placeholder": {"tag": "plain_text", "content": placeholder}, + "options": [ + { + "text": {"tag": "plain_text", "content": o.get("label", "")}, + "value": o.get("value", ""), + } + for o in options + ], + } + if action is not None: + sel["value"] = action + self._body_elements.append(sel) + return self + + def column_set(self, columns: List["CardBuilder"]) -> "CardBuilder": + """Emit a column_set whose sub-columns are themselves built via CardBuilder. + + Only the body elements of each sub-builder are embedded (header and + streaming flags on sub-columns are ignored). + """ + cols = [{"tag": "column", "elements": c._body_elements} for c in columns] + self._body_elements.append({"tag": "column_set", "columns": cols}) + return self + + def table( + self, + headers: List[str], + rows: List[List[str]], + *, + page_size: int = 5, + data_types: Optional[List[str]] = None, + ) -> "CardBuilder": + """Append a native Card 2.0 ``table`` component. + + Emits the structured ``{"tag": "table", "columns": [...], "rows": [...]}`` + element — **not** a GFM pipe-table stuffed into a markdown element. + Feishu's ``markdown`` and post ``md`` tags silently drop pipe-table + syntax, so the old string-based rendering showed as raw pipes or + blank messages. Use ``.raw({...})`` only if you need fields beyond + what this signature covers. + + ``data_types`` overrides the per-column auto-detection. When omitted, + columns whose cells contain inline markdown (bold / italic / inline + code / link) pick ``lark_md``; plain columns pick ``text``. + ``page_size`` is clamped to Feishu's 1-10 range. + """ + normalized_rows: List[List[str]] = [] + for row in rows: + cells = [str(c) if c is not None else "" for c in row] + while len(cells) < len(headers): + cells.append("") + normalized_rows.append(cells[: len(headers)]) + + if data_types is not None and len(data_types) != len(headers): + raise ValueError( + "data_types length must match headers length" + ) + + columns: List[Dict[str, Any]] = [] + for idx, header in enumerate(headers): + if data_types is not None: + data_type = data_types[idx] + else: + column_cells = (row[idx] for row in normalized_rows) + data_type = ( + "lark_md" + if any(_INLINE_MD_IN_CELL_RE.search(c) for c in column_cells) + else "text" + ) + columns.append({ + "name": f"col_{idx}", + "display_name": header, + "data_type": data_type, + }) + + rows_data = [ + {f"col_{idx}": cell for idx, cell in enumerate(row)} + for row in normalized_rows + ] + + self._body_elements.append({ + "tag": "table", + "page_size": max(1, min(10, page_size)), + "columns": columns, + "rows": rows_data, + }) + return self + + def progress(self, percent: int, *, label: Optional[str] = None) -> "CardBuilder": + percent = max(0, min(100, percent)) + bar = "▓" * (percent // 5) + "░" * (20 - percent // 5) + md = f"`{bar}` **{percent}%**" + if label: + md = f"{label}\n{md}" + return self.markdown(md) + + def footer(self, text: str) -> "CardBuilder": + self._body_elements.append( + {"tag": "markdown", "content": f"{text}"} + ) + return self + + # ---- escape hatch -------------------------------------------------------- + def raw(self, element: Dict[str, Any]) -> "CardBuilder": + self._body_elements.append(element) + return self + + # ---- build --------------------------------------------------------------- + def to_dict(self) -> Dict[str, Any]: + card_dict: Dict[str, Any] = { + "schema": "2.0", + "config": dict(self._config), + "body": {"elements": list(self._body_elements)}, + } + if self._streaming: + card_dict["config"]["streaming_mode"] = True + card_dict["config"]["summary"] = {"content": ""} + if self._header is not None: + card_dict["header"] = deepcopy(self._header) + if self._variables: + card_dict["variables"] = dict(self._variables) + return card_dict + + def build(self) -> CardPayload: + return CardPayload(data=self.to_dict(), version="v2") + + +def new_card() -> CardBuilder: + """Factory helper: return a fresh :class:`CardBuilder`. + + Prefer ``new_card()`` over instantiating ``CardBuilder()`` directly — the + name documents intent and matches TypeScript's ``lark.newCard()``. + """ + return CardBuilder() + + +# Back-compat alias; scheduled for removal after 2.0. +card = new_card diff --git a/lark_channel/channel/channel.py b/lark_channel/channel/channel.py new file mode 100644 index 0000000..61011e6 --- /dev/null +++ b/lark_channel/channel/channel.py @@ -0,0 +1,2567 @@ +"""lark_channel.channel.channel — the FeishuChannel capability layer. + +A single class owning lifecycle, transport (WS / webhook), inbound +normalization, safety pipeline, outbound sender, and streaming. + +The event-registration API uses string events:: + + channel.on("message", handler) # handler(inbound: InboundMessage) + channel.on("cardAction", handler) # handler(event: CardActionEvent) + channel.on("reaction", handler) # handler(event: ReactionEvent) + channel.on("botAdded", handler) # handler(event: BotAddedEvent) + channel.on("botLeave", handler) + channel.on("messageRead", handler) + channel.on("comment", handler) + channel.on("reject", handler) # handler(event: RejectEvent) + channel.on("reconnecting", handler) + channel.on("reconnected", handler) + channel.on("error", handler) + +For sending, streaming, message ops — use the channel methods directly +(``channel.send(...)``, ``channel.stream(...)``, ``channel.update_card(...)``, +etc). There are no typed-context hooks on event payloads — work with the +plain event data and call back into the channel. + +Orchestration-only. Orthogonal concerns live next door: + +- Input coercion → :mod:`._coerce` +- Raw Lark API calls → :mod:`._api_helpers` +- UAT device flow → :mod:`.auth.uat_runner` +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import inspect +import json +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Set, Union + +from lark_channel.client import Client +from lark_channel.core.enum import LogLevel +from lark_channel.core.log import logger +from lark_channel.event.callback.model.p2_card_action_trigger import ( + P2CardActionTrigger, + P2CardActionTriggerResponse, +) +from lark_channel.event.dispatcher_handler import EventDispatcherHandler +from lark_channel.ws.client import Client as WSClient + +from . import _api_helpers, _coerce +from .auth.device_flow import DeviceFlowClient +from .events import ChannelEventName +from lark_channel.core.cache import ICache + +from .auth.token_store import InMemoryTokenStore, TokenStore +from .auth.uat_runner import require_user_auth +from .bot_identity import BotIdentity, fetch_bot_identity +from .chat_mode import ChatModeCache +from .comment import CommentPrimitiveClient +from .config import ( + ChannelConfig, + InboundConfig, + OutboundConfig, + PolicyConfig, + SafetyConfig, + SecurityConfig, + TransportConfig, + UATConfig, +) +from .driver import LarkClientDriver +from .errors import FeishuChannelError, FeishuChannelErrorCode, OutboundSendError, SendError +from .identity import IdentityResolver, NameCache +from .keepalive import KeepaliveWatchdog +from .media_cache import MediaResourceCache +from .normalize.comment import normalize_comment +from .normalize.dedup import Deduper, InMemoryDedupStore +from .normalize.pipeline import InboundPipeline, PipelineConfig, PipelineDeps +from .outbound.routing import infer_receive_id_type +from .outbound.sender import OutboundSender +from .outbound.streaming.card_stream import CardStreamController +from .outbound.streaming.markdown_stream import MarkdownStreamController +from .quote import QuoteResolver +from .safety import RejectEvent, SafetyPipeline +from .types import ( + UAT, + BotAddedEvent, + BotLeaveEvent, + CachedResource, + CardActionEvent, + CardActionPayload, + ChatInfo, + CommentContext, + CommentTarget, + ConnectionSnapshot, + EventOperator, + MediaSource, + MessageReadEvent, + OutboundCard, + OutboundPost, + OutboundText, + ReactionEvent, + ResourceDescriptor, + SendResult, +) + +EventHandler = Callable[..., Any] +Unsubscribe = Callable[[], None] + + +@dataclass +class _SentMessageContext: + message_id: str + chat_id: str + chat_type: Optional[str] = None + receive_id_type: Optional[str] = None + + +def _card_action_identity(action: Any) -> str: + """Stable dedup fragment for a card action. + + Different buttons on the same card click to different ``(tag, value)`` + pairs, and those must dedup-distinctly; a genuine WS redelivery of the + same click hashes identically and is suppressed. + """ + tag = getattr(action, "tag", "") or "" + value = getattr(action, "value", None) + extras = { + key: getattr(action, key, None) + for key in ("form_value", "input_value", "options", "checked") + if getattr(action, key, None) is not None + } + if not extras: + value_repr = _legacy_card_action_value_identity(value) + return f"{tag}:{value_repr}" + value_repr = json.dumps( + _json_safe_identity_value(value), + sort_keys=True, + ensure_ascii=False, + ) + extras_repr = json.dumps( + _json_safe_identity_value(extras), + sort_keys=True, + ensure_ascii=False, + ) + return f"{tag}:{value_repr}:{extras_repr}" + + +def _legacy_card_action_value_identity(value: Any) -> str: + try: + return json.dumps(value, sort_keys=True, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) + + +def _json_safe_identity_value(value: Any, seen: Optional[Set[int]] = None) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if seen is None: + seen = set() + if isinstance(value, dict): + oid = id(value) + if oid in seen: + return "" + seen.add(oid) + try: + return { + _json_safe_identity_key(key): _json_safe_identity_value(val, seen) + for key, val in value.items() + } + finally: + seen.discard(oid) + if isinstance(value, (list, tuple)): + oid = id(value) + if oid in seen: + return "" + seen.add(oid) + try: + return [_json_safe_identity_value(item, seen) for item in value] + finally: + seen.discard(oid) + return f"" + + +def _json_safe_identity_key(key: Any) -> str: + if isinstance(key, str): + return key + if key is None or isinstance(key, (int, float, bool)): + return str(key) + return f"" + + +def _extract_first_message_item(raw: Any) -> Optional[Dict[str, Any]]: + if not isinstance(raw, dict): + return None + data = raw.get("data") + if isinstance(data, dict): + items = data.get("items") + if isinstance(items, list) and items and isinstance(items[0], dict): + return items[0] + message = data.get("message") + if isinstance(message, dict): + return message + if raw.get("message_id"): + return raw + return None + + +def _normalize_fetched_message_item(item: Dict[str, Any]) -> Dict[str, Any]: + msg = dict(item) + if not msg.get("message_type") and msg.get("msg_type"): + msg["message_type"] = msg.get("msg_type") + if msg.get("content") is None: + body = msg.get("body") + if isinstance(body, dict) and body.get("content") is not None: + msg["content"] = body.get("content") + return msg + + +def _extract_fetched_sender(item: Dict[str, Any]) -> Any: + sender = item.get("sender") + if sender is not None: + if isinstance(sender, dict) and "sender_id" not in sender: + sender_id = {} + if sender.get("open_id"): + sender_id["open_id"] = sender.get("open_id") + if sender.get("user_id"): + sender_id["user_id"] = sender.get("user_id") + if sender.get("union_id"): + sender_id["union_id"] = sender.get("union_id") + raw_id = sender.get("id") + id_type = sender.get("id_type") + if raw_id: + if id_type == "user_id": + sender_id.setdefault("user_id", raw_id) + elif id_type == "union_id": + sender_id.setdefault("union_id", raw_id) + elif id_type in (None, "", "open_id"): + sender_id.setdefault("open_id", raw_id) + if sender_id: + return { + "sender_id": sender_id, + "sender_type": sender.get("sender_type") or sender.get("type") or "user", + } + return sender + sender_id = item.get("sender_id") + if isinstance(sender_id, dict): + return {"sender_id": sender_id, "sender_type": item.get("sender_type") or "user"} + return {} + + +# --------------------------------------------------------------------------- +# FeishuChannel +# --------------------------------------------------------------------------- + + +class FeishuChannel: + """Single public entry point for the Feishu Channel capability layer. + + Construct with flat keyword arguments for the common case:: + + channel = FeishuChannel(app_id="cli_xxx", app_secret="***") + + Or tune any of the five functional areas (``policy`` / ``safety`` / + ``inbound`` / ``outbound`` / ``uat``) by passing a config dataclass:: + + from lark_channel import ( + FeishuChannel, SafetyConfig, DedupConfig, + OutboundConfig, RetryConfig, + ) + + channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + safety=SafetyConfig(dedup=DedupConfig(ttl_seconds=43_200)), + outbound=OutboundConfig(retry=RetryConfig(max_attempts=5)), + ) + + For pre-built full configs, pass ``config=ChannelConfig(...)``; per-area + kwargs override the fields they touch. + """ + + def __init__( + self, + *, + app_id: Optional[str] = None, + app_secret: Optional[str] = None, + encrypt_key: Optional[str] = None, + verification_token: Optional[str] = None, + domain: Optional[str] = None, + log_level: Optional[LogLevel] = None, + transport: Optional[Union[str, TransportConfig]] = None, + policy: Optional[PolicyConfig] = None, + safety: Optional[SafetyConfig] = None, + inbound: Optional[InboundConfig] = None, + outbound: Optional[OutboundConfig] = None, + uat: Optional[UATConfig] = None, + security: Optional[SecurityConfig] = None, + token_store: Optional[TokenStore] = None, + dedup_store: Any = None, + safety_cache: Optional[ICache] = None, + name_lookup: Optional[Callable[[List[str]], Any]] = None, + config: Optional[ChannelConfig] = None, + ) -> None: + """Create a channel. + + ``dedup_store`` and ``safety_cache`` target **different** dedup + layers — this is a deliberate split, not a redundancy: + + - ``dedup_store`` (:class:`~.normalize.dedup.DedupStore`) feeds the + *pipeline* ``Deduper`` that catches webhook retries + WS reconnect + backfill at the normalize step. + - ``safety_cache`` (:class:`~lark_channel.core.cache.ICache`, usually + Redis-backed) feeds the *safety* ``SeenCache`` used by + :class:`SafetyPipeline` for pre-dispatch dedup + (with an atomic + SETNX implementation) cross-process coherence. + + Both default to in-memory when left as ``None``. + """ + cfg = config if config is not None else ChannelConfig() + if app_id is not None: + cfg.app_id = app_id + if app_secret is not None: + cfg.app_secret = app_secret + if encrypt_key is not None: + cfg.encrypt_key = encrypt_key + if verification_token is not None: + cfg.verification_token = verification_token + if domain is not None: + cfg.domain = domain + if log_level is not None: + cfg.log_level = log_level + if policy is not None: + cfg.policy = policy + if safety is not None: + cfg.safety = safety + if inbound is not None: + cfg.inbound = inbound + if outbound is not None: + cfg.outbound = outbound + if uat is not None: + cfg.uat = uat + if security is not None: + cfg.security = security + if transport is not None: + if isinstance(transport, str): + if transport not in ("ws", "webhook"): + raise ValueError( + f"transport must be 'ws' or 'webhook', got {transport!r}" + ) + cfg.transport = TransportConfig(kind=transport) + else: + cfg.transport = transport + if not cfg.app_id or not cfg.app_secret: + raise ValueError("FeishuChannel requires app_id and app_secret") + + self._config = cfg + self._safety_cache = safety_cache + + self._client = ( + Client.builder() + .app_id(cfg.app_id) + .app_secret(cfg.app_secret) + .domain(cfg.domain) + .log_level(cfg.log_level) + .timeout(cfg.transport.http_timeout_seconds) + .proxy_url(cfg.transport.proxy_url) + .trust_env_proxy(cfg.transport.trust_env_proxy) + .build() + ) + # Mark all HTTP requests originating from this capability layer with a + # bare ``channel`` token in the User-Agent so the backend can attribute + # traffic to the channel SDK. + if self._client._config is not None: + self._client._config.extra_ua_tags = ["channel"] + self._client._config.security = cfg.security + self._driver = LarkClientDriver(self._client) + self._sender = OutboundSender( + driver=self._driver.send_driver(), + config=cfg.outbound, + on_success=self._track_sent_message, + ) + + self._dedup_store = dedup_store or InMemoryDedupStore( + max_entries=cfg.safety.dedup.max_entries + ) + self._deduper = Deduper( + store=self._dedup_store, + ttl_seconds=cfg.safety.dedup.ttl_seconds, + enabled=cfg.safety.dedup.enabled, + ) + + self._identity_resolver = IdentityResolver( + lookup=name_lookup + or (lambda ids: _api_helpers.default_name_lookup(self._client, ids)), + cache=NameCache(cfg.inbound.name_cache), + ) + self._chat_mode_cache = ChatModeCache(cfg.chat_mode_cache) + self._media_cache = MediaResourceCache(cfg.media_cache) + + self._token_store: TokenStore = token_store or InMemoryTokenStore() + self._device_flow = DeviceFlowClient( + app_id=cfg.app_id, + app_secret=cfg.app_secret, + domain=cfg.domain, + ) + + self._pipeline = InboundPipeline( + cfg=PipelineConfig( + inbound=cfg.inbound, security=cfg.security, account_id=cfg.app_id + ), + deps=PipelineDeps( + fetch_message=self._fetch_message_payload, + resolve_names=self._identity_resolver.resolve_names, + resolve_identity=self._identity_resolver.resolve, + ), + deduper=self._deduper, + ) + self._pipeline.set_chat_mode_resolver(self.get_chat_mode) + + self._safety: Optional[SafetyPipeline] = None + + # String-event handlers. Multiple handlers per event name are appended + # in registration order; `_invoke` iterates them all. + self._handlers: Dict[str, List[EventHandler]] = {} + + self._dispatcher: Optional[EventDispatcherHandler] = None + self._ws_client: Optional[WSClient] = None + + self._bot_identity: Optional[BotIdentity] = None + self._bot_open_id: Optional[str] = None + # Guards reads / writes to ``_bot_identity`` + ``_bot_open_id`` + + # ``_safety.set_bot_open_id`` so the initial fetch, an explicit + # ``await resolve_bot_identity()``, and the background retry loop + # can't leave the two fields in a half-updated state when a reader + # on the bg loop is inspecting them. + self._bot_identity_lock = threading.Lock() + self._bot_identity_retry_future: Optional[concurrent.futures.Future] = None + + self._sent_messages: "OrderedDict[str, float]" = OrderedDict() + self._sent_message_context: "OrderedDict[str, _SentMessageContext]" = OrderedDict() + self._sent_messages_max = 2048 + self._start_future: Optional[asyncio.Future] = None + + self._bg_loop: Optional[asyncio.AbstractEventLoop] = None + self._bg_thread: Optional[threading.Thread] = None + # Guards start/stop of the bg loop so concurrent connect() calls can't + # spawn two loops + two threads. + self._bg_lock = threading.Lock() + + # References to Futures returned by `schedule(...)` — kept alive so + # exceptions surface (done callback logs) and so shutdown can drain / + # cancel in-flight work. + self._bg_tasks: Set[concurrent.futures.Future] = set() + self._bg_tasks_lock = threading.Lock() + + self._shutdown = threading.Event() + self._stop_requested = False + self._started = False + self._lifecycle_lock = threading.Lock() + self._lifecycle_generation = 0 + self._background_generation = 0 + # Lazy-init: asyncio.Event() in older Python (<3.10) requires a + # running loop; create on first access from a coroutine context. + self._ready_event: Optional[asyncio.Event] = None + self._ready_flag: bool = False + self._connection_state: str = "idle" + self._connection_reconnect_attempts: int = 0 + self._connection_last_connected_at: Optional[float] = None + self._connection_last_disconnected_at: Optional[float] = None + self._connection_last_error_at: Optional[float] = None + self._connection_last_error: Optional[str] = None + self._keepalive_watchdog: Optional[KeepaliveWatchdog] = None + self._keepalive_future: Optional[concurrent.futures.Future] = None + + # ------------------------------------------------------------------ + # Event registration + # ------------------------------------------------------------------ + def on( + self, + name_or_map: Union[ChannelEventName, Dict[ChannelEventName, EventHandler]], + handler: Optional[EventHandler] = None, + ) -> Unsubscribe: + """Register an event handler. + + Accepts ``on(event_name, handler)`` or ``on({event_name: handler})``. + Returns an unsubscribe callable that pops exactly this handler from + the list. + + ``name_or_map`` is type-hinted with :data:`.events.ChannelEventName` + so static type checkers catch typos (e.g. ``"messageReceive"`` vs + the correct ``"message"``). At runtime, unknown names produce a + warning log but do not raise — several historical aliases are still + accepted via :mod:`._coerce`. + """ + if isinstance(name_or_map, dict): + subs = [self._register_single(k, v) for k, v in name_or_map.items() if v] + return lambda: [u() for u in subs] + if handler is None: + raise TypeError( + "FeishuChannel.on expects a handler when called with an event name" + ) + return self._register_single(name_or_map, handler) + + def _register_single(self, name: str, handler: EventHandler) -> Unsubscribe: + normalized = _coerce.normalize_event_name(name) + if normalized not in _coerce.VALID_EVENTS: + logger.warning("FeishuChannel.on: unknown event %r", name) + self._handlers.setdefault(normalized, []).append(handler) + + def unsubscribe() -> None: + lst = self._handlers.get(normalized) + if not lst: + return + try: + lst.remove(handler) + except ValueError: + return + if not lst: + self._handlers.pop(normalized, None) + + return unsubscribe + + async def _invoke(self, name: str, *args) -> None: + handlers = self._handlers.get(_coerce.normalize_event_name(name)) + if not handlers: + return + # Iterate a snapshot so handlers unsubscribing themselves mid-invoke + # don't mutate the list we're walking. + for handler in list(handlers): + try: + result = handler(*args) + if inspect.isawaitable(result): + await result + except Exception as e: + logger.exception( + "FeishuChannel: handler for %r raised: %s", name, e + ) + for err in self._handlers.get("error", []): + try: + res = err(e) + if inspect.isawaitable(res): + await res + except Exception: # pragma: no cover + pass + + # ------------------------------------------------------------------ + # Properties / escape hatches + # ------------------------------------------------------------------ + @property + def client(self) -> Client: + """The underlying OpenAPI ``Client``.""" + return self._client + + @property + def ws_client(self) -> Optional[WSClient]: + """The WebSocket client if the channel was started in ``ws`` mode.""" + return self._ws_client + + @property + def bot_identity(self) -> Optional[BotIdentity]: + """Currently-resolved bot identity, or ``None`` if not yet fetched. + + Read under the same lock that guards writes so callers never observe + a half-updated state (``_bot_identity`` fresh but ``_bot_open_id`` + stale or vice-versa). + """ + with self._bot_identity_lock: + return self._bot_identity + + @property + def config(self) -> ChannelConfig: + return self._config + + @property + def safety(self) -> Optional[SafetyPipeline]: + return self._safety + + @property + def sender(self) -> OutboundSender: + return self._sender + + @property + def driver(self) -> LarkClientDriver: + return self._driver + + @property + def dispatcher(self) -> EventDispatcherHandler: + if self._dispatcher is None: + self._dispatcher = self._build_dispatcher() + return self._dispatcher + + async def handle_webhook_request( + self, + headers: "Mapping[str, str]", + body: bytes, + ) -> "tuple[int, bytes]": + """Process a single inbound webhook request. + + This is the framework-agnostic entry point — wrap with aiohttp / + starlette / fastapi / your favorite web layer. The SDK does not ship + a built-in webhook server; rate limiting, anomaly tracking, and IP + allowlisting are deployment concerns that live in your service. + + Behavior: + + - Decrypts the body using ``encrypt_key`` (if configured). + - Verifies the request via ``verification_token`` (if configured). + - Verifies the request signature against the headers (when + ``encrypt_key`` is set, the dispatcher checks + ``X-Lark-Request-Timestamp`` / ``X-Lark-Request-Nonce`` / + ``X-Lark-Signature``). + - Routes the event to your registered ``channel.on(...)`` handlers. + + Args: + headers: HTTP request headers (typically a dict-like; case + sensitivity follows your framework's conventions). + body: Raw HTTP request body bytes. + + Returns: + ``(status_code, response_body_bytes)`` — write the body straight + back to your HTTP response. + + Raises: + FeishuChannelError(NOT_CONNECTED): When called before ``start()``. + The dispatcher converts bad-token / signature failures to a + 500 response body itself; this method does not raise on those. + """ + if self._dispatcher is None: + raise FeishuChannelError( + FeishuChannelErrorCode.NOT_CONNECTED, + "handle_webhook_request called before start() — dispatcher missing", + ) + + from lark_channel.core.model.raw_request import RawRequest + req = RawRequest() + req.uri = "/webhook" + req.headers = dict(headers) if headers else {} + req.body = body + + # The dispatcher's `do` is sync; offload so we don't block the + # caller's event loop on signature verification + JSON parse. + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, self._dispatcher.do, req) + status = getattr(resp, "status_code", 200) or 200 + content = getattr(resp, "content", b"") + if isinstance(content, str): + content = content.encode("utf-8") + if not isinstance(content, (bytes, bytearray)): + content = bytes(content) if content is not None else b"" + return status, content + + def get_policy(self) -> PolicyConfig: + return self._config.policy + + def update_policy(self, **changes) -> None: + if self._safety is not None: + self._safety.update_policy(**changes) + for k, v in changes.items(): + if hasattr(self._config.policy, k): + setattr(self._config.policy, k, v) + + # ------------------------------------------------------------------ + # Readiness + # ------------------------------------------------------------------ + @property + def is_ready(self) -> bool: + """True after start() has fully initialized. + + Specifically: WS connect has succeeded (or webhook dispatcher is + constructed), bot identity is resolved (or its retry loop is running), + and the safety pipeline is initialized. Events received before this + point are NOT dispatched to user handlers — they are dropped (the + WS-reconnect backfill or webhook retries cover startup-window losses). + """ + return self._ready_flag + + async def wait_ready(self, *, timeout: Optional[float] = None) -> None: + """Block until is_ready turns True. Raises asyncio.TimeoutError on timeout.""" + ev = self._ensure_ready_event() + if self._ready_flag: + return + if timeout is None: + await ev.wait() + return + await asyncio.wait_for(ev.wait(), timeout=timeout) + + def _mark_ready(self) -> None: + """Internal: flip the readiness event. Called at the end of start() + after WS connect, bot identity fetch, and safety pipeline init. + Idempotent. + """ + self._ready_flag = True + self._connection_state = "connected" + self._connection_last_connected_at = time.time() + ev = self._ready_event + if ev is not None: + try: + ev.set() + except Exception: # pragma: no cover + pass + + def _ensure_ready_event(self) -> "asyncio.Event": + """Lazily create the asyncio.Event so we don't need a running loop in __init__.""" + if self._ready_event is None: + self._ready_event = asyncio.Event() + if self._ready_flag: + self._ready_event.set() + return self._ready_event + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + async def connect(self) -> None: + """Idempotent. Blocks until WS connects or Webhook dispatcher is ready.""" + if getattr(self, "_started", False): + return + await asyncio.get_running_loop().run_in_executor(None, self.start) + + async def start_background(self, *, timeout: Optional[float] = 30.0) -> None: + """Start transport in the background and return once it is ready. + + ``connect()`` keeps the historical foreground/blocking WebSocket + behavior. Async applications that need startup to continue after the + WebSocket handshake should use this method instead. + """ + if self._ready_flag: + return + loop = asyncio.get_running_loop() + if self._start_future is None or self._start_future.done(): + with self._lifecycle_lock: + self._stop_requested = False + self._background_generation += 1 + background_generation = self._background_generation + self._start_future = loop.run_in_executor(None, self.start) + else: + with self._lifecycle_lock: + background_generation = self._background_generation + await self._wait_background_start_ready( + timeout=timeout, + generation=background_generation, + ) + + async def connect_until_ready(self, *, timeout: Optional[float] = 30.0) -> None: + """Alias for :meth:`start_background` with explicit ready semantics.""" + await self.start_background(timeout=timeout) + + async def stop_background(self) -> None: + """Stop a channel started by :meth:`start_background`. + + This is equivalent to :meth:`disconnect`; it is provided as the + lifecycle counterpart to ``start_background``. + """ + await self.disconnect() + + async def _wait_background_start_ready( + self, + *, + timeout: Optional[float], + generation: int, + ) -> None: + loop = asyncio.get_running_loop() + deadline = None if timeout is None else loop.time() + timeout + while True: + if self._ready_flag: + return + if generation != self._background_generation: + return + if self._stop_requested: + return + fut = self._start_future + if fut is None: + await asyncio.sleep(0.05) + continue + if fut is not None and fut.done(): + if fut.cancelled(): + if generation != self._background_generation or self._stop_requested: + return + raise FeishuChannelError( + FeishuChannelErrorCode.NOT_CONNECTED, + "Channel background start was cancelled", + ) + exc = fut.exception() + if exc is not None: + raise exc + if self._ready_flag or self._config.transport.kind == "webhook": + return + raise FeishuChannelError( + FeishuChannelErrorCode.NOT_CONNECTED, + "Channel start exited before transport became ready", + ) + ws = self._ws_client + if ws is not None and getattr(ws, "_conn", None) is not None: + self._mark_ready() + return + if deadline is not None and loop.time() >= deadline: + self.stop() + raise FeishuChannelError( + FeishuChannelErrorCode.NOT_CONNECTED, + "Timed out waiting for channel transport readiness", + ) + await asyncio.sleep(0.05) + + async def disconnect(self) -> None: + """Gracefully drain safety pipeline batches + stop the WS loop.""" + if self._safety is not None: + try: + await self._safety.dispose() + except Exception: # pragma: no cover + pass + self.stop() + + def start(self) -> None: + """Start WS (blocking) or return after initializing Webhook dispatcher. + + Transport-level failures (bad credentials, network unreachable, TLS + handshake failure, ...) are wrapped into + :class:`FeishuChannelError(NOT_CONNECTED)` with the original + exception chained via ``__cause__``. This gives callers a stable, + ``code``-bearing exception to match on instead of whatever raw + exception the underlying transport happens to raise (e.g. + ``lark_channel.ws.client.ClientException`` whose ``.code`` is an int). + """ + if self._started: + return + with self._lifecycle_lock: + self._lifecycle_generation += 1 + generation = self._lifecycle_generation + self._stop_requested = False + self._started = True + self._ensure_bg_loop() + self._fetch_bot_identity_sync() + self._dispatcher = self._build_dispatcher() + if self._config.transport.kind == "webhook": + with self._lifecycle_lock: + if not self._is_active_start(generation): + return + logger.info( + "FeishuChannel: webhook mode ready — pass `dispatcher` to your HTTP adaptor" + ) + self._mark_ready() + return + with self._lifecycle_lock: + if not self._is_active_start(generation): + return + self._ws_client = WSClient( + self._config.app_id, + self._config.app_secret, + log_level=self._config.log_level, + event_handler=self._dispatcher, + domain=self._config.domain, + auto_reconnect=self._config.transport.auto_reconnect, + extra_ua_tags=["channel"], + headers=self._config.transport.headers, + proxy_url=self._config.transport.proxy_url, + trust_env_proxy=self._config.transport.trust_env_proxy, + handshake_timeout=self._config.transport.handshake_timeout_seconds, + security=self._config.security, + ) + # Wire transport-level reconnect events to the public ``on()`` bus so + # callers registering ``on("reconnecting", ...) / on("reconnected", ...)`` + # actually observe them. + self._ws_client.on_reconnecting = self._notify_reconnecting + self._ws_client.on_reconnected = self._notify_reconnected + self._start_keepalive_watchdog() + try: + self._ws_client.start() + except FeishuChannelError: + # Already the right shape; just reset started so caller can retry. + self._cleanup_failed_start(generation) + raise + except Exception as e: + if not self._is_active_start(generation): + self._cleanup_failed_start(generation) + return + # Anything else (ws client's ClientException, timeouts, DNS, ...) + # → typed NOT_CONNECTED so callers can ``except FeishuChannelError + # as err: if err.code == FeishuChannelErrorCode.NOT_CONNECTED``. + self._record_connection_error(e) + self._cleanup_failed_start(generation) + raise FeishuChannelError( + FeishuChannelErrorCode.NOT_CONNECTED, + f"WebSocket connect failed: {e}", + ) from e + with self._lifecycle_lock: + if not self._is_active_start(generation): + self._stop_keepalive_watchdog() + return + self._mark_ready() + + def _is_active_start(self, generation: int) -> bool: + if ( + generation != self._lifecycle_generation + or self._shutdown.is_set() + or self._stop_requested + ): + if generation == self._lifecycle_generation: + self._started = False + return False + return True + + def _cleanup_failed_start(self, generation: int) -> None: + self._stop_keepalive_watchdog() + ws = self._ws_client + if ws is not None: + stopped = False + for meth in ("stop", "close", "disconnect"): + fn = getattr(ws, meth, None) + if callable(fn): + try: + fn() + except Exception as e: # pragma: no cover + logger.warning( + "FeishuChannel.start: ws.%s raised during cleanup: %s", + meth, + e, + ) + stopped = True + break + if not stopped: + self._stop_private_ws_client(ws) + self._cancel_bg_tasks() + self._stop_bg_loop(join_timeout=2.0) + with self._lifecycle_lock: + if generation == self._lifecycle_generation: + self._started = False + self._ready_flag = False + self._connection_state = "error" + self._ws_client = None + self._safety = None + + def stop(self, *, join_timeout: float = 5.0) -> None: + """Tear down everything the channel owns. + + Safe to call from any thread, idempotent. Steps: + + 1. Signal shutdown (sets ``self._shutdown``). + 2. Stop the WS client if one was created. + 3. Cancel in-flight futures returned from :meth:`schedule`. + 4. Run ``DeviceFlowClient.close()`` on the bg loop to release httpx. + 5. Stop the bg loop and join its thread. + """ + if self._shutdown.is_set(): + return + self._shutdown.set() + if self._start_future is not None: + try: + self._start_future.cancel() + except Exception: # pragma: no cover + pass + self._stop_keepalive_watchdog() + + # 1. Stop WS client (best-effort; some builds don't expose stop()). + with self._lifecycle_lock: + self._background_generation += 1 + self._lifecycle_generation += 1 + self._stop_requested = True + ws = self._ws_client + if ws is not None: + stopped = False + for meth in ("stop", "close", "disconnect"): + fn = getattr(ws, meth, None) + if callable(fn): + try: + fn() + except Exception as e: # pragma: no cover + logger.warning("FeishuChannel.stop: ws.%s raised: %s", meth, e) + stopped = True + break + if not stopped: + self._stop_private_ws_client(ws) + + # 2. Cancel scheduled futures. + self._cancel_bg_tasks() + + # 3. Close httpx client inside device_flow via the bg loop (if up). + if self._bg_loop is not None and self._bg_loop.is_running(): + try: + close_fut = asyncio.run_coroutine_threadsafe( + self._device_flow.close(), self._bg_loop + ) + try: + close_fut.result(timeout=2.0) + except (concurrent.futures.TimeoutError, Exception) as e: # pragma: no cover + logger.warning("FeishuChannel.stop: device_flow.close timed out: %s", e) + except RuntimeError: # pragma: no cover - loop closed between checks + pass + + # 4. Stop the bg loop + join thread. + self._stop_bg_loop(join_timeout=join_timeout) + self._ws_client = None + self._start_future = None + + # Allow a subsequent connect()/start() to actually run. Without + # clearing these two flags, a channel that was stopped would be + # permanently inert — any future connect() would short-circuit on + # ``self._started`` and ``_ensure_bg_loop`` would refuse on the + # persisted ``_shutdown``. + self._shutdown.clear() + self._started = False + self._ready_flag = False + self._connection_state = "idle" + self._connection_last_disconnected_at = time.time() + if self._ready_event is not None: + try: + self._ready_event.clear() + except Exception: # pragma: no cover + pass + with self._bg_tasks_lock: + self._bg_tasks.clear() + + def _stop_private_ws_client(self, ws: Any) -> None: + disconnect = getattr(ws, "_disconnect", None) + try: + from lark_channel.ws import client as ws_client_module + + ws_loop = getattr(ws_client_module, "loop", None) + if callable(disconnect) and ws_loop is not None: + if ws_loop.is_running(): + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is ws_loop: + ws_loop.create_task(disconnect()) + else: + fut = asyncio.run_coroutine_threadsafe(disconnect(), ws_loop) + try: + fut.result(timeout=2.0) + except Exception as e: # pragma: no cover + logger.warning("FeishuChannel.stop: ws._disconnect timed out: %s", e) + elif not ws_loop.is_closed(): + ws_loop.run_until_complete(disconnect()) + if ws_loop is not None and ws_loop.is_running(): + ws_loop.call_soon_threadsafe(ws_loop.stop) + except Exception as e: # pragma: no cover + logger.warning("FeishuChannel.stop: private ws shutdown raised: %s", e) + + def schedule(self, coro) -> "concurrent.futures.Future": + """Submit a coroutine to the background loop; safe from any thread. + + The returned Future is also tracked internally so exceptions surface + via a logging done-callback (no more fire-and-forget) and so + :meth:`stop` can cancel in-flight work. Callers may still ignore the + return value. + """ + self._ensure_bg_loop() + if self._bg_loop is None: + raise RuntimeError("FeishuChannel: background loop is not running") + try: + fut = asyncio.run_coroutine_threadsafe(coro, self._bg_loop) + except RuntimeError as e: # pragma: no cover - loop closed + logger.warning("FeishuChannel: schedule failed: %s", e) + raise + with self._bg_tasks_lock: + self._bg_tasks.add(fut) + + def _done(f: "concurrent.futures.Future") -> None: + with self._bg_tasks_lock: + self._bg_tasks.discard(f) + if f.cancelled(): + return + exc = f.exception() + if exc is not None: + logger.exception( + "FeishuChannel.schedule: background task raised: %s", exc, + exc_info=exc, + ) + + fut.add_done_callback(_done) + return fut + + def _call_safety_threadsafe(self, method_name: str, *args) -> None: + self._ensure_bg_loop() + loop = self._bg_loop + if loop is None or getattr(loop, "is_closed", lambda: False)(): + raise RuntimeError("FeishuChannel: background loop is not running") + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + safety = self._safety + if safety is None: + if running_loop is loop: + raise RuntimeError("FeishuChannel: safety pipeline is not running") + for _ in range(50): + time.sleep(0.01) + safety = self._safety + if safety is not None: + break + if safety is None: + raise RuntimeError("FeishuChannel: safety pipeline is not running") + fn = getattr(safety, method_name) + try: + if running_loop is loop: + fn(*args) + elif loop.is_running(): + loop.call_soon_threadsafe(fn, *args) + else: + fn(*args) + except RuntimeError as e: + raise RuntimeError("FeishuChannel: background loop is not running") from e + + def block_batch_scope(self, scope: str) -> None: + self._call_safety_threadsafe("block_batch_scope", scope) + + def unblock_batch_scope(self, scope: str) -> None: + self._call_safety_threadsafe("unblock_batch_scope", scope) + + def cancel_batch_scope(self, scope: str) -> None: + self._call_safety_threadsafe("cancel_batch_scope", scope) + + def _start_keepalive_watchdog(self) -> None: + keepalive_cfg = self._config.transport.keepalive + ws = self._ws_client + if not keepalive_cfg.enabled or ws is None: + return + if self._keepalive_future is not None and not self._keepalive_future.done(): + return + self._keepalive_watchdog = KeepaliveWatchdog( + config=keepalive_cfg, + probe=lambda: ws.probe_endpoint(timeout=keepalive_cfg.probe_timeout_seconds), + reconnect=lambda: ws.request_reconnect(), + ) + self._keepalive_future = self.schedule(self._keepalive_loop()) + + def _stop_keepalive_watchdog(self) -> None: + future = self._keepalive_future + self._keepalive_watchdog = None + self._keepalive_future = None + if future is None: + return + try: + future.cancel() + except Exception: # pragma: no cover + pass + + async def _keepalive_loop(self) -> None: + while not self._shutdown.is_set(): + cfg = self._config.transport.keepalive + await asyncio.sleep(cfg.check_interval_seconds) + watchdog = self._keepalive_watchdog + if watchdog is not None: + await watchdog.run_once() + + # Backoff schedule used by :meth:`_start_bot_identity_retry_loop`. + # Covers roughly 1h 15m of retries: 10s, 30s, 2m, 10m, 1h. After that we + # give up and log once at ERROR; a caller can always invoke + # :meth:`resolve_bot_identity` manually to try again. + _BOT_IDENTITY_RETRY_DELAYS_S = (10, 30, 120, 600, 3600) + + def _store_bot_identity(self, identity: Optional[BotIdentity]) -> None: + """Atomically swap in a fresh bot identity + propagate to safety.""" + with self._bot_identity_lock: + self._bot_identity = identity + self._bot_open_id = identity.open_id if identity is not None else None + self._pipeline.set_bot_open_id(self._bot_open_id) + safety = self._safety + if safety is not None and identity is not None: + safety.set_bot_open_id(identity.open_id) + + async def resolve_bot_identity(self) -> Optional[BotIdentity]: + """Fetch the bot identity + publish it to readers under a lock. + + Safe to call from any loop. Returns the resolved identity (or None if + the upstream call failed — callers that need the identity should + react to None by retrying later). + """ + identity = await fetch_bot_identity(self._client.config) + if identity is not None: + self._store_bot_identity(identity) + return identity + + def _fetch_bot_identity_sync(self) -> None: + if self._bg_loop is None: + raise RuntimeError("FeishuChannel: background loop is not running") + fut = asyncio.run_coroutine_threadsafe( + fetch_bot_identity(self._client.config), self._bg_loop + ) + try: + identity = fut.result(timeout=10) + except Exception as e: + logger.warning("FeishuChannel: bot identity fetch failed: %s", e) + identity = None + if identity is None: + # A transient network hiccup at startup can leave group ``@Bot`` + # detection unavailable until identity resolves. Schedule a + # background backoff retry loop instead of treating startup lookup + # as the only chance to populate ``_bot_open_id``. + logger.warning( + "FeishuChannel: bot identity unresolved on startup — " + "scheduling background retry (group @Bot detection will " + "remain off until resolved)" + ) + self._start_bot_identity_retry_loop() + return + self._store_bot_identity(identity) + logger.info( + "FeishuChannel: bot identity resolved — open_id=%s name=%s", + identity.open_id, identity.name, + ) + + def _start_bot_identity_retry_loop(self) -> None: + """Schedule a backoff retry task on the bg loop.""" + future = self._bot_identity_retry_future + if future is not None and not future.done(): + return + if self._bg_loop is None: + return + coro = self._bot_identity_retry_loop() + try: + self._bot_identity_retry_future = self.schedule(coro) + except RuntimeError: # pragma: no cover - loop already stopped + coro.close() + pass + + def _cancel_bg_tasks(self) -> None: + with self._bg_tasks_lock: + pending = list(self._bg_tasks) + retry_future = self._bot_identity_retry_future + if retry_future is not None and retry_future not in pending: + pending.append(retry_future) + for fut in pending: + try: + fut.cancel() + except Exception: # pragma: no cover + pass + self._drain_cancelled_bg_tasks() + + def _stop_bg_loop(self, *, join_timeout: float) -> None: + loop = self._bg_loop + thread = self._bg_thread + if loop is not None: + try: + loop.call_soon_threadsafe(loop.stop) + except RuntimeError: # pragma: no cover - already stopped + pass + if ( + thread is not None + and thread.is_alive() + and thread is not threading.current_thread() + ): + thread.join(timeout=join_timeout) + if thread.is_alive(): # pragma: no cover + logger.warning( + "FeishuChannel.stop: bg thread did not exit within %.1fs", + join_timeout, + ) + self._bg_loop = None + self._bg_thread = None + + async def _bot_identity_retry_loop(self) -> None: + """Retry ``fetch_bot_identity`` on a backoff schedule until it + succeeds, shutdown fires, or we exhaust the delay table.""" + for delay_s in self._BOT_IDENTITY_RETRY_DELAYS_S: + try: + await asyncio.sleep(delay_s) + except asyncio.CancelledError: + raise + if self._shutdown.is_set(): + return + # Someone else (manual resolve_bot_identity call) may have + # already filled it in — bail out without a pointless fetch. + with self._bot_identity_lock: + if self._bot_identity is not None: + return + try: + identity = await fetch_bot_identity(self._client.config) + except Exception as e: + logger.warning( + "FeishuChannel: bot identity retry failed (delay=%ds): %s", + delay_s, e, + ) + continue + if identity is not None: + self._store_bot_identity(identity) + logger.info( + "FeishuChannel: bot identity resolved on retry — open_id=%s", + identity.open_id, + ) + return + logger.error( + "FeishuChannel: bot identity still unresolved after all retries — " + "group @Bot detection is disabled for this process; call " + "channel.resolve_bot_identity() manually to try again" + ) + + def _drain_cancelled_bg_tasks(self) -> None: + loop = self._bg_loop + if loop is None or not loop.is_running(): + return + try: + drain_fut = asyncio.run_coroutine_threadsafe(asyncio.sleep(0), loop) + drain_fut.result(timeout=0.5) + except Exception: # pragma: no cover + pass + + def _ensure_bg_loop(self) -> None: + # Double-checked locking: the outer check avoids the lock in the hot + # path, the inner check prevents a race between two concurrent + # callers both seeing None and each spawning their own loop+thread. + if self._bg_loop is not None: + return + with self._bg_lock: + if self._bg_loop is not None: + return + if self._shutdown.is_set(): + raise RuntimeError( + "FeishuChannel._ensure_bg_loop: channel is shutting down" + ) + loop = asyncio.new_event_loop() + + def _runner() -> None: + asyncio.set_event_loop(loop) + try: + loop.run_forever() + finally: + try: + loop.close() + except Exception: # pragma: no cover + pass + + t = threading.Thread(target=_runner, name="lark-channel-bg", daemon=True) + t.start() + self._bg_loop = loop + self._bg_thread = t + + async def _build_safety(): + safety_cfg = self._config.safety + return SafetyPipeline( + loop=self._bg_loop, + on_message=self._dispatch_inbound_to_user, + on_reject=self._emit_reject, + policy=self._config.policy, + # Safety-layer dedup cache (ICache, usually Redis) — wired + # explicitly from the constructor kwarg, independent of the + # pipeline-layer DedupStore that feeds `self._deduper`. + cache=self._safety_cache, + dedup_config=safety_cfg.dedup, + batch_config=safety_cfg.text_batch, + media_batch_config=safety_cfg.media_batch, + queue_config=safety_cfg.chat_queue, + stale_window_ms=safety_cfg.stale_message_window_ms, + drop_self_sent=self._config.inbound.drop_self_sent, + ) + + fut = asyncio.run_coroutine_threadsafe(_build_safety(), self._bg_loop) + self._safety = fut.result(timeout=5) + with self._bot_identity_lock: + open_id = self._bot_open_id + if open_id: + self._safety.set_bot_open_id(open_id) + + # ------------------------------------------------------------------ + # Dispatcher + # ------------------------------------------------------------------ + def _build_dispatcher(self) -> EventDispatcherHandler: + b = EventDispatcherHandler.builder( + self._config.encrypt_key or "", + self._config.verification_token or "", + self._config.log_level, + security=self._config.security, + ) + b = b.register_p2_im_message_receive_v1(self._on_p2_im_message_receive_v1) + b = b.register_p2_card_action_trigger(self._on_p2_card_action_trigger) + for register, handler in ( + ("register_p2_im_message_reaction_created_v1", self._on_p2_reaction_created), + ("register_p2_im_message_reaction_deleted_v1", self._on_p2_reaction_deleted), + ("register_p2_im_chat_member_bot_added_v1", self._on_p2_bot_added), + ("register_p2_im_chat_member_bot_deleted_v1", self._on_p2_bot_deleted), + ("register_p2_im_message_message_read_v1", self._on_p2_message_read), + ): + try: + b = getattr(b, register)(handler) + except Exception: # pragma: no cover + pass + # drive.notice.comment_add_v1 has no typed processor in the + # generated SDK. The wire payload may arrive under either schema: + # the legacy callback channel uses p1 (event has ``uuid``), but + # the modern WS frontier wraps the same event in a p2 envelope + # (``schema=2.0``). Register the customized-event handler under + # both so neither path logs "processor not found". + b = b.register_p1_customized_event( + "drive.notice.comment_add_v1", self._on_p1_comment_add + ) + b = b.register_p2_customized_event( + "drive.notice.comment_add_v1", self._on_p1_comment_add + ) + return b.build() + + # ------------------------------------------------------------------ + # Raw sync entry points — schedule async work on the bg loop + # ------------------------------------------------------------------ + def _on_p2_im_message_receive_v1(self, data: Any) -> None: + self.schedule(self._handle_message_event(data)) + + def _on_p2_card_action_trigger( + self, data: P2CardActionTrigger + ) -> P2CardActionTriggerResponse: + try: + if "cardAction" in self._handlers or self._config.inbound.emit_raw_events: + self.schedule(self._handle_interaction_event(data)) + except Exception as e: + logger.exception("cardAction schedule failed: %s", e) + return P2CardActionTriggerResponse({}) + + def _on_p2_reaction_created(self, data: Any) -> None: + self.schedule(self._handle_reaction_event(data, action="create")) + + def _on_p2_reaction_deleted(self, data: Any) -> None: + self.schedule(self._handle_reaction_event(data, action="delete")) + + def _on_p2_bot_added(self, data: Any) -> None: + self.schedule(self._handle_bot_event(data, joined=True)) + + def _on_p2_bot_deleted(self, data: Any) -> None: + self.schedule(self._handle_bot_event(data, joined=False)) + + def _on_p2_message_read(self, data: Any) -> None: + self.schedule(self._handle_message_read_event(data)) + + def _on_p1_comment_add(self, data: Any) -> None: + self.schedule(self._handle_comment_event(data)) + + # ------------------------------------------------------------------ + # Async event handlers + # ------------------------------------------------------------------ + async def _emit_raw_event(self, data: Any) -> None: + if not self._config.inbound.emit_raw_events: + return + await self._invoke("raw", _coerce.obj_to_dict(data) or {}) + + async def _handle_message_event(self, data: Any) -> None: + try: + await self._emit_raw_event(data) + event = getattr(data, "event", None) + header = getattr(data, "header", None) + event_id = getattr(header, "event_id", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + if message is None: + return + inbound = await self._pipeline.process( + event_id=event_id, + message_event=message, + sender=sender, + ) + if inbound is None: + return + if self._safety is not None: + await self._safety.push_message(inbound) + else: + await self._dispatch_inbound_to_user(inbound) + except Exception as e: + logger.exception("FeishuChannel.handle_message_event failed: %s", e) + + async def _dispatch_inbound_to_user(self, inbound) -> None: + await self._invoke("message", inbound) + + def _emit_reject(self, event: RejectEvent) -> None: + handlers = self._handlers.get("reject") + if not handlers: + logger.debug( + "policy reject message=%s reason=%s", event.message_id, event.reason + ) + return + for handler in list(handlers): + try: + result = handler(event) + if inspect.isawaitable(result): + self.schedule(result) + except Exception as e: # pragma: no cover + logger.warning("FeishuChannel.on('reject') raised: %s", e) + + async def _handle_interaction_event(self, data: P2CardActionTrigger) -> None: + try: + await self._emit_raw_event(data) + event = getattr(data, "event", None) + action = getattr(event, "action", None) + raw_value = getattr(action, "value", None) + if isinstance(raw_value, str): + try: + parsed = json.loads(raw_value) + raw_value = parsed if isinstance(parsed, dict) else {"value": parsed} + except ValueError: + raw_value = {"value": raw_value} + tag = getattr(action, "tag", None) + context = getattr(event, "context", None) + message_id = getattr(context, "open_message_id", None) + chat_id = getattr(context, "open_chat_id", None) + operator_open_id = getattr( + getattr(event, "operator", None), "open_id", None + ) + payload = CardActionEvent( + message_id=message_id or "", + chat_id=chat_id or "", + operator=EventOperator(open_id=operator_open_id or ""), + action=CardActionPayload( + tag=tag or "", + value=raw_value, + name=getattr(raw_value, "name", None) + if hasattr(raw_value, "name") + else None, + form_value=getattr(action, "form_value", None), + input_value=getattr(action, "input_value", None), + options=getattr(action, "options", None), + checked=getattr(action, "checked", None), + ), + raw=_coerce.obj_to_dict(data) or {}, + ) + # Route through safety.push_action (tier 2): dedup on a stable + # action identity (tag + value payload) so Feishu's at-least-once + # WS redelivery can't double-invoke the handler, and serialize by + # chat_id so two fast clicks in the same chat are processed in + # order. + await self._through_action_safety( + event_id=f"card:{payload.message_id}:{payload.operator.open_id}:" + f"{_card_action_identity(payload.action)}", + queue_scope=payload.chat_id or payload.message_id or "", + handler=lambda: self._invoke("cardAction", payload), + ) + except Exception as e: + logger.exception("FeishuChannel cardAction dispatch failed: %s", e) + + async def _through_action_safety( + self, + *, + event_id: str, + queue_scope: str, + handler: Callable[[], Any], + ) -> None: + """Run ``handler`` through the safety tier-2 gate (dedup + lock + + per-scope serial queue) when the pipeline exists; fall back to a + direct invocation when it hasn't been built yet (early events during + startup, unit tests that bypass ``connect``).""" + safety = self._safety + if safety is None: + result = handler() + if inspect.isawaitable(result): + await result + return + + async def _run() -> None: + result = handler() + if inspect.isawaitable(result): + await result + + await safety.push_action(event_id, queue_scope or event_id, _run) + + async def _through_light_safety( + self, + *, + event_id: str, + handler: Callable[[], Any], + ) -> None: + """Tier-3 variant: dedup only (reaction add/remove). Same fallback + semantics as :meth:`_through_action_safety`.""" + safety = self._safety + if safety is None: + result = handler() + if inspect.isawaitable(result): + await result + return + + async def _run() -> None: + result = handler() + if inspect.isawaitable(result): + await result + + await safety.push_light(event_id, _run) + + async def _handle_reaction_event(self, data: Any, *, action: str) -> None: + await self._emit_raw_event(data) + cfg = self._config.inbound.reaction_notifications + if cfg == "off": + return + try: + event = getattr(data, "event", None) or {} + user = getattr(event, "user_id", None) + message_id = getattr(event, "message_id", None) or "" + operator_open_id = ( + getattr(user, "open_id", None) if user is not None else None + ) + emoji_type = getattr( + getattr(event, "reaction_type", None), "emoji_type", None + ) + action_time = getattr(event, "action_time", None) + raw_dict = _coerce.obj_to_dict(data) or {} + raw_event = raw_dict.get("event") if isinstance(raw_dict, dict) else {} + if not isinstance(raw_event, dict): + raw_event = {} + chat_id = getattr(event, "chat_id", None) or raw_event.get("chat_id") + chat_type = getattr(event, "chat_type", None) or raw_event.get("chat_type") + context = self._sent_message_context.get(message_id) + if context is not None: + if not chat_id: + chat_id = context.chat_id + if not chat_type: + chat_type = context.chat_type + + if cfg == "own": + if message_id and message_id not in self._sent_messages: + return + + direction = "added" if action == "create" else "removed" + payload = ReactionEvent( + message_id=message_id, + operator=EventOperator(open_id=operator_open_id or ""), + emoji_type=emoji_type or "", + action=direction, + chat_id=chat_id or None, + chat_type=chat_type or None, + action_time=action_time, + raw=raw_dict, + ) + # Tier 3: dedup only. Reactions are idempotent state changes so + # lock / serial queue would add latency for no benefit, but + # WS redelivery would double-invoke without this guard. + # The light safety tier keeps that dedup-only behavior explicit. + await self._through_light_safety( + event_id=( + f"reaction:{message_id}:{operator_open_id or ''}:" + f"{emoji_type or ''}:{direction}" + ), + handler=lambda: self._invoke("reaction", payload), + ) + except Exception as e: + logger.exception("FeishuChannel reaction dispatch failed: %s", e) + + async def _handle_bot_event(self, data: Any, *, joined: bool) -> None: + try: + await self._emit_raw_event(data) + event = getattr(data, "event", None) or {} + chat_id = getattr(event, "chat_id", None) or "" + operator = getattr(event, "operator_id", None) + open_id = getattr(operator, "open_id", None) if operator else None + raw_dict = _coerce.obj_to_dict(data) + payload_cls = BotAddedEvent if joined else BotLeaveEvent + name = "botAdded" if joined else "botLeave" + await self._invoke( + name, + payload_cls( + chat_id=chat_id, + operator=EventOperator(open_id=open_id or ""), + raw=raw_dict or {}, + ), + ) + except Exception as e: + logger.exception( + "FeishuChannel bot-%s dispatch failed: %s", + "added" if joined else "leave", e, + ) + + async def _handle_message_read_event(self, data: Any) -> None: + try: + await self._emit_raw_event(data) + event = getattr(data, "event", None) or {} + reader = getattr(event, "reader", None) + reader_open_id = getattr(reader, "reader_id", None) and getattr( + reader.reader_id, "open_id", None + ) + message_ids = list(getattr(event, "message_id_list", []) or []) + await self._invoke( + "messageRead", + MessageReadEvent( + reader=EventOperator(open_id=reader_open_id or ""), + message_ids=message_ids, + raw=_coerce.obj_to_dict(data) or {}, + ), + ) + except Exception as e: + logger.exception("FeishuChannel messageRead dispatch failed: %s", e) + + async def _handle_comment_event(self, data: Any) -> None: + try: + await self._emit_raw_event(data) + # ``CustomizedEvent.event`` is the raw inner event payload as a + # plain dict; the per-event timestamp lives on the envelope + # (``header.create_time`` for p2, ``ts`` for p1) — not in the + # inner dict — so pass it explicitly. + raw_event = getattr(data, "event", None) + header = getattr(data, "header", None) + envelope_ts = ( + getattr(header, "create_time", None) if header is not None + else getattr(data, "ts", None) + ) + normalized = normalize_comment( + raw_event if raw_event is not None else data, + bot_open_id=self._bot_open_id, + envelope_timestamp=envelope_ts, + ) + if normalized is None: + return + # Tier 2: dedup + lock + per-file_token serial queue. Multiple + # comments on the same document are ordered; redeliveries of the + # same comment event are dropped. + await self._through_action_safety( + event_id=f"comment:{normalized.file_token}:{normalized.comment_id}", + queue_scope=normalized.file_token, + handler=lambda: self._invoke("comment", normalized), + ) + except Exception as e: + logger.exception("FeishuChannel comment dispatch failed: %s", e) + + def _notify_reconnecting(self) -> None: + self._connection_state = "reconnecting" + self._connection_reconnect_attempts += 1 + for h in list(self._handlers.get("reconnecting", [])): + try: + h() + except Exception as e: # pragma: no cover + logger.warning("reconnecting handler raised: %s", e) + + def _notify_reconnected(self) -> None: + self._connection_state = "connected" + self._connection_last_connected_at = time.time() + for h in list(self._handlers.get("reconnected", [])): + try: + h() + except Exception as e: # pragma: no cover + logger.warning("reconnected handler raised: %s", e) + + def _record_connection_error(self, error: BaseException) -> None: + self._connection_state = "error" + self._connection_last_error_at = time.time() + self._connection_last_error = str(error) + + def connection_snapshot(self) -> ConnectionSnapshot: + return ConnectionSnapshot( + state=self._connection_state, + ready=self._ready_flag, + reconnect_attempts=self._connection_reconnect_attempts, + last_connected_at=self._connection_last_connected_at, + last_disconnected_at=self._connection_last_disconnected_at, + last_error_at=self._connection_last_error_at, + last_error=self._connection_last_error, + ) + + # ------------------------------------------------------------------ + # Outbound: send / stream / message ops + # ------------------------------------------------------------------ + async def upload_media( + self, + source: MediaSource, + *, + kind: Literal["image", "file"], + file_name: Optional[str] = None, + file_type: Optional[str] = None, + ) -> str: + """Upload a media resource and return its Feishu ``image_key`` / + ``file_key`` without sending a message. + + Public wrapper over the internal :func:`resolve_media_key` helper — + intended for callers that need to construct custom post AST (e.g. + ``audio + caption`` / ``file + caption``, which Feishu does not render + natively but accepts as ``msg_type=post`` with ``tag:audio|file`` + nodes), or want to pre-upload + cache a key for cross-chat reuse. + + ``source`` must be a :class:`MediaSource`; pass an explicit kind: + + - ``MediaSource(kind="buffer", buffer=...)`` — in-memory bytes + - ``MediaSource(kind="file", path=...)`` — local file + - ``MediaSource(kind="url", url=...)`` — remote URL (SSRF-checked + against ``OutboundConfig.ssrf_allowlist``) + - ``MediaSource(kind="key", key=...)`` — pre-uploaded key (returned + unchanged; no upload performed) + + ``kind`` selects the upload route: ``"image"`` → image upload (returns + ``image_key``), ``"file"`` → file upload (returns ``file_key``). For + audio / video, use ``kind="file"`` with ``file_type="opus"`` / + ``"mp4"`` — Feishu treats them as typed file uploads at the wire + level. ``file_name`` is shown in Feishu UI for ``kind="file"``. + + Failures raise :class:`FeishuChannelError`: + + - ``UPLOAD_FAILED`` — server rejection, network error, missing local + file, malformed response, or empty ``MediaSource(kind="key", key="")`` + - ``SSRF_BLOCKED`` — URL source without an allowlist match + + SSRF allowlist is taken from ``self.config.outbound.ssrf_allowlist`` + — callers do **not** mutate the source object. + """ + if not isinstance(source, MediaSource): + raise TypeError( + f"upload_media: source must be a MediaSource; " + f"got {type(source).__name__}" + ) + if kind not in ("image", "file"): + raise ValueError( + f"upload_media: kind must be 'image' or 'file'; got {kind!r}" + ) + + from .outbound.media.uploader import resolve_media_key + + key = await resolve_media_key( + self._sender._driver, + source, + kind, + file_name=file_name, + file_type=file_type, + ssrf_allowlist=self._config.outbound.ssrf_allowlist, + ) + if key is None: + # ``resolve_media_key`` returns None only for "nothing to upload" + # inputs (kind="key" with empty key, or unhandled source shape). + # The public method commits to ``str`` — convert to UPLOAD_FAILED + # so callers don't need to handle Optional. + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"upload_media: source has no uploadable content " + f"(source.kind={source.kind!r})", + context={"source_kind": source.kind}, + ) + return key + + async def send(self, to, message, opts=None) -> SendResult: + """Send a message to a chat / user / email. + + ``message`` may be a dict (``{"text": "..."}``, ``{"markdown": "..."}``, + ``{"image": {...}}``, …), a typed :class:`OutboundMessage` dataclass, + or a bare string (shorthand for markdown). See :mod:`._coerce` for the + full accepted shape. + + Errors: both raised exceptions (coercion errors, transport failures) + AND ``SendResult.fail(...)`` outcomes are also forwarded to any + handler registered via ``channel.on("error", ...)``, so apps can + centralise failure observation. The error is still returned / + raised to the immediate caller as well — forwarding does not swallow. + """ + try: + outbound = _coerce.coerce_outbound(message) + send_opts = _coerce.coerce_send_opts(opts) + rit = send_opts.receive_id_type or infer_receive_id_type(to) + result = await self._sender.send( + outbound, + receive_id=to, + receive_id_type=rit, + reply_to=send_opts.reply_to, + reply_in_thread=send_opts.reply_in_thread, + reply_target_gone=send_opts.reply_target_gone, + uuid_=send_opts.uuid, + ) + except Exception as e: + await self._forward_outbound_error(e) + raise + if not result.success and result.error is not None: + await self._forward_outbound_error(result.error) + if result.success: + self._remember_sent_message_context( + result, + chat_id=to if rit == "chat_id" and isinstance(to, str) else None, + receive_id_type=rit, + ) + return result + + async def _forward_outbound_error(self, err: Any) -> None: + """Fan-out a send/stream failure to any ``on("error", ...)`` handlers. + + :class:`SendError` is a dataclass without ``__traceback__`` or a + ``str``-friendly form, so generic diagnostic plumbing + (``logger.exception``, Sentry) chokes on it. Wrap it in + :class:`OutboundSendError` before dispatching. The wrapping does not + affect the value returned by ``channel.send()`` (still + :class:`SendResult`). + + Never swallows — the error still propagates to the direct caller. + Handler exceptions are logged but otherwise ignored. + """ + if isinstance(err, SendError): + err = OutboundSendError(err) + for h in list(self._handlers.get("error", [])): + try: + result = h(err) + if inspect.isawaitable(result): + await result + except Exception as inner: # pragma: no cover - defensive + logger.warning("on('error') handler raised: %s", inner) + + async def stream(self, to, spec: Dict[str, Any], opts=None) -> SendResult: + """Stream a message progressively. + + ``spec`` is either ``{"markdown": producer}`` or + ``{"card": {"initial": ..., "producer": ...}}``. + + Errors (coercion + stream controller raises) are also forwarded to + any ``on("error", ...)`` handlers before being re-raised to the + caller, same contract as :meth:`send`. + """ + try: + if not isinstance(spec, dict): + raise TypeError("stream spec must be a dict") + send_opts = _coerce.coerce_send_opts(opts) + rit = send_opts.receive_id_type or infer_receive_id_type(to) + except Exception as e: + await self._forward_outbound_error(e) + raise + + if "markdown" in spec: + # Markdown streaming uses the CardKit preallocation flow — every + # throttle tick is an `update_card_element_content` call (seq-ordered + # element patch) instead of a full-card PATCH. See + # :mod:`.outbound.streaming.markdown_stream` for the protocol. + ctl = MarkdownStreamController( + to=to, + receive_id_type=rit, + reply_to=send_opts.reply_to, + reply_in_thread=send_opts.reply_in_thread, + reply_target_gone=send_opts.reply_target_gone, + create_card_instance=self.create_card_instance, + send_card_by_reference=self.send_card_by_reference, + update_card_element_content=self.update_card_element_content, + finish_streaming_card=self.finish_streaming_card, + ) + try: + mid = await ctl.run(spec["markdown"]) + except Exception as e: + await self._forward_outbound_error(e) + raise + return SendResult.ok(message_id=mid) + + if "card" in spec: + card_def = spec["card"] + initial = card_def.get("initial") if isinstance(card_def, dict) else None + producer = card_def.get("producer") if isinstance(card_def, dict) else None + if initial is None or not callable(producer): + err = TypeError("card stream requires {initial, producer}") + await self._forward_outbound_error(err) + raise err + ctl = CardStreamController( + initial=initial, + ensure_created=lambda snap: self._ensure_card_snapshot( + to, rit, snapshot=snap, + reply_to=send_opts.reply_to, + reply_in_thread=send_opts.reply_in_thread, + reply_target_gone=send_opts.reply_target_gone, + ), + patch_card=self._patch_card, + ) + try: + mid = await ctl.run(producer) + except Exception as e: + await self._forward_outbound_error(e) + raise + return SendResult.ok(message_id=mid) + + err = TypeError("stream spec must contain 'markdown' or 'card'") + await self._forward_outbound_error(err) + raise err + + async def update_card(self, message_id: str, card: Dict[str, Any]) -> SendResult: + """Update a card message. Returns a :class:`SendResult`.""" + raw = await self._patch_card(message_id, card) + return _coerce.result_from_raw(raw, message_id=message_id) + + async def recall_message(self, message_id: str) -> SendResult: + raw = await self._driver.delete_message(message_id=message_id) + return _coerce.result_from_raw(raw, message_id=message_id) + + async def add_reaction(self, message_id: str, emoji_type: str) -> SendResult: + raw = await self._driver.add_reaction( + message_id=message_id, emoji_type=emoji_type + ) + return _coerce.result_from_raw(raw, message_id=message_id) + + async def remove_reaction(self, message_id: str, reaction_id: str) -> SendResult: + raw = await self._driver.remove_reaction( + message_id=message_id, reaction_id=reaction_id + ) + return _coerce.result_from_raw(raw, message_id=message_id) + + async def list_reactions( + self, + message_id: str, + emoji_type: Optional[str] = None, + *, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + ) -> Dict[str, Any]: + return await self._driver.list_reactions( + message_id=message_id, + emoji_type=emoji_type, + page_token=page_token, + page_size=page_size, + ) + + async def remove_reaction_by_emoji( + self, + message_id: str, + emoji_type: str, + ) -> SendResult: + raw = await self._driver.list_reactions( + message_id=message_id, + emoji_type=emoji_type, + page_token=None, + page_size=None, + ) + data = (raw or {}).get("data") if isinstance(raw, dict) else None + items = data.get("items") if isinstance(data, dict) else None + for item in items if isinstance(items, list) else []: + reaction = item.get("reaction_type") if isinstance(item, dict) else None + if not isinstance(reaction, dict) or reaction.get("emoji_type") != emoji_type: + continue + reaction_id = item.get("reaction_id") + if reaction_id: + return await self.remove_reaction(message_id, reaction_id) + return SendResult.fail( + SendError( + code=FeishuChannelErrorCode.UNKNOWN, + retryable=False, + hint=f"reaction not found for emoji_type={emoji_type}", + ), + raw=raw if isinstance(raw, dict) else None, + ) + + async def add_typing_reaction(self, message_id: str) -> Optional[str]: + try: + result = await self.add_reaction(message_id, "Typing") + raw = getattr(result, "raw", None) + if raw is None and isinstance(result, dict): + raw = result + data = (raw or {}).get("data") or {} + return data.get("reaction_id") + except Exception: + return None + + async def remove_typing_reaction(self, message_id: str, reaction_id: str) -> bool: + try: + result = await self.remove_reaction(message_id, reaction_id) + return bool(getattr(result, "success", False)) + except Exception: + return False + + def _comment_client(self) -> CommentPrimitiveClient: + return CommentPrimitiveClient(raw_request=lambda req: self._client.arequest(req)) + + async def resolve_comment_target( + self, + *, + file_token: str, + file_type: str, + ) -> CommentTarget: + return await self._comment_client().resolve_comment_target( + file_token=file_token, + file_type=file_type, + ) + + async def get_comment_context( + self, + *, + target: CommentTarget, + comment_id: str, + event_reply_id: Optional[str] = None, + ) -> CommentContext: + return await self._comment_client().get_comment_context( + target=target, + comment_id=comment_id, + event_reply_id=event_reply_id, + ) + + async def fetch_comment( + self, + *, + target: CommentTarget, + comment_id: str, + ): + return await self._comment_client().fetch_comment( + target=target, + comment_id=comment_id, + ) + + async def list_comments( + self, + *, + target: CommentTarget, + page_token=None, + page_size=None, + is_whole=None, + is_solved=None, + ): + return await self._comment_client().list_comments( + target=target, + page_token=page_token, + page_size=page_size, + is_whole=is_whole, + is_solved=is_solved, + ) + + async def list_comment_replies( + self, + *, + target: CommentTarget, + comment_id: str, + page_token=None, + page_size=None, + ): + return await self._comment_client().list_comment_replies( + target=target, + comment_id=comment_id, + page_token=page_token, + page_size=page_size, + ) + + async def reply_comment(self, context: CommentContext, content: str): + return await self._comment_client().reply_comment(context, content) + + async def edit_message(self, message_id: str, message) -> SendResult: + """Edit a previously sent text or post message. + + Accepted message shapes mirror send() for editable types: str, + {"markdown": ...}, {"text": ...}, {"post": ...}, OutboundText, + and OutboundPost. Cards must use update_card(); media/share/sticker + messages are not editable through this method. + """ + outbound = _coerce.coerce_outbound(message) + if isinstance(outbound, OutboundCard): + raise TypeError("edit_message does not support cards; use update_card()") + if not isinstance(outbound, (OutboundText, OutboundPost)): + raise TypeError( + "edit_message only supports text/post messages; " + f"got {type(outbound).__name__}" + ) + body = await self._sender.materialize_for_edit(outbound) + raw = await self._driver.update_message( + message_id=message_id, + msg_type=body["msg_type"], + content=body["content"], + ) + return _coerce.result_from_raw(raw, message_id=message_id) + + async def download_resource( + self, + file_key: str, + resource_type: str = "image", + message_id: Optional[str] = None, + ) -> Optional[bytes]: + return await self._download_media( + message_id=message_id or "", + file_key=file_key, + resource_type=resource_type, + ) + + async def _download_media( + self, *, message_id: str, file_key: str, resource_type: str + ) -> Optional[bytes]: + return await _api_helpers.download_media( + self._client, + message_id=message_id, + file_key=file_key, + resource_type=resource_type, + ) + + async def download_resource_to_file( + self, + file_key: str, + *, + resource_type: str = "image", + message_id: Optional[str] = None, + dest_dir: "Path", + file_name: Optional[str] = None, + ) -> "Path": + """Download a resource to disk and return the absolute path. + + Args: + file_key: opaque resource identifier from the inbound event. + resource_type: ``image`` / ``file`` / ``audio`` / ``video``. + message_id: when supplied, downloads via the message-resource + endpoint; without it, falls back to the standalone + image/file endpoints. + dest_dir: target directory (auto-created if missing). + file_name: override the auto-generated filename. When None, the + file is named ```` where ```` is + inferred from the response's content-type / file_name. + + Returns: + Absolute path to the downloaded file. + + Raises: + FeishuChannelError(DOWNLOAD_FAILED): when the download fails or + the response has no body. + """ + from pathlib import Path + import os + import tempfile + + body, meta = await _api_helpers.download_media_with_meta( + self._client, + message_id=message_id or "", + file_key=file_key, + resource_type=resource_type, + ) + if body is None: + raise FeishuChannelError( + FeishuChannelErrorCode.DOWNLOAD_FAILED, + f"download failed: file_key={file_key} resource_type={resource_type}", + ) + + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + if file_name: + name = file_name + else: + suffix = self._infer_suffix(meta, resource_type) + name = f"{file_key}{suffix}" + name = self._safe_download_file_name(name) + out = dest_dir / name + + # Atomic: write to tmp file in same dir, then rename. + fd, tmp_path = tempfile.mkstemp(prefix=".dl-", dir=str(dest_dir)) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(body) + os.replace(tmp_path, out) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return out + + async def resolve_resource_to_cache( + self, + *, + message_id: str, + resource: ResourceDescriptor, + ) -> CachedResource: + return await self._media_cache.resolve( + message_id=message_id, + resource=resource, + downloader=lambda **kwargs: _api_helpers.download_media_with_meta( + self._client, + **kwargs, + ), + ) + + async def resolve_resources_to_cache( + self, *, message_id: str, resources: List[ResourceDescriptor] + ) -> List[CachedResource]: + return [ + await self.resolve_resource_to_cache(message_id=message_id, resource=resource) + for resource in resources + ] + + @staticmethod + def _safe_download_file_name(name: str) -> str: + """Validate a download filename before joining it under dest_dir.""" + from pathlib import PureWindowsPath + import os + + name = (name or "").replace("\x00", "") + win = PureWindowsPath(name) + if ( + not name + or name in (".", "..") + or os.path.isabs(name) + or "/" in name + or "\\" in name + or win.drive + or win.root + or any(part in ("", ".", "..") for part in win.parts) + ): + raise FeishuChannelError( + FeishuChannelErrorCode.DOWNLOAD_FAILED, + f"unsafe download file name: {name!r}", + ) + return name + + @staticmethod + def _infer_suffix(meta: Optional[str], resource_type: str) -> str: + """Best-effort suffix derivation from content-type / filename. + + Falls back to ``.bin`` when nothing is parseable, then to type-default + suffixes for the four common ``resource_type`` values. + """ + import mimetypes + + if meta: + # If meta looks like a filename (has a dot in last segment), + # return its suffix directly. + if "/" not in meta and "." in meta: + ext = "." + meta.rsplit(".", 1)[-1] + if 1 < len(ext) <= 6: + return ext.lower() + # Otherwise treat as a MIME type + ext = mimetypes.guess_extension(meta.split(";", 1)[0].strip()) + if ext: + return ext + + return { + "image": ".jpg", + "audio": ".mp3", + "video": ".mp4", + "file": ".bin", + }.get(resource_type, ".bin") + + async def get_chat_info(self, chat_id: str) -> Optional[ChatInfo]: + """Fetch chat metadata. Returns None on API failure.""" + return await _api_helpers.fetch_chat_info(self._client, chat_id) + + async def get_chat_mode(self, chat_id: str) -> Optional[str]: + cached = self._chat_mode_cache.get(chat_id) + if cached is not None: + return cached + info = await self.get_chat_info(chat_id) + mode = info.chat_mode if info is not None else None + if mode: + self._chat_mode_cache.set(chat_id, mode) + return mode + return self._config.chat_mode_cache.fallback + + # ------------------------------------------------------------------ + # CardKit preallocation API + # ------------------------------------------------------------------ + async def create_card_instance(self, spec: Dict[str, Any]) -> str: + """Create a pre-allocated card via ``POST /open-apis/cardkit/v1/card``. + + Returns the ``card_id`` string. Use with :meth:`send_card_by_reference` + / :meth:`update_card_element_content` / :meth:`finish_streaming_card` + for the CardKit typewriter-streaming flow. + """ + raw = await self._driver.cardkit_create( + body={"type": "card_json", "data": json.dumps(spec, ensure_ascii=False)} + ) + if not isinstance(raw, dict) or raw.get("code", 0) != 0: + raise FeishuChannelError( + FeishuChannelErrorCode.UNKNOWN, + f"create_card_instance failed: {raw}", + ) + data = raw.get("data") or {} + card_id = data.get("card_id") + if not card_id: + raise FeishuChannelError( + FeishuChannelErrorCode.UNKNOWN, + f"create_card_instance response missing card_id: {raw}", + ) + return str(card_id) + + async def send_card_by_reference( + self, + to: str, + card_id: str, + *, + receive_id_type: Optional[str] = None, + reply_to: Optional[str] = None, + reply_in_thread: Optional[bool] = None, + reply_target_gone: str = "fresh", + ) -> SendResult: + """Send a message that references a pre-allocated card (see + :meth:`create_card_instance`).""" + rit = receive_id_type or infer_receive_id_type(to) + return await self._sender.send( + OutboundCard(card={"type": "card", "data": {"card_id": card_id}}), + receive_id=to, + receive_id_type=rit, + reply_to=reply_to, + reply_in_thread=reply_in_thread, + reply_target_gone=reply_target_gone, + ) + + async def update_card_element_content( + self, + card_id: str, + element_id: str, + content: str, + sequence: int, + ) -> None: + """Typewriter-update a card element. ``sequence`` must strictly increase.""" + raw = await self._driver.cardkit_update_element( + card_id=card_id, + element_id=element_id, + body={"content": content, "sequence": sequence}, + ) + if isinstance(raw, dict) and raw.get("code", 0) != 0: + raise FeishuChannelError( + FeishuChannelErrorCode.UNKNOWN, + f"update_card_element_content failed: {raw}", + ) + + async def finish_streaming_card(self, card_id: str, sequence: int) -> None: + """Close ``streaming_mode`` on a pre-allocated card.""" + settings = json.dumps( + {"config": {"streaming_mode": False}}, ensure_ascii=False + ) + raw = await self._driver.cardkit_update_settings( + card_id=card_id, + body={"settings": settings, "sequence": sequence}, + ) + if isinstance(raw, dict) and raw.get("code", 0) != 0: + raise FeishuChannelError( + FeishuChannelErrorCode.UNKNOWN, + f"finish_streaming_card failed: {raw}", + ) + + # ------------------------------------------------------------------ + # Card streaming internals + # ------------------------------------------------------------------ + async def _ensure_card( + self, + to, + rit, + *, + initial_text, + reply_to, + reply_in_thread, + reply_target_gone="fresh", + ) -> str: + """Compatibility wrapper for older internal card-stream call sites.""" + return await self._ensure_card_snapshot( + to, rit, + snapshot={ + "schema": "2.0", + "config": {"streaming_mode": True, "summary": {"content": ""}}, + "body": {"elements": [{"tag": "markdown", "content": initial_text or "..."}]}, + }, + reply_to=reply_to, + reply_in_thread=reply_in_thread, + reply_target_gone=reply_target_gone, + ) + + async def _ensure_card_snapshot( + self, + to, + rit, + *, + snapshot, + reply_to, + reply_in_thread, + reply_target_gone="fresh", + ) -> str: + result = await self._sender.send( + OutboundCard(card=snapshot), + receive_id=to, + receive_id_type=rit, + reply_to=reply_to, + reply_in_thread=reply_in_thread, + reply_target_gone=reply_target_gone, + ) + if not result.success or not result.message_id: + code = result.error.code if result.error else FeishuChannelErrorCode.UNKNOWN + raise FeishuChannelError( + code, f"failed to create streaming card: {result.error}" + ) + return result.message_id + + async def _patch_card(self, message_id: str, card: Dict[str, Any]) -> Dict[str, Any]: + raw = await self._driver.patch_message( + message_id=message_id, + content=json.dumps(card, ensure_ascii=False), + ) + code = (raw or {}).get("code", 0) + if code != 0: + logger.warning( + "channel.card_patch: patch failed code=%s msg=%s", + code, (raw or {}).get("msg"), + ) + return raw + + # ------------------------------------------------------------------ + # Fetch payload + # ------------------------------------------------------------------ + async def fetch_message(self, message_id: str) -> Dict[str, Any]: + """Fetch a message by ID and return the raw Feishu ``im.v1.message.get`` + response as a dict. + + This is a thin wrapper over the underlying OpenAPI call — no + normalization is performed. Use it for cases like "look up the text + of a message the user replied to" where you need ad-hoc access to + the wire payload. For the typed :class:`InboundMessage` shape, feed + events through the inbound pipeline normally. + """ + return await self._driver.fetch_message(message_id) + + async def fetch_inbound_message(self, message_id: str): + raw = await self._driver.fetch_message(message_id) + item = _extract_first_message_item(raw) + if item is None: + return None + sender = _extract_fetched_sender(item) + message = _normalize_fetched_message_item(item) + return await self._pipeline.normalize(message_event=message, sender=sender) + + async def fetch_quoted_context(self, message_id: str): + resolver = QuoteResolver( + fetcher=lambda mid: _api_helpers.fetch_message_raw( + self._client, + mid, + card_content_type="user_card_content", + ) + ) + return await resolver.fetch_quoted_context(message_id) + + async def resolve_quoted_contexts(self, messages, *, chat_mode=None): + resolver = QuoteResolver( + fetcher=lambda mid: _api_helpers.fetch_message_raw( + self._client, + mid, + card_content_type="user_card_content", + ) + ) + return await resolver.resolve_quoted_contexts(messages, chat_mode=chat_mode) + + async def select_quote_targets(self, messages, *, chat_mode=None): + return await self.resolve_quoted_contexts(messages, chat_mode=chat_mode) + + # Alias used by the internal pipeline (kept stable for dependency + # injection; external callers should prefer ``fetch_message``). + async def _fetch_message_payload(self, message_id: str) -> Dict[str, Any]: + return await self._driver.fetch_message(message_id) + + # ------------------------------------------------------------------ + # UAT (user access token) — exposed for callers that need it explicitly + # ------------------------------------------------------------------ + async def require_user_auth( + self, + user_open_id: str, + scopes: list, + *, + prompt_context: Any = None, + ) -> UAT: + """Resolve a user access token for ``user_open_id``, running the + device flow if needed. ``prompt_context`` must expose + ``respond(card)`` if the user needs a prompt card (usually the + original interaction carrier).""" + return await require_user_auth( + device_flow=self._device_flow, + token_store=self._token_store, + uat_config=self._config.uat, + user_open_id=user_open_id, + scopes=scopes, + context=prompt_context, + ) + + def _track_sent_message(self, message_id: str) -> None: + if not message_id: + return + self._sent_messages[message_id] = time.time() + self._sent_messages.move_to_end(message_id) + while len(self._sent_messages) > self._sent_messages_max: + evicted, _ = self._sent_messages.popitem(last=False) + self._sent_message_context.pop(evicted, None) + + def _remember_sent_message_context( + self, + result: SendResult, + *, + chat_id: Optional[str], + receive_id_type: Optional[str], + ) -> None: + message_ids = list(result.chunk_ids or []) + if result.message_id and result.message_id not in message_ids: + message_ids.insert(0, result.message_id) + for message_id in message_ids: + self._track_sent_message(message_id) + if not chat_id: + self._sent_message_context.pop(message_id, None) + continue + self._sent_message_context[message_id] = _SentMessageContext( + message_id=message_id, + chat_id=chat_id, + receive_id_type=receive_id_type, + ) + self._sent_message_context.move_to_end(message_id) diff --git a/lark_channel/channel/chat_mode.py b/lark_channel/channel/chat_mode.py new file mode 100644 index 0000000..4caf827 --- /dev/null +++ b/lark_channel/channel/chat_mode.py @@ -0,0 +1,32 @@ +import time +from collections import OrderedDict +from typing import Optional + +from .config import ChatModeCacheConfig + + +class ChatModeCache: + def __init__(self, config: ChatModeCacheConfig) -> None: + self._config = config + self._values = OrderedDict() + + def get(self, chat_id: str) -> Optional[str]: + if not self._config.enabled: + return None + item = self._values.get(chat_id) + if item is None: + return None + value, expires_at = item + if expires_at < time.time(): + self._values.pop(chat_id, None) + return None + self._values.move_to_end(chat_id) + return value + + def set(self, chat_id: str, chat_mode: str) -> None: + if not self._config.enabled or self._config.max_size <= 0: + return + self._values[chat_id] = (chat_mode, time.time() + self._config.ttl_seconds) + self._values.move_to_end(chat_id) + while len(self._values) > self._config.max_size: + self._values.popitem(last=False) diff --git a/lark_channel/channel/comment.py b/lark_channel/channel/comment.py new file mode 100644 index 0000000..051d922 --- /dev/null +++ b/lark_channel/channel/comment.py @@ -0,0 +1,301 @@ +import json +import inspect +from typing import Any, Callable, Dict, Optional + +from lark_channel.api.drive.comment import ( + build_comment_create_request, + build_comment_get_request, + build_comment_list_request, + build_comment_reply_list_request, + build_comment_reply_update_request, +) +from lark_channel.api.wiki.node import build_wiki_node_get_request + +from .types import CommentContext, CommentTarget + + +SUPPORTED_FILE_TYPES = {"doc", "docx", "sheet", "file"} + + +class CommentPrimitiveClient: + def __init__(self, *, raw_request: Callable[[Any], Any]) -> None: + self._raw_request = raw_request + + def resolve_comment_target_sync( + self, + *, + file_token: str, + file_type: str, + ) -> CommentTarget: + if file_type in SUPPORTED_FILE_TYPES: + return CommentTarget( + file_token=file_token, + file_type=file_type, + supported=True, + ) + if file_type == "wiki": + return CommentTarget( + file_token=file_token, + file_type=file_type, + supported=True, + reason="wiki_requires_async_resolution", + ) + return CommentTarget( + file_token=file_token, + file_type=file_type, + supported=False, + reason="unsupported_file_type", + ) + + async def resolve_comment_target( + self, + *, + file_token: str, + file_type: str, + ) -> CommentTarget: + direct = self.resolve_comment_target_sync( + file_token=file_token, + file_type=file_type, + ) + if file_type != "wiki": + return direct + + raw = await self._call(build_wiki_node_get_request(token=file_token)) + data = (raw or {}).get("data") or {} + node = data.get("node") or data + obj_token = node.get("obj_token") + obj_type = node.get("obj_type") + if obj_token and obj_type in SUPPORTED_FILE_TYPES: + return CommentTarget( + file_token=obj_token, + file_type=obj_type, + supported=True, + raw=node, + ) + return CommentTarget( + file_token=file_token, + file_type=file_type, + supported=False, + reason="wiki_resolution_failed", + raw=node if isinstance(node, dict) else {}, + ) + + async def get_comment_context( + self, + *, + target: CommentTarget, + comment_id: str, + event_reply_id: Optional[str] = None, + ) -> CommentContext: + if not target.supported: + return CommentContext(target, comment_id, "", "", False, None) + + raw = await self._call( + build_comment_get_request( + file_token=target.file_token, + file_type=target.file_type, + comment_id=comment_id, + ) + ) + if not raw or raw.get("code") != 0: + raw = await self._call( + build_comment_list_request( + file_token=target.file_token, + file_type=target.file_type, + ) + ) + + data = (raw or {}).get("data") or {} + comment = _select_comment(data, comment_id) + replies = _comment_replies(comment) + target_reply_id = _select_reply_id(replies, event_reply_id) + return CommentContext( + target=target, + comment_id=comment_id, + question=_content_to_text(comment.get("content")), + quote=_content_to_text(comment.get("quote")), + is_whole=bool(comment.get("is_whole")), + target_reply_id=target_reply_id, + raw=comment, + ) + + async def fetch_comment( + self, + *, + target: CommentTarget, + comment_id: str, + ): + if not target.supported: + return None + return await self._call( + build_comment_get_request( + file_token=target.file_token, + file_type=target.file_type, + comment_id=comment_id, + ) + ) + + async def list_comments( + self, + *, + target: CommentTarget, + page_token=None, + page_size=None, + is_whole=None, + is_solved=None, + ): + if not target.supported: + return None + return await self._call( + build_comment_list_request( + file_token=target.file_token, + file_type=target.file_type, + page_token=page_token, + page_size=page_size, + is_whole=is_whole, + is_solved=is_solved, + ) + ) + + async def list_comment_replies( + self, + *, + target: CommentTarget, + comment_id: str, + page_token=None, + page_size=None, + ): + if not target.supported: + return None + return await self._call( + build_comment_reply_list_request( + file_token=target.file_token, + file_type=target.file_type, + comment_id=comment_id, + page_token=page_token, + page_size=page_size, + ) + ) + + async def reply_comment(self, context: CommentContext, content: str): + if context.is_whole: + return await self._call( + build_comment_create_request( + file_token=context.target.file_token, + file_type=context.target.file_type, + content=content, + ) + ) + if not context.target_reply_id: + return None + return await self._call( + build_comment_reply_update_request( + file_token=context.target.file_token, + file_type=context.target.file_type, + comment_id=context.comment_id, + reply_id=context.target_reply_id, + content=content, + ) + ) + + async def _call(self, req): + result = self._raw_request(req) + if inspect.isawaitable(result): + result = await result + return _response_to_dict(result) + + +def _select_reply_id(replies, event_reply_id): + if event_reply_id: + for reply in replies: + if reply.get("reply_id") == event_reply_id: + return event_reply_id + return None + if replies: + return replies[-1].get("reply_id") + return None + + +def _comment_replies(comment: Dict[str, Any]): + reply_list = comment.get("reply_list") + if isinstance(reply_list, dict): + replies = reply_list.get("replies") + if isinstance(replies, list): + return replies + replies = comment.get("replies") + return replies if isinstance(replies, list) else [] + + +def _select_comment(data: Dict[str, Any], comment_id: str) -> Dict[str, Any]: + comment = data.get("comment") + if isinstance(comment, dict): + return comment + items = data.get("items") or data.get("comments") or [] + for item in items: + if item.get("comment_id") == comment_id: + return item + return data + + +def _response_to_dict(result: Any) -> Optional[Dict[str, Any]]: + if result is None: + return None + if isinstance(result, dict): + return _object_to_dict(result) + raw = getattr(result, "raw", None) + content = getattr(raw, "content", None) + if content: + if isinstance(content, bytes): + content = content.decode("utf-8") + if isinstance(content, str): + try: + parsed = json.loads(content) + except ValueError: + parsed = None + if isinstance(parsed, dict): + return _object_to_dict(parsed) + data = getattr(result, "data", None) + return { + "code": getattr(result, "code", 0), + "data": _object_to_dict(data), + } + + +def _content_to_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return "".join(_content_to_text(item) for item in value) + if not isinstance(value, dict): + return str(value) + + text_run = value.get("text_run") + if isinstance(text_run, dict) and isinstance(text_run.get("text"), str): + return text_run["text"] + if isinstance(value.get("text"), str): + return value["text"] + if isinstance(value.get("content"), str): + return value["content"] + + parts = [] + for key in ("elements", "content", "children"): + nested = value.get(key) + if nested is not None: + parts.append(_content_to_text(nested)) + return "".join(parts) + + +def _object_to_dict(value: Any) -> Any: + if isinstance(value, dict): + return {key: _object_to_dict(val) for key, val in value.items()} + if isinstance(value, list): + return [_object_to_dict(item) for item in value] + if hasattr(value, "__dict__"): + return { + key: _object_to_dict(val) + for key, val in value.__dict__.items() + if not key.startswith("_") + } + return value diff --git a/lark_channel/channel/config.py b/lark_channel/channel/config.py new file mode 100644 index 0000000..4d799e7 --- /dev/null +++ b/lark_channel/channel/config.py @@ -0,0 +1,497 @@ +"""Configuration for :class:`FeishuChannel`. + +**One schema**: ``policy`` / ``safety`` / ``inbound`` / ``outbound`` / ``uat`` +live at the top level of :class:`ChannelConfig`. Names follow Python +conventions — ``snake_case`` throughout and explicit time-unit suffixes +(``ttl_seconds``, ``delay_ms``, …). What you configure is what consumers read. + +Typical construction:: + + channel = FeishuChannel(app_id="cli_xxx", app_secret="***") + + channel = FeishuChannel( + app_id="cli_xxx", + app_secret="***", + safety=SafetyConfig( + dedup=DedupConfig(ttl_seconds=12 * 3600, max_entries=10_000), + text_batch=TextBatchConfig(delay_ms=800), + ), + outbound=OutboundConfig( + text_chunk_limit=2000, + retry=RetryConfig(max_attempts=5, base_delay_ms=250), + ssrf_allowlist=["cdn.example.com"], + ), + ) +""" + +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Union + +from lark_channel.core.const import FEISHU_DOMAIN +from lark_channel.core.enum import LogLevel + + +# --------------------------------------------------------------------------- +# Safety-layer primitives +# --------------------------------------------------------------------------- +# These used to live in safety/types.py, but the safety package eagerly +# imports SafetyPipeline which needs PolicyConfig from this module — a +# circular import. Defining them here breaks the cycle; the safety package +# imports them from here. + + +@dataclass +class DedupConfig: + """Dedup configuration shared by the pipeline ``Deduper`` and the safety + ``SeenCache``. ``enabled`` only gates the pipeline-layer ``Deduper``; the + safety-layer ``SeenCache`` always runs.""" + + enabled: bool = True + ttl_seconds: int = 12 * 3600 + max_entries: int = 5000 + sweep_seconds: int = 5 * 60 + + +@dataclass +class TextBatchConfig: + """Debounce + merge successive text messages in the same chat.""" + + delay_ms: int = 600 + long_threshold_chars: int = 1000 + long_delay_ms: int = 2000 + max_messages: int = 8 + max_chars: int = 4000 + + +@dataclass +class ChatQueueConfig: + """Per-chat serialization queue — forces one-handler-at-a-time per chat.""" + + enabled: bool = True + merge_while_busy: bool = True + +# --------------------------------------------------------------------------- +# Literal type aliases +# --------------------------------------------------------------------------- + +ReactionNotifications = Literal["off", "own", "all"] +ReplyModeValue = Literal["auto", "static", "streaming"] +ChunkMode = Literal["newline", "paragraph", "none"] +TableMode = Literal["table", "bullets", "code", "off"] +TagMdMode = Literal["structured", "native"] +TransportKind = Literal["ws", "webhook"] +DmPolicy = Literal["open", "allowlist", "blocklist", "disabled"] +GroupPolicy = Literal["open", "allowlist", "blocklist", "admin_only", "disabled"] +SenderIdentityField = Literal["open_id", "user_id", "union_id"] +ChatModeFallback = Optional[Literal["unknown", "group"]] +SecurityMode = Literal["compat", "audit", "strict"] +ResourceOverflowPolicy = Literal["audit", "drop"] + + +# --------------------------------------------------------------------------- +# Policy +# --------------------------------------------------------------------------- + + +@dataclass +class GroupOverride: + """Per-chat overrides for a single group chat_id.""" + + policy: Optional[GroupPolicy] = None + allowlist: Optional[List[str]] = None + blocklist: Optional[List[str]] = None + require_mention: Optional[bool] = None + respond_to_mention_all: Optional[bool] = None + reply_mode: Optional[ReplyModeValue] = None + history_limit: Optional[int] = None + enabled: Optional[bool] = None + + +@dataclass +class PolicyConfig: + """Admission / routing policy for inbound messages. + + Fields: + + - ``dm_policy`` / ``group_policy`` — + ``open`` | ``allowlist`` | ``blocklist`` | ``admin_only`` | ``disabled`` + (``admin_only`` only valid for groups) + - ``require_mention`` — only respond in group chats when @Bot is mentioned + - ``respond_to_mention_all`` — treat ``@all`` as a valid mention + - ``allow_from`` / ``deny_from`` — sender identities allowed/denied + under DM ``allowlist``/``blocklist`` modes, matched by + ``sender_identity_fields`` + - ``group_allowlist`` / ``group_blocklist`` — chat_ids allowed/denied + under group ``allowlist``/``blocklist`` modes + - ``admins`` — sender identities that bypass every gate (always allowed; + required sender list for ``admin_only`` group policy), matched by + ``sender_identity_fields`` + - ``sender_identity_fields`` — identity fields used for sender-based + allow/block/admin lists; group chat allow/block lists remain chat_id based + - ``group_overrides`` — per-chat overrides keyed by chat_id + """ + + dm_policy: DmPolicy = "open" + group_policy: GroupPolicy = "open" + require_mention: bool = True + respond_to_mention_all: bool = False + allow_from: Optional[List[str]] = None + deny_from: Optional[List[str]] = None + group_allowlist: Optional[List[str]] = None + group_blocklist: Optional[List[str]] = None + admins: Optional[List[str]] = None + sender_identity_fields: List[SenderIdentityField] = field( + default_factory=lambda: ["open_id"] + ) + group_overrides: Dict[str, GroupOverride] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Inbound +# --------------------------------------------------------------------------- + + +@dataclass +class MediaCapabilities: + image: bool = True + audio: bool = True + video: bool = True + file: bool = True + sticker: bool = True + + +@dataclass +class NameCacheConfig: + enabled: bool = True + max_size: int = 2000 + ttl_seconds: int = 24 * 3600 + + +@dataclass +class InboundConfig: + """Inbound-pipeline behaviour.""" + + expand_merge_forward: bool = True + fetch_interactive_card: bool = True + reaction_notifications: ReactionNotifications = "own" + media_capabilities: MediaCapabilities = field(default_factory=MediaCapabilities) + media_max_mb: Optional[int] = None + name_cache: NameCacheConfig = field(default_factory=NameCacheConfig) + merge_forward_max_depth: int = 3 + merge_forward_max_items: int = 50 + drop_self_sent: bool = True + inject_chat_mode: bool = False + include_raw: bool = True + emit_raw_events: bool = False + + +# --------------------------------------------------------------------------- +# Safety +# --------------------------------------------------------------------------- + + +@dataclass +class SafetyConfig: + """Safety-pipeline configuration. + + Groups dedup, per-chat queue, text batching, and the stale-cutoff window. + ``DedupConfig``, ``TextBatchConfig``, and ``ChatQueueConfig`` live in this + module; ``MediaBatchConfig`` lives in :mod:`lark_channel.channel.safety.types` + and is imported lazily to avoid a circular import through the safety + package. + """ + + dedup: DedupConfig = field(default_factory=DedupConfig) + text_batch: TextBatchConfig = field(default_factory=TextBatchConfig) + media_batch: Any = field( + default_factory=lambda: _media_batch_default() + ) + chat_queue: ChatQueueConfig = field(default_factory=ChatQueueConfig) + stale_message_window_ms: int = 30 * 60 * 1000 + + +def _media_batch_default(): + """Avoid importing MediaBatchConfig at module top to prevent circular + imports back from the safety package.""" + from .safety.types import MediaBatchConfig + return MediaBatchConfig() + + +# --------------------------------------------------------------------------- +# Outbound +# --------------------------------------------------------------------------- + + +@dataclass +class FooterConfig: + status: bool = False + elapsed: bool = False + tokens: bool = False + model: bool = False + cache: bool = False + context: bool = False + + +@dataclass +class StreamThrottleConfig: + min_chars: int = 20 + max_chars: int = 200 + idle_ms: int = 300 + + +@dataclass +class MarkdownConverter: + enabled: bool = True + table_mode: TableMode = "off" + tag_md_mode: TagMdMode = "structured" + + +@dataclass +class PerChatReplyMode: + default: ReplyModeValue = "auto" + dm: Optional[ReplyModeValue] = None + group: Optional[ReplyModeValue] = None + + +@dataclass +class RetryConfig: + """Outbound retry behaviour (see :mod:`..outbound.retry`).""" + + max_attempts: int = 3 + base_delay_ms: int = 500 + + +@dataclass +class OversizeContext: + """Passed to ``OutboundConfig.on_oversize`` when an outbound text exceeds + ``text_chunk_limit``. + + Hook contract: + + - Return None or empty string -> SDK falls back to default chunking. + - Return a non-empty string -> SDK sends that as a single replacement + message; the original long text is dropped. + - Raise -> exception propagates to ``channel.send(...)`` caller; no + silent fallback. + """ + + text: str + chat_id: str + receive_id_type: str + estimated_chunks: int + + +@dataclass +class OutboundConfig: + """Outbound-pipeline behaviour: chunking, streaming, retries, SSRF, oversize hook.""" + + reply_mode: Union[ReplyModeValue, PerChatReplyMode] = "auto" + text_chunk_limit: int = 3500 + chunk_mode: ChunkMode = "newline" + stream_initial_text: str = "" + stream_throttle: StreamThrottleConfig = field(default_factory=StreamThrottleConfig) + footer: FooterConfig = field(default_factory=FooterConfig) + markdown_converter: MarkdownConverter = field(default_factory=MarkdownConverter) + retry: RetryConfig = field(default_factory=RetryConfig) + # Hostname allowlist for URL-sourced media downloads. Required by the + # SSRF guard — without an allowlist, URL downloads are refused. See + # :mod:`..outbound.media.ssrf_guard` for the rationale. + ssrf_allowlist: Optional[List[str]] = None + on_oversize: Optional[ + Callable[[OversizeContext], "Awaitable[Optional[str]]"] + ] = None + + +# --------------------------------------------------------------------------- +# UAT (user access token) +# --------------------------------------------------------------------------- + + +@dataclass +class UATConfig: + """User access token (device-flow) behaviour.""" + + allowed_scopes: Optional[List[str]] = None + blocked_scopes: Optional[List[str]] = None + refresh_before_expiry_seconds: int = 300 + device_poll_interval_seconds: int = 5 + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + +@dataclass +class ChatModeCacheConfig: + enabled: bool = True + max_size: int = 2048 + ttl_seconds: int = 24 * 3600 + fallback: ChatModeFallback = None + + +@dataclass +class MediaCacheConfig: + enabled: bool = True + root_dir: Optional[Any] = None + ttl_seconds: int = 7 * 24 * 3600 + max_entries: int = 128 + max_bytes: int = 512 * 1024 * 1024 + max_file_bytes: int = 50 * 1024 * 1024 + image_max_bytes: int = 20 * 1024 * 1024 + + +@dataclass +class KeepaliveConfig: + enabled: bool = False + check_interval_seconds: float = 30.0 + wake_threshold_seconds: float = 90.0 + probe_timeout_seconds: float = 5.0 + failure_threshold: int = 2 + + +@dataclass +class TransportConfig: + kind: TransportKind = "ws" + auto_reconnect: bool = True + headers: Optional[Dict[str, str]] = None + http_timeout_seconds: float = 30.0 + proxy_url: Optional[str] = None + trust_env_proxy: Optional[bool] = None + keepalive: KeepaliveConfig = field(default_factory=KeepaliveConfig) + handshake_timeout_seconds: Optional[float] = None + + # WS tuning (pingInterval / reconnectInterval / reconnectNonce / etc.) is + # NOT exposed here intentionally: the Feishu WS endpoint delivers a + # server-authoritative ClientConfig on every handshake (and may push + # updates mid-session via CONTROL frames) which overrides any client-side + # values. User-supplied overrides would be silently replaced and create + # a false-control footgun. + + +# --------------------------------------------------------------------------- +# Security +# --------------------------------------------------------------------------- + + +@dataclass +class SecurityConfig: + mode: SecurityMode = "compat" + audit_recorder: Any = field( + default_factory=lambda: _security_audit_recorder_default() + ) + allow_unsigned_encrypted_webhook: bool = False + allow_insecure_ws: bool = False + allow_local_insecure_ws: bool = True + strict_error_response: Optional[bool] = None + strict_content_text: bool = False + legacy_token_cache_fallback: Optional[bool] = None + max_ws_fragment_parts: Optional[int] = None + max_ws_fragment_bytes: Optional[int] = None + max_concurrent_ws_handlers: Optional[int] = None + resource_overflow_policy: ResourceOverflowPolicy = "audit" + + def __post_init__(self) -> None: + if self.mode not in ("compat", "audit", "strict"): + raise ValueError("security mode must be 'compat', 'audit', or 'strict'") + if self.resource_overflow_policy not in ("audit", "drop"): + raise ValueError("resource_overflow_policy must be 'audit' or 'drop'") + if not callable(getattr(self.audit_recorder, "record", None)): + raise TypeError("audit_recorder must provide a callable record method") + for field_name in ( + "allow_unsigned_encrypted_webhook", + "allow_insecure_ws", + "allow_local_insecure_ws", + "strict_content_text", + ): + value = getattr(self, field_name) + if not isinstance(value, bool): + raise TypeError(f"{field_name} must be a boolean") + for field_name in ("strict_error_response", "legacy_token_cache_fallback"): + value = getattr(self, field_name) + if value is not None and not isinstance(value, bool): + raise TypeError(f"{field_name} must be a boolean or None") + for field_name in ( + "max_ws_fragment_parts", + "max_ws_fragment_bytes", + "max_concurrent_ws_handlers", + ): + value = getattr(self, field_name) + if value is not None and ( + not isinstance(value, int) or isinstance(value, bool) + ): + raise TypeError(f"{field_name} must be a positive integer") + if value is not None and value <= 0: + raise ValueError(f"{field_name} must be a positive integer") + + @property + def is_compat(self) -> bool: + return self.mode == "compat" + + @property + def is_audit(self) -> bool: + return self.mode == "audit" + + @property + def is_strict(self) -> bool: + return self.mode == "strict" + + @property + def enforce_strict_error_response(self) -> bool: + if self.strict_error_response is not None: + return self.strict_error_response + return self.is_strict + + @property + def effective_legacy_token_cache_fallback(self) -> bool: + if self.legacy_token_cache_fallback is not None: + return self.legacy_token_cache_fallback + return not self.is_strict + + +def _security_audit_recorder_default(): + from lark_channel.event.security import SecurityAuditRecorder + recorder = SecurityAuditRecorder() + recorder._lark_channel_default_recorder = True + return recorder + + +# --------------------------------------------------------------------------- +# Top-level +# --------------------------------------------------------------------------- + + +@dataclass +class ChannelConfig: + """Top-level configuration for :class:`FeishuChannel`. + + Groups the five functional areas (``policy`` / ``safety`` / ``inbound`` / + ``outbound`` / ``uat``) plus transport settings and the security fields + (``encrypt_key`` / ``verification_token``) used by the event dispatcher. + Every field has a sensible default; callers typically only need to set + ``app_id`` and ``app_secret`` for a minimal setup. + """ + + app_id: str = "" + app_secret: str = "" + domain: str = FEISHU_DOMAIN + log_level: LogLevel = LogLevel.INFO + + # Event dispatcher uses these regardless of transport kind; webhook mode + # also uses ``verification_token`` for request signature verification. + encrypt_key: Optional[str] = None + verification_token: Optional[str] = None + + transport: TransportConfig = field(default_factory=TransportConfig) + chat_mode_cache: ChatModeCacheConfig = field(default_factory=ChatModeCacheConfig) + + policy: PolicyConfig = field(default_factory=PolicyConfig) + safety: SafetyConfig = field(default_factory=SafetyConfig) + inbound: InboundConfig = field(default_factory=InboundConfig) + outbound: OutboundConfig = field(default_factory=OutboundConfig) + uat: UATConfig = field(default_factory=UATConfig) + + # Hook for testing / custom HTTP transport. + http_executor: Optional[Callable] = None + media_cache: MediaCacheConfig = field(default_factory=MediaCacheConfig) + security: SecurityConfig = field(default_factory=SecurityConfig) diff --git a/lark_channel/channel/driver.py b/lark_channel/channel/driver.py new file mode 100644 index 0000000..9f90736 --- /dev/null +++ b/lark_channel/channel/driver.py @@ -0,0 +1,350 @@ +"""Bridge between the channel layer and the underlying `lark_channel.Client`. + +Rather than coupling the channel to specific Request/Response types of the +generated API surface, we adapt them here into plain dict calls that the +OutboundSender / pipeline expect. This also makes it trivial to mock the +driver in tests. +""" + +import io +import json +from typing import Any, Dict, Optional + +from lark_channel.api.im.v1.model.create_message_request import CreateMessageRequest +from lark_channel.api.im.v1.model.create_message_request_body import CreateMessageRequestBody +from lark_channel.api.im.v1.model.create_message_reaction_request import CreateMessageReactionRequest +from lark_channel.api.im.v1.model.create_message_reaction_request_body import CreateMessageReactionRequestBody +from lark_channel.api.im.v1.model.delete_message_reaction_request import DeleteMessageReactionRequest +from lark_channel.api.im.v1.model.delete_message_request import DeleteMessageRequest +from lark_channel.api.im.v1.model.forward_message_request import ForwardMessageRequest +from lark_channel.api.im.v1.model.forward_message_request_body import ForwardMessageRequestBody +from lark_channel.api.im.v1.model.get_message_request import GetMessageRequest +from lark_channel.api.im.v1.model.list_message_reaction_request import ListMessageReactionRequest +from lark_channel.api.im.v1.model.patch_message_request import PatchMessageRequest +from lark_channel.api.im.v1.model.patch_message_request_body import PatchMessageRequestBody +from lark_channel.api.im.v1.model.reply_message_request import ReplyMessageRequest +from lark_channel.api.im.v1.model.reply_message_request_body import ReplyMessageRequestBody +from lark_channel.api.im.v1.model.update_message_request import UpdateMessageRequest +from lark_channel.api.im.v1.model.update_message_request_body import UpdateMessageRequestBody +from lark_channel.api.im.v1.model.emoji import Emoji +from lark_channel.client import Client +from lark_channel.core.json import JSON + +from .outbound.sender import SendDriver + + +def _as_upload_stream(data: bytes, name: str) -> io.BytesIO: + bio = io.BytesIO(data) + bio.name = name + return bio + + +def _resp_to_dict(resp: Any) -> Dict[str, Any]: + code = getattr(resp, "code", None) + msg = getattr(resp, "msg", None) + data_obj = getattr(resp, "data", None) + out: Dict[str, Any] = {"code": code if code is not None else 0, "msg": msg or ""} + if data_obj is not None: + try: + # Use the SDK's JSON marshaller for consistent dict shape. + out["data"] = json.loads(JSON.marshal(data_obj)) + except Exception: + # Best-effort: shallow copy of attrs. + out["data"] = { + k: getattr(data_obj, k) + for k in dir(data_obj) + if not k.startswith("_") and not callable(getattr(data_obj, k, None)) + } + return out + + +class LarkClientDriver: + """Adapt a `lark_channel.Client` instance into the driver methods we need.""" + + def __init__(self, client: Client) -> None: + self._client = client + + # -------- send ------------------------------------------------------------ + async def create_message( + self, + *, + receive_id_type: str, + receive_id: str, + msg_type: str, + content: str, + uuid: Optional[str] = None, + ) -> Dict[str, Any]: + body = ( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + ) + if uuid: + body = body.uuid(uuid) + req = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body(body.build()) + .build() + ) + resp = await self._client.im.v1.message.acreate(req) + return _resp_to_dict(resp) + + async def reply_message( + self, + *, + message_id: str, + msg_type: str, + content: str, + uuid: Optional[str] = None, + reply_in_thread: Optional[bool] = None, + ) -> Dict[str, Any]: + body = ReplyMessageRequestBody.builder().content(content).msg_type(msg_type) + if reply_in_thread is not None: + body = body.reply_in_thread(reply_in_thread) + if uuid: + body = body.uuid(uuid) + req = ( + ReplyMessageRequest.builder() + .message_id(message_id) + .request_body(body.build()) + .build() + ) + resp = await self._client.im.v1.message.areply(req) + return _resp_to_dict(resp) + + async def patch_message( + self, + *, + message_id: str, + content: str, + ) -> Dict[str, Any]: + req = ( + PatchMessageRequest.builder() + .message_id(message_id) + .request_body(PatchMessageRequestBody.builder().content(content).build()) + .build() + ) + resp = await self._client.im.v1.message.apatch(req) + return _resp_to_dict(resp) + + async def update_message( + self, + *, + message_id: str, + msg_type: str, + content: str, + ) -> Dict[str, Any]: + body = ( + UpdateMessageRequestBody.builder() + .msg_type(msg_type) + .content(content) + .build() + ) + req = ( + UpdateMessageRequest.builder() + .message_id(message_id) + .request_body(body) + .build() + ) + resp = await self._client.im.v1.message.aupdate(req) + return _resp_to_dict(resp) + + async def delete_message(self, *, message_id: str) -> Dict[str, Any]: + req = DeleteMessageRequest.builder().message_id(message_id).build() + resp = await self._client.im.v1.message.adelete(req) + return _resp_to_dict(resp) + + async def forward_message( + self, + *, + message_id: str, + chat_id: str, + ) -> Dict[str, Any]: + body = ForwardMessageRequestBody.builder().receive_id(chat_id).build() + req = ( + ForwardMessageRequest.builder() + .message_id(message_id) + .receive_id_type("chat_id") + .request_body(body) + .build() + ) + resp = await self._client.im.v1.message.aforward(req) + return _resp_to_dict(resp) + + async def fetch_message(self, message_id: str) -> Dict[str, Any]: + req = GetMessageRequest.builder().message_id(message_id).build() + resp = await self._client.im.v1.message.aget(req) + return _resp_to_dict(resp) + + async def add_reaction(self, *, message_id: str, emoji_type: str) -> Dict[str, Any]: + body = ( + CreateMessageReactionRequestBody.builder() + .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) + .build() + ) + req = ( + CreateMessageReactionRequest.builder() + .message_id(message_id) + .request_body(body) + .build() + ) + resp = await self._client.im.v1.message_reaction.acreate(req) + return _resp_to_dict(resp) + + async def remove_reaction(self, *, message_id: str, reaction_id: str) -> Dict[str, Any]: + req = ( + DeleteMessageReactionRequest.builder() + .message_id(message_id) + .reaction_id(reaction_id) + .build() + ) + resp = await self._client.im.v1.message_reaction.adelete(req) + return _resp_to_dict(resp) + + async def list_reactions( + self, + *, + message_id: str, + emoji_type: Optional[str] = None, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + ) -> Dict[str, Any]: + builder = ListMessageReactionRequest.builder().message_id(message_id) + if emoji_type: + builder = builder.reaction_type(emoji_type) + if page_token: + builder = builder.page_token(page_token) + if page_size is not None: + builder = builder.page_size(page_size) + req = builder.build() + resp = await self._client.im.v1.message_reaction.alist(req) + return _resp_to_dict(resp) + + # -------- media upload ---------------------------------------------------- + async def upload_image(self, *, data: bytes, file_name: str = "") -> Dict[str, Any]: + try: + from lark_channel.api.im.v1.model.create_image_request import CreateImageRequest + from lark_channel.api.im.v1.model.create_image_request_body import CreateImageRequestBody + except ImportError: # pragma: no cover + return {"code": -1, "msg": "image upload model missing"} + # The SDK's multipart serializer (`Files.extract_files`) only picks up + # fields whose value is an `io.IOBase`; raw `bytes` are silently + # dropped, which makes the server reject the request as 234001 + # "Invalid request param". Wrap the buffer in a BytesIO and attach a + # filename so the multipart part is well-formed. + body = ( + CreateImageRequestBody.builder() + .image_type("message") + .image(_as_upload_stream(data, file_name or "image")) + .build() + ) + req = CreateImageRequest.builder().request_body(body).build() + resp = await self._client.im.v1.image.acreate(req) + return _resp_to_dict(resp) + + async def upload_file( + self, + *, + data: bytes, + file_name: str = "", + file_type: str = "stream", + ) -> Dict[str, Any]: + try: + from lark_channel.api.im.v1.model.create_file_request import CreateFileRequest + from lark_channel.api.im.v1.model.create_file_request_body import CreateFileRequestBody + except ImportError: # pragma: no cover + return {"code": -1, "msg": "file upload model missing"} + name = file_name or "upload" + body = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(name) + .file(_as_upload_stream(data, name)) + .build() + ) + req = CreateFileRequest.builder().request_body(body).build() + resp = await self._client.im.v1.file.acreate(req) + return _resp_to_dict(resp) + + # -------- cardkit preallocation ------------------------------------------ + async def cardkit_create(self, *, body: Dict[str, Any]) -> Dict[str, Any]: + """POST ``/open-apis/cardkit/v1/card``. ``body`` is ``{type, data}``.""" + from lark_channel.api.cardkit.v1.model.create_card_request import CreateCardRequest + from lark_channel.api.cardkit.v1.model.create_card_request_body import ( + CreateCardRequestBody, + ) + + rb = ( + CreateCardRequestBody.builder() + .type(body.get("type") or "card_json") + .data(body.get("data") or "") + .build() + ) + req = CreateCardRequest.builder().request_body(rb).build() + resp = await self._client.cardkit.v1.card.acreate(req) + return _resp_to_dict(resp) + + async def cardkit_update_element( + self, *, card_id: str, element_id: str, body: Dict[str, Any] + ) -> Dict[str, Any]: + """POST ``/open-apis/cardkit/v1/card/{card_id}/element/{element_id}/content``.""" + from lark_channel.api.cardkit.v1.model.content_card_element_request import ( + ContentCardElementRequest, + ) + from lark_channel.api.cardkit.v1.model.content_card_element_request_body import ( + ContentCardElementRequestBody, + ) + + rb_b = ContentCardElementRequestBody.builder().content( + body.get("content") or "" + ) + seq = body.get("sequence") + if seq is not None: + rb_b = rb_b.sequence(int(seq)) + req = ( + ContentCardElementRequest.builder() + .card_id(card_id) + .element_id(element_id) + .request_body(rb_b.build()) + .build() + ) + resp = await self._client.cardkit.v1.card_element.acontent(req) + return _resp_to_dict(resp) + + async def cardkit_update_settings( + self, *, card_id: str, body: Dict[str, Any] + ) -> Dict[str, Any]: + """POST ``/open-apis/cardkit/v1/card/{card_id}/settings``.""" + from lark_channel.api.cardkit.v1.model.settings_card_request import ( + SettingsCardRequest, + ) + from lark_channel.api.cardkit.v1.model.settings_card_request_body import ( + SettingsCardRequestBody, + ) + + rb_b = SettingsCardRequestBody.builder().settings(body.get("settings") or "") + seq = body.get("sequence") + if seq is not None: + rb_b = rb_b.sequence(int(seq)) + req = ( + SettingsCardRequest.builder() + .card_id(card_id) + .request_body(rb_b.build()) + .build() + ) + resp = await self._client.cardkit.v1.card.asettings(req) + return _resp_to_dict(resp) + + # -------- public helpers -------------------------------------------------- + def send_driver(self) -> SendDriver: + return SendDriver( + create_message=self.create_message, + reply_message=self.reply_message, + patch_message=self.patch_message, + delete_message=self.delete_message, + forward_message=self.forward_message, + upload_image=self.upload_image, + upload_file=self.upload_file, + ) diff --git a/lark_channel/channel/errors.py b/lark_channel/channel/errors.py new file mode 100644 index 0000000..52f33a5 --- /dev/null +++ b/lark_channel/channel/errors.py @@ -0,0 +1,220 @@ +"""Channel error types and classification. + +Single canonical enum: `FeishuChannelErrorCode` — 10 values covering the +taxonomy of failures surfaced by the outbound / inbound pipelines. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional + + +class FeishuChannelErrorCode(str, Enum): + """Channel-layer error taxonomy (10 canonical values).""" + + FORMAT_ERROR = "format_error" + TARGET_REVOKED = "target_revoked" + RATE_LIMITED = "rate_limited" + PERMISSION_DENIED = "permission_denied" + UPLOAD_FAILED = "upload_failed" + DOWNLOAD_FAILED = "download_failed" + SSRF_BLOCKED = "ssrf_blocked" + SEND_TIMEOUT = "send_timeout" + NOT_CONNECTED = "not_connected" + UNKNOWN = "unknown" + + +@dataclass +class SendError: + code: FeishuChannelErrorCode + retryable: bool + hint: Optional[str] = None + raw_code: Optional[int] = None + # Suggested minimum wait before retrying. Populated when the upstream + # response carries a ``Retry-After`` header (seconds) or an equivalent + # rate-limit hint. ``None`` means "use default backoff". + retry_after_seconds: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "code": self.code.value, + "retryable": self.retryable, + "hint": self.hint, + "raw_code": self.raw_code, + "retry_after_seconds": self.retry_after_seconds, + } + + +# Feishu API error code buckets +_TOKEN_INVALID_CODES = {99991663, 99991664, 99991665, 99991666, 99991668} +# 99991402 / 11020 / 11021 are genuine "too many requests" backpressure codes; +# 99991400 / 99991401 used to be miscategorized here but are actually auth / +# permission denials ("invalid app_ticket" / "invalid access_token") so they +# live in _PERMISSION_CODES below. +_RATE_LIMIT_CODES = {99991402, 11020, 11021} +# 230001 = "invalid message content" (malformed body); NOT a revoked target. +# Do NOT put it in _TARGET_REVOKED_CODES — that triggers the wrong fallback +# (reply-gone → fresh create) and hides the real schema bug. +_TARGET_REVOKED_CODES = {230002, 230005, 230020, 230017} +_LENGTH_EXCEED_CODES = {230021, 230022} +_PERMISSION_CODES = { + 99991400, 99991401, # auth/token denials (not rate-limit) + 99991672, 99991679, 99991680, 99991681, + 230003, 230010, +} +_FORMAT_CODES = { + 230001, # invalid message content — shape mismatch, malformed JSON, etc. + 230099, # CardKit "failed to create card content" +} + + +def classify_error(raw_code: int, msg: str = "") -> SendError: + """Classify a Feishu API error code into a `SendError`.""" + if raw_code == 0: + return SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=False, raw_code=0, hint=msg) + if raw_code in _TOKEN_INVALID_CODES: + return SendError(code=FeishuChannelErrorCode.PERMISSION_DENIED, retryable=True, raw_code=raw_code, hint=msg) + if raw_code in _RATE_LIMIT_CODES: + return SendError(code=FeishuChannelErrorCode.RATE_LIMITED, retryable=True, raw_code=raw_code, hint=msg) + if raw_code in _TARGET_REVOKED_CODES: + return SendError(code=FeishuChannelErrorCode.TARGET_REVOKED, retryable=False, raw_code=raw_code, hint=msg) + if raw_code in _LENGTH_EXCEED_CODES: + return SendError(code=FeishuChannelErrorCode.FORMAT_ERROR, retryable=False, raw_code=raw_code, hint=msg) + if raw_code in _PERMISSION_CODES: + return SendError(code=FeishuChannelErrorCode.PERMISSION_DENIED, retryable=False, raw_code=raw_code, hint=msg) + if raw_code in _FORMAT_CODES: + return SendError(code=FeishuChannelErrorCode.FORMAT_ERROR, retryable=False, raw_code=raw_code, hint=msg) + if 500 <= raw_code < 600 or 50000 <= raw_code < 60000: + return SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=True, raw_code=raw_code, hint=msg) + return SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=False, raw_code=raw_code, hint=msg) + + +class _ChannelError(Exception): + """Base class for channel-layer errors that escape user handlers. + + Kept as an internal inheritance root; user-facing code should catch + :class:`FeishuChannelError`. + """ + + +class UATAuthError(_ChannelError): + """UAT device-flow authorization failed or was cancelled.""" + + +class PolicyDeniedError(_ChannelError): + """Raised internally when a policy gate blocks a message.""" + + +class FeishuChannelError(_ChannelError): + """Unified channel error raised by the outbound / inbound pipelines. + + Prefer the ``raise FeishuChannelError(...) from original_exc`` idiom to + preserve the underlying cause in tracebacks. The ``cause=`` kwarg below is + kept for back-compat: when supplied it is wired to ``__cause__`` so + ``traceback.print_exc()`` still shows the chain. + """ + + def __init__( + self, + code: FeishuChannelErrorCode, + message: str = "", + *, + cause: Optional[BaseException] = None, + context: Optional[Dict[str, Any]] = None, + ) -> None: + resolved = message or code.value + super().__init__(resolved) + self.code = code + # Stored explicitly so ``__repr__`` / diagnostics don't depend on the + # fragile ``self.args[0]`` indexing. + self.message: str = resolved + self.context = context or {} + # Wire to Python's exception-chaining machinery so tracebacks show the + # root cause. Previously this kwarg was stored but never surfaced. + if cause is not None: + self.__cause__ = cause + + @property + def cause(self) -> Optional[BaseException]: + """Back-compat shim: returns the wired ``__cause__``.""" + c = self.__cause__ + return c if isinstance(c, BaseException) else None + + def __repr__(self) -> str: + return ( + f"FeishuChannelError(code={self.code.value}, " + f"message={self.message!r})" + ) + + +class OutboundSendError(FeishuChannelError): + """Exception form of :class:`SendError` — what ``on('error', ...)`` handlers + actually receive when a send/stream call returns ``SendResult.fail(...)``. + + Subscribers consume errors uniformly (``logger.exception``, + ``traceback.format_exception``, Sentry, etc.); these expect a real + :class:`Exception` with ``__traceback__``. Forwarding a bare + :class:`SendError` dataclass would break that contract. This wrapper + preserves every field of the original ``SendError`` and is a subclass of + :class:`FeishuChannelError`, so existing ``except FeishuChannelError`` + catches still work. + + The original ``SendResult.fail(...)`` returned to the direct caller is + unchanged — wrapping happens only on the forwarding path. + """ + + def __init__(self, send_error: SendError) -> None: + super().__init__( + send_error.code, + send_error.hint or send_error.code.value, + ) + self.send_error = send_error + self.retryable = send_error.retryable + self.raw_code = send_error.raw_code + self.retry_after_seconds = send_error.retry_after_seconds + self.hint = send_error.hint + + def __repr__(self) -> str: + return ( + f"OutboundSendError(code={self.code.value}, " + f"raw_code={self.raw_code}, retryable={self.retryable}, " + f"hint={self.hint!r})" + ) + + +# ---- Classification helpers ------------------------------------------------- + + +def classify_api_error(raw_code: int, msg: str = "") -> FeishuChannelErrorCode: + """Map a Feishu API error code to a :class:`FeishuChannelErrorCode`. + + Thin shim around :func:`classify_error` — use that directly when you need + the full ``SendError`` (retryable flag, raw code, hint). Kept for callers + that only care about the taxonomy code. + """ + return classify_error(raw_code, msg).code + + +def classify_http_status(status: int) -> FeishuChannelErrorCode: + """Map an HTTP status code to a ``FeishuChannelErrorCode``.""" + if status == 429: + return FeishuChannelErrorCode.RATE_LIMITED + if status in (401, 403): + return FeishuChannelErrorCode.PERMISSION_DENIED + if status == 404: + return FeishuChannelErrorCode.TARGET_REVOKED + if status == 400: + return FeishuChannelErrorCode.FORMAT_ERROR + return FeishuChannelErrorCode.UNKNOWN + + +def is_retryable(code: FeishuChannelErrorCode) -> bool: + return code in (FeishuChannelErrorCode.RATE_LIMITED, FeishuChannelErrorCode.UNKNOWN) + + +def is_reply_target_gone(code: FeishuChannelErrorCode) -> bool: + return code == FeishuChannelErrorCode.TARGET_REVOKED + + +def is_format_error(code: FeishuChannelErrorCode) -> bool: + return code == FeishuChannelErrorCode.FORMAT_ERROR diff --git a/lark_channel/channel/events.py b/lark_channel/channel/events.py new file mode 100644 index 0000000..a03cf22 --- /dev/null +++ b/lark_channel/channel/events.py @@ -0,0 +1,71 @@ +"""Event-name constants and :data:`ChannelEventName` Literal type. + +``FeishuChannel.on(name, handler)`` accepts any of the strings in +:data:`ChannelEventName`. Passing an unknown name is not a hard error — it +logs a warning at runtime — so typos like ``channel.on("messageReceive", ...)`` +(the correct name is ``"message"``) will otherwise fail silently. + +Two ways to guard against that: + +1. **Use the constants** (``Events.MESSAGE``):: + + channel.on(Events.MESSAGE, handler) + +2. **Type-check with** :data:`ChannelEventName`:: + + from lark_channel import ChannelEventName + + event: ChannelEventName = "message" # mypy/pyright catches typos + channel.on(event, handler) + + ``FeishuChannel.on``'s type hint uses this Literal alias, so any type + checker will flag unknown event names in callers. +""" + +from typing import Literal + + +#: All event names accepted by :meth:`FeishuChannel.on`. The alias table in +#: :mod:`._coerce` normalizes snake_case aliases (``"bot_added"``, ``"card_action"``, +#: etc.) onto these canonical forms. +ChannelEventName = Literal[ + "message", + "cardAction", + "reaction", + "botAdded", + "botLeave", + "messageRead", + "reject", + "comment", + "raw", + "reconnecting", + "reconnected", + "error", +] + + +class Events: + """String constants for :meth:`FeishuChannel.on` event names. + + Prefer these over string literals so typos surface as ``AttributeError`` + at import time instead of a runtime no-op:: + + channel.on(Events.MESSAGE, on_message) + channel.on(Events.CARD_ACTION, on_card_action) + """ + + MESSAGE = "message" + CARD_ACTION = "cardAction" + REACTION = "reaction" + BOT_ADDED = "botAdded" + BOT_LEAVE = "botLeave" + MESSAGE_READ = "messageRead" + REJECT = "reject" + COMMENT = "comment" + RAW = "raw" + RECONNECTING = "reconnecting" + RECONNECTED = "reconnected" + ERROR = "error" + + +__all__ = ["ChannelEventName", "Events"] diff --git a/lark_channel/channel/identity.py b/lark_channel/channel/identity.py new file mode 100644 index 0000000..b36fae8 --- /dev/null +++ b/lark_channel/channel/identity.py @@ -0,0 +1,126 @@ +"""Identity resolution with a bounded LRU + TTL name cache. + +Channel handlers often need to turn an open_id into a display name (for +mentions, merge_forward summaries, etc.). This module: + +- Caches resolved (open_id → name) entries with a TTL. +- Batches lookups via `resolve_names(open_ids)`. +- Is transport-agnostic: a `ContactLookupFn` is injected. +""" + +import inspect +import threading +import time +from collections import OrderedDict +from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Union + +from lark_channel.core.log import logger + +from .config import NameCacheConfig +from .types import Identity + + +ContactLookupResult = Dict[str, Union[Identity, str]] +ContactLookupFn = Callable[ + [List[str]], Union[ContactLookupResult, Awaitable[ContactLookupResult]] +] +"""Signature: given a list of open_ids, return dict[open_id, Identity | str]. + +Either sync or awaitable return is accepted. +""" + + +class NameCache: + """Thread-safe bounded LRU + TTL cache of open_id → display_name.""" + + def __init__(self, config: Optional[NameCacheConfig] = None) -> None: + config = config or NameCacheConfig() + self._enabled = config.enabled + self._max = config.max_size + self._ttl = config.ttl_seconds + self._data: "OrderedDict[str, tuple[str, float]]" = OrderedDict() + self._lock = threading.Lock() + + def get(self, open_id: str) -> Optional[str]: + if not self._enabled or not open_id: + return None + with self._lock: + entry = self._data.get(open_id) + if entry is None: + return None + name, exp = entry + if exp <= time.time(): + self._data.pop(open_id, None) + return None + self._data.move_to_end(open_id) + return name + + def put(self, open_id: str, name: str) -> None: + if not self._enabled or not open_id or not name: + return + with self._lock: + self._data[open_id] = (name, time.time() + self._ttl) + self._data.move_to_end(open_id) + while len(self._data) > self._max: + self._data.popitem(last=False) + + def invalidate(self, open_id: str) -> None: + with self._lock: + self._data.pop(open_id, None) + + +class IdentityResolver: + """Async-friendly name resolver.""" + + def __init__( + self, + lookup: Optional[ContactLookupFn], + cache: Optional[NameCache] = None, + ) -> None: + self._lookup = lookup + self._cache = cache or NameCache() + + @property + def cache(self) -> NameCache: + return self._cache + + async def resolve_names(self, open_ids: Iterable[str]) -> Dict[str, str]: + """Batch resolve. Returns dict[open_id, name]; missing keys indicate + lookup failure. Populates the cache on success. + """ + ids = [o for o in open_ids if o] + if not ids: + return {} + + out: Dict[str, str] = {} + missing: List[str] = [] + for oid in ids: + cached = self._cache.get(oid) + if cached: + out[oid] = cached + else: + missing.append(oid) + + if missing and self._lookup is not None: + try: + result = self._lookup(missing) + if inspect.isawaitable(result): + result = await result + if isinstance(result, dict): + for oid, ident in result.items(): + if isinstance(ident, Identity) and ident.display_name: + self._cache.put(oid, ident.display_name) + out[oid] = ident.display_name + elif isinstance(ident, str) and ident: + self._cache.put(oid, ident) + out[oid] = ident + except Exception as e: # pragma: no cover - defensive + logger.warning("identity: lookup failed for %s ids: %s", len(missing), e) + + return out + + async def resolve(self, open_id: str) -> Identity: + if not open_id: + return Identity(open_id="") + names = await self.resolve_names([open_id]) + return Identity(open_id=open_id, display_name=names.get(open_id)) diff --git a/lark_channel/channel/keepalive.py b/lark_channel/channel/keepalive.py new file mode 100644 index 0000000..7fbd6f7 --- /dev/null +++ b/lark_channel/channel/keepalive.py @@ -0,0 +1,42 @@ +import inspect +import time +from typing import Callable + +from .config import KeepaliveConfig + + +class KeepaliveWatchdog: + def __init__( + self, + *, + config: KeepaliveConfig, + probe: Callable[[], bool], + reconnect: Callable[[], None], + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._config = config + self._probe = probe + self._reconnect = reconnect + self._clock = clock + self._last_tick = clock() + self._failures = 0 + + async def run_once(self) -> None: + if not self._config.enabled: + return + now = self._clock() + elapsed = now - self._last_tick + self._last_tick = now + if elapsed < self._config.wake_threshold_seconds: + self._failures = 0 + return + ok = self._probe() + if inspect.isawaitable(ok): + ok = await ok + if ok: + self._failures = 0 + return + self._failures += 1 + if self._failures >= self._config.failure_threshold: + self._failures = 0 + self._reconnect() diff --git a/lark_channel/channel/media_cache.py b/lark_channel/channel/media_cache.py new file mode 100644 index 0000000..090793f --- /dev/null +++ b/lark_channel/channel/media_cache.py @@ -0,0 +1,386 @@ +import hashlib +import mimetypes +import os +import tempfile +import time +from pathlib import Path + +from .config import MediaCacheConfig +from .types import CachedResource, ResourceDescriptor + + +class MediaResourceCache: + def __init__(self, config: MediaCacheConfig) -> None: + self._config = config + self._root = ( + Path(config.root_dir) + if config.root_dir is not None + else Path.home() / ".cache" / "lark-channel-sdk" + ) + self._key_index = {} + + async def resolve(self, *, message_id: str, resource: ResourceDescriptor, downloader): + if not self._config.enabled: + return self._result( + "skipped", + reason="cache_disabled", + message_id=message_id, + resource=resource, + ) + if resource.type not in ("image", "file"): + return self._result( + "rejected", + reason="unsupported_resource_type", + message_id=message_id, + resource=resource, + ) + + if not self._can_store(): + return self._result( + "skipped", + reason="cache_limit", + message_id=message_id, + resource=resource, + ) + + cache_key = (message_id, resource.type, resource.file_key) + cached = self._key_index.get(cache_key) + if cached is not None and cached.path is not None: + if ( + self._path_exists(cached.path) + and self._is_fresh(cached.path) + and self._is_valid_cache_file( + cached.path, + cached.sha256 or "", + cached.size if cached.size is not None else -1, + ) + ): + if self._touch(cached.path): + return cached + self._key_index.pop(cache_key, None) + else: + self._safe_unlink(cached.path) + self._key_index.pop(cache_key, None) + + body, meta = await downloader( + message_id=message_id, + file_key=resource.file_key, + resource_type=resource.type, + ) + if body is None: + return self._result( + "rejected", + reason="download_failed", + message_id=message_id, + resource=resource, + ) + + size = len(body) + mime_type = _normalize_mime_type(meta) + if resource.type == "image" and mime_type == "application/octet-stream": + mime_type = _sniff_image_mime_type(body) or mime_type + if size > self._config.max_file_bytes: + return self._result( + "rejected", + reason="max_file_bytes", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + if size > self._config.max_bytes: + return self._result( + "rejected", + reason="max_bytes", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + if resource.type == "image" and size > self._config.image_max_bytes: + return self._result( + "rejected", + reason="image_max_bytes", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + if resource.type == "image" and mime_type not in { + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + }: + return self._result( + "rejected", + reason="unsupported_mime_type", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + + digest = hashlib.sha256(body).hexdigest() + suffix = _suffix_for(mime_type) + try: + self._root.mkdir(parents=True, exist_ok=True) + except OSError: + return self._result( + "rejected", + reason="write_failed", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + path = self._root / f"{digest}{suffix}" + if self._path_exists(path) and not self._is_valid_cache_file(path, digest, size): + self._safe_unlink(path) + if not self._is_valid_cache_file(path, digest, size) and not self._write_atomic(path, body): + return self._result( + "rejected", + reason="write_failed", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + if not self._touch(path): + return self._result( + "rejected", + reason="write_failed", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + cached = self._result( + "cached", + message_id=message_id, + resource=resource, + path=path, + sha256=digest, + mime_type=mime_type, + size=size, + ) + self._key_index[cache_key] = cached + self.gc(protect=path) + if not self._path_exists(path): + self._key_index.pop(cache_key, None) + return self._result( + "rejected", + reason="cache_evicted", + message_id=message_id, + resource=resource, + mime_type=mime_type, + size=size, + ) + return cached + + def gc(self, *, protect=None) -> None: + if not self._path_exists(self._root): + return + now = time.time() + ttl_seconds = self._config.ttl_seconds + entries = self._entries() + for path in entries: + if path == protect: + continue + try: + expired = ttl_seconds <= 0 or now - path.stat().st_mtime > ttl_seconds + except OSError: + expired = True + if expired: + self._safe_unlink(path) + entries = self._entries() + max_entries = max(self._config.max_entries, 0) + while len(entries) > max_entries: + victim = self._oldest_unprotected(entries, protect) + if victim is None: + break + self._safe_unlink(victim) + entries = [path for path in entries if path != victim] + max_bytes = max(self._config.max_bytes, 0) + while entries and self._total_size(entries) > max_bytes: + victim = self._oldest_unprotected(entries, protect) + if victim is None: + break + self._safe_unlink(victim) + entries = [path for path in entries if path != victim] + self._key_index = { + key: value + for key, value in self._key_index.items() + if value.path is not None and self._path_exists(value.path) + } + + def _entries(self): + try: + candidates = list(self._root.iterdir()) + except OSError: + return [] + entries = [] + for path in candidates: + try: + if path.is_file() and not path.name.startswith("."): + path.stat() + entries.append(path) + except OSError: + continue + return sorted(entries, key=lambda path: self._mtime(path)) + + def _can_store(self) -> bool: + return ( + self._config.ttl_seconds > 0 + and self._config.max_entries > 0 + and self._config.max_bytes > 0 + ) + + def _is_fresh(self, path) -> bool: + try: + return time.time() - path.stat().st_mtime <= self._config.ttl_seconds + except OSError: + return False + + def _write_atomic(self, path, body: bytes) -> bool: + tmp_path = None + try: + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.stem}.", + suffix=".tmp", + dir=str(self._root), + ) + tmp_path = Path(tmp_name) + with os.fdopen(fd, "wb") as tmp: + tmp.write(body) + os.replace(tmp_path, path) + return True + except OSError: + if tmp_path is not None: + self._safe_unlink(tmp_path) + return False + + def _is_valid_cache_file(self, path, digest: str, size: int) -> bool: + try: + if path.is_symlink() or not path.is_file(): + return False + if path.stat().st_size != size: + return False + except OSError: + return False + return self._sha256_file(path) == digest + + def _touch(self, path) -> bool: + try: + os.utime(path, None) + return True + except OSError: + return False + + @staticmethod + def _path_exists(path) -> bool: + try: + return path.exists() + except OSError: + return False + + @staticmethod + def _safe_unlink(path) -> bool: + try: + path.unlink(missing_ok=True) + return True + except OSError: + return False + + @staticmethod + def _sha256_file(path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as fp: + for chunk in iter(lambda: fp.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return "" + return digest.hexdigest() + + @staticmethod + def _mtime(path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + @staticmethod + def _total_size(paths) -> int: + total = 0 + for path in paths: + try: + total += path.stat().st_size + except OSError: + continue + return total + + @staticmethod + def _oldest_unprotected(paths, protect): + for path in paths: + if path != protect: + return path + return None + + @staticmethod + def _result( + decision: str, + *, + message_id: str, + resource: ResourceDescriptor, + reason=None, + path=None, + sha256=None, + mime_type=None, + size=None, + ) -> CachedResource: + return CachedResource( + decision=decision, + reason=reason, + path=path, + sha256=sha256, + mime_type=mime_type, + size=size, + source_message_id=message_id, + source_file_key=resource.file_key, + resource_type=resource.type, + ) + + +def _normalize_mime_type(meta: str) -> str: + if not meta: + return "application/octet-stream" + if "/" in meta: + return meta.split(";", 1)[0].strip().lower() + guessed, _ = mimetypes.guess_type(meta) + return guessed or "application/octet-stream" + + +def _suffix_for(mime_type: str) -> str: + if mime_type == "image/png": + return ".png" + if mime_type in ("image/jpeg", "image/jpg"): + return ".jpg" + if mime_type == "image/gif": + return ".gif" + if mime_type == "image/webp": + return ".webp" + return mimetypes.guess_extension(mime_type) or ".bin" + + +def _sniff_image_mime_type(body: bytes) -> str: + if body.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if body.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if body.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" + if len(body) >= 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP": + return "image/webp" + return "" diff --git a/lark_channel/channel/normalize/__init__.py b/lark_channel/channel/normalize/__init__.py new file mode 100644 index 0000000..f1ab15a --- /dev/null +++ b/lark_channel/channel/normalize/__init__.py @@ -0,0 +1,37 @@ +"""Normalize: raw Lark events → structured InboundMessage / *Event shapes. + +Houses the inbound pipeline, message-content registry, dedup store, mention +parser (node-aligned ``extract_mentions`` / ``resolve_mentions``), and the +flat-text/resource flattener. +""" + +from .comment import CommentEvent, CommentOperator, normalize_comment +from .dedup import DedupStore, InMemoryDedupStore, make_event_key, make_message_key +from .flatten import flatten +from .mentions import ( + MentionExtraction, + extract_mentions, + is_mention_all, + parse_at_tags, + resolve_mentions, +) +from .pipeline import InboundPipeline +from .registry import parse_message_content + +__all__ = [ + "CommentEvent", + "CommentOperator", + "DedupStore", + "InMemoryDedupStore", + "InboundPipeline", + "MentionExtraction", + "extract_mentions", + "flatten", + "is_mention_all", + "make_event_key", + "make_message_key", + "normalize_comment", + "parse_at_tags", + "parse_message_content", + "resolve_mentions", +] diff --git a/lark_channel/channel/normalize/comment.py b/lark_channel/channel/normalize/comment.py new file mode 100644 index 0000000..f4f9423 --- /dev/null +++ b/lark_channel/channel/normalize/comment.py @@ -0,0 +1,147 @@ +"""Normalize ``drive.notice.comment_add_v1`` events. + +Wire payload (verified against the live Feishu tenant API):: + + { + "file_token": "...", + "file_type": "docx", + "comment_id": "...", + "reply_id": "...", + "is_mentioned": true, + "create_time": "1700000000000", # ms, string + "notice_meta": { + "from_user_id": { "open_id": "...", "user_id": "...", "union_id": "..." }, + "to_user_id": { ... }, + "file_token": "...", + "file_type": "docx", + "timestamp": "1700000000000", + "is_mentioned": true, + "notice_type": "comment_add" + }, + // Legacy fallbacks (older p1 callbacks): + "user_id": { "open_id": "...", ... }, + "is_mention": true, + "action_time": "1700000000000" + } + +The operator lives at ``notice_meta.from_user_id`` (top-level +``user_id`` is the legacy fallback). Whether the bot was mentioned is a +boolean flag (``is_mentioned``), not a probe of the mentions array. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + + +@dataclass +class CommentOperator: + open_id: Optional[str] = None + user_id: Optional[str] = None + union_id: Optional[str] = None + + +@dataclass +class CommentEvent: + file_token: str + file_type: str + comment_id: str + reply_id: Optional[str] + operator: CommentOperator + mentioned_bot: bool + timestamp: int + raw: Dict[str, Any] = field(default_factory=dict) + + +def normalize_comment( + data: Any, + *, + bot_open_id: Optional[str] = None, + envelope_timestamp: Optional[str] = None, +) -> Optional[CommentEvent]: + """Flatten the raw ``drive.notice.comment_add_v1`` payload. + + Accepts either the whole envelope (with ``event`` key) or the inner + event dict. Returns ``None`` when the payload is malformed or missing + one of the required fields (``file_token`` / ``file_type`` / + ``comment_id`` / operator open_id). + + ``envelope_timestamp`` is the ``header.create_time`` (p2) or top-level + ``ts`` (p1) carried by the WS/HTTP envelope. The inner event payload + omits a per-event timestamp on the wire, so the envelope is the only + reliable source — pass it from the dispatcher callback. + + ``bot_open_id`` is unused — the bot-mention signal is sourced from the + payload's ``is_mentioned`` flag instead. The parameter is kept for + backward compatibility with the previous (broken) implementation. + """ + if not isinstance(data, dict): + data = _try_dict(data) + if data is None: + return None + event = data.get("event") if isinstance(data.get("event"), dict) else data + if not isinstance(event, dict): + return None + notice_meta = event.get("notice_meta") if isinstance(event.get("notice_meta"), dict) else {} + + file_token = event.get("file_token") or notice_meta.get("file_token") or "" + file_type = event.get("file_type") or notice_meta.get("file_type") or "" + comment_id = event.get("comment_id") or notice_meta.get("comment_id") or "" + + # Operator: prefer notice_meta.from_user_id (current p2 wire format), + # fall back to top-level user_id (legacy p1 callback shape). The old + # path looked at ``event.operator`` / ``event.operator_id`` — neither + # is in the actual payload, so operator came back null. + user_id_obj = notice_meta.get("from_user_id") or event.get("user_id") or {} + if not isinstance(user_id_obj, dict): + user_id_obj = {} + operator_open_id = user_id_obj.get("open_id") + + # Required-field gate (node-aligned). Missing operator open_id is a + # malformed payload — drop rather than deliver a half-populated event. + if not (file_token and file_type and comment_id and operator_open_id): + return None + + reply_id = event.get("reply_id") or notice_meta.get("reply_id") or None + + op = CommentOperator( + open_id=operator_open_id, + user_id=user_id_obj.get("user_id"), + union_id=user_id_obj.get("union_id"), + ) + + mentioned_bot_flag = bool( + event.get("is_mentioned") + if event.get("is_mentioned") is not None + else (notice_meta.get("is_mentioned") or event.get("is_mention")) + ) + + ts_str = ( + event.get("create_time") + or notice_meta.get("timestamp") + or event.get("action_time") + or event.get("event_create_time") + or event.get("timestamp") + or envelope_timestamp + ) + try: + ts = int(ts_str) if ts_str is not None else 0 + except (TypeError, ValueError): + ts = 0 + + return CommentEvent( + file_token=file_token, + file_type=file_type, + comment_id=comment_id, + reply_id=reply_id, + operator=op, + mentioned_bot=mentioned_bot_flag, + timestamp=ts, + raw=event, + ) + + +def _try_dict(obj) -> Optional[Dict[str, Any]]: + try: + return {k: getattr(obj, k) for k in dir(obj) if not k.startswith("_")} + except Exception: # pragma: no cover + return None diff --git a/lark_channel/channel/normalize/converters/__init__.py b/lark_channel/channel/normalize/converters/__init__.py new file mode 100644 index 0000000..aa0516b --- /dev/null +++ b/lark_channel/channel/normalize/converters/__init__.py @@ -0,0 +1,85 @@ +"""Per-type MessageContent → (content_text, resources) converters. + +Each converter is a pure function ``convert(content) -> (str, List[ResourceDescriptor])``. +The ``REGISTRY`` maps concrete content types to their converter. The top-level +:func:`lark_channel.channel.normalize.flatten.flatten` dispatches via this table +and falls back to :mod:`.fallback` for unknown variants. + +Layout mirrors node-sdk's ``channel/normalize/converters/*.ts``. +""" + +from typing import Any, Callable, Dict, List, Tuple + +from ...types import ( + AudioContent, + CalendarContent, + FileContent, + FolderContent, + GeneralCalendarContent, + HongbaoContent, + ImageContent, + InteractiveContent, + LocationContent, + MediaContent, + MergeForwardContent, + PostContent, + ResourceDescriptor, + ShareCalendarEventContent, + ShareChatContent, + ShareUserContent, + StickerContent, + SystemContent, + TextContent, + TodoContent, + VideoChatContent, + VoteContent, +) +from . import ( + audio, + calendar, + fallback, + file, + folder, + hongbao, + image, + interactive, + location, + merge_forward, + post, + share, + sticker, + system, + text, + todo, + video, + video_chat, + vote, +) + +Converter = Callable[[Any], Tuple[str, List[ResourceDescriptor]]] + +REGISTRY: Dict[type, Converter] = { + TextContent: text.convert, + PostContent: post.convert, + ImageContent: image.convert, + FileContent: file.convert, + AudioContent: audio.convert, + MediaContent: video.convert, + StickerContent: sticker.convert, + InteractiveContent: interactive.convert, + ShareChatContent: share.convert_chat, + ShareUserContent: share.convert_user, + SystemContent: system.convert, + LocationContent: location.convert, + FolderContent: folder.convert, + HongbaoContent: hongbao.convert, + VideoChatContent: video_chat.convert, + CalendarContent: calendar.convert, + GeneralCalendarContent: calendar.convert_general, + ShareCalendarEventContent: calendar.convert_share_event, + VoteContent: vote.convert, + TodoContent: todo.convert, + MergeForwardContent: merge_forward.convert, +} + +__all__ = ["REGISTRY", "Converter", "fallback"] diff --git a/lark_channel/channel/normalize/converters/_utils.py b/lark_channel/channel/normalize/converters/_utils.py new file mode 100644 index 0000000..b9d5c1a --- /dev/null +++ b/lark_channel/channel/normalize/converters/_utils.py @@ -0,0 +1,60 @@ +"""Shared helpers used across per-type converters.""" + +import datetime +from typing import Any + + +def attr(s: Any) -> str: + """Escape a string for use inside an XML-like attribute value.""" + return (s or "").replace('"', "'").replace("\n", " ") + + +def format_duration(ms: Any) -> str: + """Format a millisecond duration as ``12s`` / ``1.5s``.""" + try: + if not ms: + return "0s" + s = ms / 1000.0 + formatted = f"{s:.1f}" + if "." in formatted: + return formatted.rstrip("0").rstrip(".") + "s" + return f"{int(s)}s" + except Exception: # pragma: no cover + return "0s" + + +def rfc3339_beijing(create_time_ms: Any) -> str: + """Format a timestamp as ``YYYY-MM-DDTHH:MM:SS+08:00`` (best-effort).""" + if not create_time_ms: + return "" + try: + ts = int(create_time_ms) + if ts > 10**12: + ts = ts / 1000.0 + dt = datetime.datetime.fromtimestamp( + ts, tz=datetime.timezone(datetime.timedelta(hours=8)) + ) + return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00") + except Exception: # pragma: no cover + return "" + + +def millis_to_datetime(value: Any) -> str: + """Format a ms-or-s epoch timestamp as ``YYYY-MM-DD HH:MM:SS``. + + Mirrors node-sdk's ``millisToDatetime`` helper used by calendar / video_chat + / todo converters. Accepts str-or-int input; returns empty string on any + parse failure so converters can conditionally omit a line. + """ + if value in (None, "", 0, "0"): + return "" + try: + ts = int(value) + if ts > 10**12: + ts = ts / 1000.0 + dt = datetime.datetime.fromtimestamp( + ts, tz=datetime.timezone(datetime.timedelta(hours=8)) + ) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: # pragma: no cover + return "" diff --git a/lark_channel/channel/normalize/converters/audio.py b/lark_channel/channel/normalize/converters/audio.py new file mode 100644 index 0000000..a4caad4 --- /dev/null +++ b/lark_channel/channel/normalize/converters/audio.py @@ -0,0 +1,17 @@ +"""Converter: AudioContent → ``", + re.IGNORECASE | re.DOTALL, +) +_ATTR_RE = re.compile(r'([\w_-]+)\s*=\s*"([^"]*)"') + + +def text_has_mention_all(text: Optional[str]) -> bool: + """True if ``text`` contains the ``@_all`` placeholder. + + Feishu does NOT populate ``event.message.mentions`` for mention-all + messages in all cases — the only signal can be an ``@_all`` token + inside ``content.text``. Without detecting that, downstream policy + code sees ``mentioned_all=False`` and the ``mention_all_blocked`` + gate never fires. + """ + if not text: + return False + return _AT_ALL_RE.search(text) is not None + + +# --------------------------------------------------------------------------- +# Data shape +# --------------------------------------------------------------------------- + + +@dataclass +class MentionExtraction: + """Indexed result of :func:`extract_mentions`. + + Mirrors node's ``MentionExtraction`` interface. + """ + + mentions: Dict[str, Mention] = field(default_factory=dict) + """Keyed by placeholder (e.g. ``@_user_1``).""" + + mentions_by_open_id: Dict[str, Mention] = field(default_factory=dict) + """Keyed by ``open_id`` (excludes @all).""" + + mention_list: List[Mention] = field(default_factory=list) + """Filtered list: no @all, no bot self-mention.""" + + mentioned_all: bool = False + mentioned_bot: bool = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def is_mention_all(m: Any) -> bool: + """True if ``m`` represents an ``@all`` mention (node-aligned).""" + if isinstance(m, dict): + if m.get("key") == "@_all": + return True + ident = m.get("id") + if isinstance(ident, dict) and ident.get("user_id") == "all": + return True + return False + if getattr(m, "key", None) == "@_all": + return True + ident = getattr(m, "id", None) + return ident is not None and getattr(ident, "user_id", None) == "all" + + +def _mention_from_event(m: Any) -> Mention: + if isinstance(m, dict): + ident = m.get("id") if isinstance(m.get("id"), dict) else {} + return Mention( + key=m.get("key") or "", + open_id=ident.get("open_id") if ident else None, + union_id=ident.get("union_id") if ident else None, + user_id=ident.get("user_id") if ident else None, + name=m.get("name"), + tenant_key=m.get("tenant_key"), + ) + ident = getattr(m, "id", None) + return Mention( + key=getattr(m, "key", "") or "", + open_id=getattr(ident, "open_id", None) if ident is not None else None, + union_id=getattr(ident, "union_id", None) if ident is not None else None, + user_id=getattr(ident, "user_id", None) if ident is not None else None, + name=getattr(m, "name", None), + tenant_key=getattr(m, "tenant_key", None), + ) + + +# --------------------------------------------------------------------------- +# Public API (node-aligned) +# --------------------------------------------------------------------------- + + +def extract_mentions( + raw: Optional[Iterable[Any]], + bot_open_id: Optional[str] = None, +) -> MentionExtraction: + """Index raw event mentions into a :class:`MentionExtraction`. + + - ``@all`` entries set ``mentioned_all`` and are excluded from lists. + - Entries whose ``open_id`` matches ``bot_open_id`` set ``mentioned_bot`` + and are also excluded from ``mention_list`` (but kept in ``mentions`` + by key so :func:`resolve_mentions` can still strip placeholders). + """ + out = MentionExtraction() + for m in (raw or []): + if is_mention_all(m): + out.mentioned_all = True + continue + parsed = _mention_from_event(m) + if parsed.key: + out.mentions[parsed.key] = parsed + if parsed.open_id: + out.mentions_by_open_id[parsed.open_id] = parsed + if bot_open_id and parsed.open_id == bot_open_id: + out.mentioned_bot = True + continue + out.mention_list.append(parsed) + return out + + +def resolve_mentions( + content: str, + ext: MentionExtraction, + *, + strip_bot_mentions: bool = False, + bot_open_id: Optional[str] = None, +) -> str: + """Replace ``@_user_N`` placeholders with ``@{name}``. + + When ``strip_bot_mentions=True`` and ``bot_open_id`` is provided, + placeholders referencing the bot are removed outright (adjacent + whitespace normalized). + """ + if not content: + return content or "" + + def _replace(match: "re.Match[str]") -> str: + key = match.group(0) + m = ext.mentions.get(key) + if m is None: + return key + if strip_bot_mentions and bot_open_id and m.open_id == bot_open_id: + return "" + if not m.name: + return key + return f"@{m.name}" + + result = _PLACEHOLDER_RE.sub(_replace, content) + # Rewrite ``@_all`` placeholder to the human-visible form; without this + # user-visible content carries the raw token. + result = _AT_ALL_RE.sub(MENTION_ALL_DISPLAY, result) + if strip_bot_mentions: + result = re.sub(r"\s{2,}", " ", result).strip() + return result + + +# --------------------------------------------------------------------------- +# Python-only helper: inline tags inside post / card rendered content +# --------------------------------------------------------------------------- + + +def parse_at_tags(text: str) -> Tuple[List[Mention], bool, str]: + """Strip ``Name`` tags into mentions + text. + + Used for post / card rendered payloads where placeholders are unavailable. + No direct node counterpart — node handles this inside per-converter walks. + """ + mentions: List[Mention] = [] + mentioned_all = False + + def _replace(match: "re.Match[str]") -> str: + nonlocal mentioned_all + attrs = dict(_ATTR_RE.findall(match.group(1))) + uid = attrs.get("user_id") or attrs.get("open_id") or "" + name = match.group("name") or attrs.get("user_name") or "" + if uid == "all": + mentioned_all = True + return MENTION_ALL_DISPLAY + mentions.append( + Mention( + key=f"@_at_{uid}", + open_id=uid if uid.startswith("ou_") else None, + user_id=uid if not uid.startswith("ou_") else None, + name=name or None, + ) + ) + return f"@{name}" if name else f"@{uid}" + + stripped = _AT_TAG_RE.sub(_replace, text) + return mentions, mentioned_all, stripped diff --git a/lark_channel/channel/normalize/merge_forward.py b/lark_channel/channel/normalize/merge_forward.py new file mode 100644 index 0000000..d96f071 --- /dev/null +++ b/lark_channel/channel/normalize/merge_forward.py @@ -0,0 +1,243 @@ +"""Merge-forward async expansion — single fetch, local traversal. + +Pattern: + +1. ONE top-level ``fetchSubMessages(messageId)`` call returns the flat list + of ALL descendants (including deeply-nested merge_forwards). +2. Build a ``parent_id → [children]`` map keyed by ``upper_message_id``. +3. Traverse the tree locally. Nested merge_forward items just recurse into + the same map — **no additional API calls**. + +A naive implementation that re-fetches for each nested merge_forward +multiplies API calls with nesting depth. This module instead matches +node: single fetch, local traversal. + +Any failure is captured in the ``.error`` field of the MergeForwardContent — +handlers never see an exception. +""" + +from collections import defaultdict +from typing import Any, Dict, List, Optional, Set + +from lark_channel.core.log import logger + +from ..types import ( + MergeForwardContent, + MergeForwardItem, + MessageContent, + UnknownContent, +) +from .registry import parse_message_content + + +class MergeForwardExpander: + """Expand a merge_forward message via a single API fetch + local traversal. + + Dependencies are injected so this is easy to test: + - ``fetch_message(message_id) -> payload dict``: returns the full + response (expected to contain ``data.items`` — the flat tree). + - ``resolve_names(open_ids) -> dict[open_id, display_name]``: batch + name lookup. + """ + + def __init__( + self, + fetch_message, + resolve_names=None, + max_depth: int = 3, + max_items: int = 50, + ): + self._fetch_message = fetch_message + self._resolve_names = resolve_names + self._max_depth = max_depth + self._max_items = max_items + + async def expand(self, message_id: str, depth: int = 0) -> MergeForwardContent: + """Fetch once, build map, materialize tree. Never re-fetches.""" + content = MergeForwardContent(loading=False) + if depth > self._max_depth: + content.error = "max_depth_exceeded" + return content + + try: + payload = await _maybe_await(self._fetch_message(message_id)) + except Exception as e: # pragma: no cover - network/defensive + logger.warning("merge_forward fetch failed for %s: %s", message_id, e) + content.error = f"fetch_failed: {e}" + return content + + if not payload: + content.error = "empty_payload" + return content + + items_raw = _extract_all_items(payload) + if not items_raw: + return content # empty tree — loading=False, items=[] + + children_map = _build_children_map(items_raw, root_id=message_id) + + # One batch name-resolve call for every sender across the whole tree. + open_ids: Set[str] = set() + for item in items_raw: + oid = _get_sender_open_id(item) + if oid: + open_ids.add(oid) + name_map: Dict[str, str] = {} + if open_ids and self._resolve_names is not None: + try: + resolved = await _maybe_await( + self._resolve_names(list(open_ids)) + ) + if isinstance(resolved, dict): + name_map = {k: v for k, v in resolved.items() if v} + except Exception as e: # pragma: no cover - defensive + logger.warning("merge_forward name resolution failed: %s", e) + + materialized = self._materialize( + message_id, children_map, name_map, depth=depth + ) + content.items = materialized.items + content.truncated = materialized.truncated + return content + + def _materialize( + self, + parent_id: str, + children_map: Dict[str, List[Dict[str, Any]]], + name_map: Dict[str, str], + depth: int, + ) -> MergeForwardContent: + """Build one ``MergeForwardContent`` level from the pre-fetched map.""" + result = MergeForwardContent(loading=False) + if depth > self._max_depth: + result.error = "max_depth_exceeded" + return result + + children = children_map.get(parent_id, []) + if len(children) > self._max_items: + children = children[: self._max_items] + result.truncated = True + + items: List[MergeForwardItem] = [] + for item in children: + try: + items.append( + self._materialize_item( + item, children_map, name_map, depth + ) + ) + except Exception as e: # pragma: no cover - defensive + logger.warning("merge_forward materialize item failed: %s", e) + continue + result.items = items + return result + + def _materialize_item( + self, + item: Dict[str, Any], + children_map: Dict[str, List[Dict[str, Any]]], + name_map: Dict[str, str], + depth: int, + ) -> MergeForwardItem: + mt = item.get("msg_type") or item.get("message_type") or "" + oid = _get_sender_open_id(item) + child_content: MessageContent + if mt == "merge_forward": + child_id = item.get("message_id") or "" + if child_id: + # Node-aligned: recurse into the SAME map (no new fetch). + child_content = self._materialize( + child_id, children_map, name_map, depth + 1 + ) + else: + child_content = UnknownContent(message_type="merge_forward", raw=item) + else: + body = item.get("body") or item + raw_content = body.get("content") if isinstance(body, dict) else None + if raw_content is None: + raw_content = item.get("content") + child_content = parse_message_content(mt, raw_content) + return MergeForwardItem( + message_id=item.get("message_id") or "", + sender_open_id=oid, + sender_name=name_map.get(oid) if oid else None, + create_time=_as_int(item.get("create_time")), + content=child_content, + raw=item, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_all_items(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return every item dict from the fetch payload — DO NOT filter by msg_type. + + Node traverses the full flat list (including nested merge_forward items) + and uses ``upper_message_id`` to rebuild the tree; filtering here would + lose nested children. + """ + if not isinstance(payload, dict): + return [] + data = payload.get("data") or payload + if not isinstance(data, dict): + return [] + items = data.get("items") or data.get("messages") or [] + if not isinstance(items, list): + return [] + return [i for i in items if isinstance(i, dict)] + + +def _build_children_map( + items: List[Dict[str, Any]], root_id: str +) -> Dict[str, List[Dict[str, Any]]]: + """Group items by ``upper_message_id`` (fall back to ``root_id``). + + Skips the root item itself to match node's + ``if (it.message_id === rootId && !it.upper_message_id) continue`` guard. + + Sorts each bucket by ``create_time`` ascending. + """ + m: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for it in items: + mid = it.get("message_id") + upper = it.get("upper_message_id") + if mid == root_id and not upper: + continue # root itself + parent = upper or root_id + m[parent].append(it) + for arr in m.values(): + arr.sort(key=lambda x: _as_int(x.get("create_time")) or 0) + return dict(m) + + +def _get_sender_open_id(item: Dict[str, Any]) -> Optional[str]: + sender = item.get("sender") or {} + if isinstance(sender, dict): + return ( + sender.get("id") + or sender.get("open_id") + or (sender.get("sender_id") or {}).get("open_id") + if isinstance(sender.get("sender_id"), dict) + else sender.get("id") or sender.get("open_id") + ) + return None + + +def _as_int(v: Any) -> Optional[int]: + if v is None: + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + +async def _maybe_await(v): + import inspect + + if inspect.isawaitable(v): + return await v + return v diff --git a/lark_channel/channel/normalize/pipeline.py b/lark_channel/channel/normalize/pipeline.py new file mode 100644 index 0000000..a5949fe --- /dev/null +++ b/lark_channel/channel/normalize/pipeline.py @@ -0,0 +1,326 @@ +"""Top-level inbound pipeline. + +Orchestrates the flow: + raw event → dedup → parse → async enrich (merge_forward, interactive, + mentions, identity) → InboundMessage. +""" + +import inspect +import html +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from lark_channel.core.log import logger + +from ..config import InboundConfig, SecurityConfig +from ..types import ( + Conversation, + Identity, + InboundMessage, + InteractiveContent, + Mention, + MergeForwardContent, + PostContent, + ReplyRef, + TextContent, +) +from .dedup import Deduper +from .flatten import flatten +from .interactive import fetch_interactive +from .mentions import ( + extract_mentions, + parse_at_tags, + resolve_mentions, + text_has_mention_all, +) +from .merge_forward import MergeForwardExpander +from .registry import parse_message_content + + +@dataclass +class PipelineDeps: + """External hooks the pipeline needs (all optional for testing).""" + + fetch_message: Optional[Callable[[str], Any]] = None + resolve_names: Optional[Callable[[List[str]], Any]] = None + resolve_identity: Optional[Callable[[str], Any]] = None + + +@dataclass +class PipelineConfig: + inbound: InboundConfig = field(default_factory=InboundConfig) + security: SecurityConfig = field(default_factory=SecurityConfig) + account_id: str = "" + + +def _to_chat_type(value: Optional[str]) -> str: + if value in ("p2p", "group", "topic"): + return value + # Feishu sometimes uses "public"/"private" for chat_type. + if value == "private": + return "p2p" + if value == "public": + return "group" + return "unknown" + + +def _sender_to_identity(sender: Any) -> Identity: + """Normalize an event.sender payload (dict or EventSender) to Identity.""" + if isinstance(sender, dict): + sid = sender.get("sender_id") or {} + return Identity( + open_id=sid.get("open_id") or "", + union_id=sid.get("union_id"), + user_id=sid.get("user_id"), + is_bot=_is_bot_sender_type(sender.get("sender_type")), + ) + sid = getattr(sender, "sender_id", None) + return Identity( + open_id=getattr(sid, "open_id", "") or "", + union_id=getattr(sid, "union_id", None), + user_id=getattr(sid, "user_id", None), + is_bot=_is_bot_sender_type(getattr(sender, "sender_type", None)), + ) + + +def _is_bot_sender_type(sender_type: Any) -> bool: + return sender_type in {"bot", "app"} + + +def _message_to_dict(msg: Any) -> Dict[str, Any]: + if isinstance(msg, dict): + return msg + out: Dict[str, Any] = {} + for field_name in ( + "message_id", + "root_id", + "parent_id", + "create_time", + "update_time", + "chat_id", + "thread_id", + "chat_type", + "message_type", + "content", + "mentions", + "user_agent", + ): + out[field_name] = getattr(msg, field_name, None) + return out + + +class InboundPipeline: + def __init__( + self, + cfg: PipelineConfig, + deps: PipelineDeps, + deduper: Optional[Deduper] = None, + ) -> None: + self._cfg = cfg + self._deps = deps + self._deduper = deduper + self._bot_open_id: Optional[str] = None + self._chat_mode_resolver: Optional[Callable[[str], Any]] = None + self._expander = MergeForwardExpander( + fetch_message=deps.fetch_message, + resolve_names=deps.resolve_names, + max_depth=cfg.inbound.merge_forward_max_depth, + max_items=cfg.inbound.merge_forward_max_items, + ) + + def set_bot_open_id(self, open_id: Optional[str]) -> None: + self._bot_open_id = open_id or None + + def set_chat_mode_resolver(self, resolver: Optional[Callable[[str], Any]]) -> None: + self._chat_mode_resolver = resolver + + async def normalize( + self, + *, + message_event: Any, + sender: Any = None, + event_id: Optional[str] = None, + ) -> Optional[InboundMessage]: + return await self.process( + event_id=event_id, + message_event=message_event, + sender=sender, + apply_dedup=False, + ) + + async def process( + self, + event_id: Optional[str], + message_event: Any, + sender: Any, + *, + apply_dedup: bool = True, + ) -> Optional[InboundMessage]: + """Return InboundMessage or None if the event was deduped / filtered.""" + msg = _message_to_dict(message_event) + message_id: str = msg.get("message_id") or "" + + if apply_dedup and self._deduper is not None: + ok = self._deduper.check_and_mark( + self._cfg.account_id or "", event_id, message_id + ) + if not ok: + logger.debug("inbound: dedup hit for %s / %s", event_id, message_id) + return None + + message_type = msg.get("message_type") or "" + # Media capability gate (drop the message if that type is disabled) + media_caps = self._cfg.inbound.media_capabilities + gate_map = { + "image": media_caps.image, + "audio": media_caps.audio, + "media": media_caps.video, + "video": media_caps.video, + "file": media_caps.file, + "sticker": media_caps.sticker, + } + if message_type in gate_map and not gate_map[message_type]: + logger.debug("inbound: message_type %s disabled by media_caps", message_type) + return None + + content = parse_message_content(message_type, msg.get("content")) + + # Process mentions for text / post (extract → resolve). + raw_mentions = msg.get("mentions") or [] + ext = extract_mentions(raw_mentions) + mentions: List[Mention] = list(ext.mention_list) + mentioned_all = ext.mentioned_all + if isinstance(content, TextContent): + # Feishu frequently ships ``@all`` messages with + # ``mentions = null`` — the only signal is an ``@_all`` + # placeholder in ``content.text``. Without this probe the + # policy gate never sees ``mentioned_all=True`` so + # ``respond_to_mention_all`` / ``mention_all_blocked`` go + # silently skipped. + if not mentioned_all and text_has_mention_all(content.text): + mentioned_all = True + content.text = resolve_mentions(content.text, ext) + elif isinstance(content, PostContent): + if not mentioned_all and text_has_mention_all(content.text): + mentioned_all = True + content.text = resolve_mentions(content.text, ext) + at_mentions, at_all, stripped = parse_at_tags(content.text) + content.text = stripped + # Merge -tag mentions in (dedup by open_id/user_id/key). + seen = {m.open_id or m.user_id or m.key for m in mentions} + for m in at_mentions: + sig = m.open_id or m.user_id or m.key + if sig in seen: + continue + seen.add(sig) + mentions.append(m) + mentioned_all = mentioned_all or at_all + + mentioned_bot = bool( + self._bot_open_id + and any(m.open_id == self._bot_open_id for m in mentions) + ) + + # Async enrichment: merge_forward expansion + if ( + isinstance(content, MergeForwardContent) + and self._cfg.inbound.expand_merge_forward + and self._deps.fetch_message is not None + and message_id + ): + content = await self._expander.expand(message_id) + + # Async enrichment: interactive card re-fetch + if ( + isinstance(content, InteractiveContent) + and self._cfg.inbound.fetch_interactive_card + and self._deps.fetch_message is not None + and message_id + ): + fetched = await fetch_interactive(message_id, self._deps.fetch_message) + if fetched is not None: + # keep raw from the original event + fetched.raw = content.raw or fetched.raw + content = fetched + + # Build conversation + reply ref + conversation = Conversation( + chat_id=msg.get("chat_id") or "", + chat_type=_to_chat_type(msg.get("chat_type")), + thread_id=msg.get("thread_id") or None, + ) + + chat_mode = None + if ( + self._cfg.inbound.inject_chat_mode + and self._chat_mode_resolver is not None + and conversation.chat_id + ): + try: + chat_mode = self._chat_mode_resolver(conversation.chat_id) + if inspect.isawaitable(chat_mode): + chat_mode = await chat_mode + except Exception as e: # pragma: no cover - defensive + logger.debug("inbound: resolve chat mode failed: %s", e) + chat_mode = None + + reply: Optional[ReplyRef] = None + parent_id = msg.get("parent_id") or "" + root_id = msg.get("root_id") or "" + if parent_id and parent_id != root_id: + reply = ReplyRef(message_id=parent_id) + + sender_identity = _sender_to_identity(sender) + # Fill sender display name from name cache / resolver if available. + if not sender_identity.display_name and self._deps.resolve_names and sender_identity.open_id: + try: + result = self._deps.resolve_names([sender_identity.open_id]) + if inspect.isawaitable(result): + result = await result + if isinstance(result, dict): + sender_identity.display_name = result.get(sender_identity.open_id) or sender_identity.display_name + except Exception as e: # pragma: no cover - defensive + logger.debug("inbound: resolve sender name failed: %s", e) + + # Flat-string + resource-descriptor views of `content`. Re-flatten + # every time in case merge_forward expansion or interactive re-fetch + # changed the content kind since the initial parse. + flat_text, resources = flatten(content) + + # Second pass: resolve any ``@_user_N`` placeholder still in the + # rendered text. This catches placeholders embedded in nested + # merge_forward child content (parsed but not yet resolved because + # flatten walks children sync without a ctx). Idempotent on text that + # contains no placeholders. + flat_text = resolve_mentions(flat_text, ext) + safe_flat_text = _safe_content_text(flat_text) + content_text = safe_flat_text if self._cfg.security.strict_content_text else flat_text + + return InboundMessage( + id=message_id, + create_time=_int_or_zero(msg.get("create_time")), + conversation=conversation, + sender=sender_identity, + mentions=mentions, + mentioned_all=mentioned_all, + reply=reply, + content=content, + raw=msg if self._cfg.inbound.include_raw and isinstance(msg, dict) else {}, + content_text=content_text, + safe_content_text=safe_flat_text, + resources=resources, + mentioned_bot=mentioned_bot, + chat_mode=chat_mode, + raw_content_type=message_type, + ) + + +def _int_or_zero(v: Any) -> int: + try: + return int(v) if v is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _safe_content_text(value: str) -> str: + return html.escape(value or "", quote=True) diff --git a/lark_channel/channel/normalize/registry.py b/lark_channel/channel/normalize/registry.py new file mode 100644 index 0000000..938b31d --- /dev/null +++ b/lark_channel/channel/normalize/registry.py @@ -0,0 +1,359 @@ +"""Convert Lark EventMessage.content JSON into a MessageContent variant. + +The Lark platform currently supports 19 message types. We produce a uniform +`MessageContent` union so callers do not need to switch on the original +wire schema. + +This module handles the SYNCHRONOUS parsing step only — async expansion +(merge_forward child fetch, interactive card re-fetch) is layered on top in +`pipeline.py`. +""" + +import json +from typing import Any, Dict, List, Optional, Tuple + +from lark_channel.core.log import logger + +from ..types import ( + AudioContent, + CalendarContent, + FileContent, + FolderContent, + GeneralCalendarContent, + HongbaoContent, + ImageContent, + InteractiveContent, + LocationContent, + MediaContent, + MergeForwardContent, + MessageContent, + PostContent, + ShareCalendarEventContent, + ShareChatContent, + ShareUserContent, + StickerContent, + SystemContent, + TextContent, + TodoContent, + UnknownContent, + VideoChatContent, + VoteContent, +) + + +def _safe_json(raw: Any) -> Dict[str, Any]: + if isinstance(raw, dict): + return raw + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", errors="replace") + if isinstance(raw, str) and raw: + try: + decoded = json.loads(raw) + if isinstance(decoded, dict): + return decoded + except (ValueError, TypeError) as e: # pragma: no cover - defensive + logger.debug("parse_message_content: invalid JSON content: %s", e) + return {} + + +def _flatten_post_text(post: Dict[str, Any]) -> Tuple[str, str]: + """Return (title, plain_text) from a post AST. + + Post content has locale keys (`zh_cn`, `en_us`). We pick the first locale. + """ + if not isinstance(post, dict): + return "", "" + first_key = next(iter(post), None) + if first_key is None: + return "", "" + locale_doc = post.get(first_key) + if not isinstance(locale_doc, dict): + return "", "" + title = locale_doc.get("title") or "" + lines: List[str] = [] + for para in locale_doc.get("content") or []: + chunk: List[str] = [] + for el in para or []: + if not isinstance(el, dict): + continue + tag = el.get("tag") + if tag == "text": + chunk.append(el.get("text") or "") + elif tag == "a": + chunk.append(el.get("text") or el.get("href") or "") + elif tag == "at": + nm = el.get("user_name") or el.get("user_id") or "" + chunk.append(f"@{nm}" if nm else "@") + elif tag == "emotion": + chunk.append(f":{el.get('emoji_type') or ''}:") + elif tag == "img": + chunk.append("[image]") + elif tag == "media": + chunk.append("[media]") + elif tag == "code_block": + chunk.append(el.get("text") or "") + elif tag == "hr": + chunk.append("---") + elif tag == "md": + chunk.append(el.get("text") or "") + lines.append("".join(chunk)) + return title, "\n".join(lines) + + +def _parse_text(data: Dict[str, Any]) -> TextContent: + return TextContent(text=data.get("text") or "", raw=data) + + +def _parse_post(data: Dict[str, Any]) -> PostContent: + title, text = _flatten_post_text(data) + return PostContent(title=title, text=text, post=data, raw=data) + + +def _parse_image(data: Dict[str, Any]) -> ImageContent: + return ImageContent(image_key=data.get("image_key") or "", raw=data) + + +def _parse_file(data: Dict[str, Any]) -> FileContent: + return FileContent( + file_key=data.get("file_key") or "", + file_name=data.get("file_name"), + raw=data, + ) + + +def _parse_audio(data: Dict[str, Any]) -> AudioContent: + return AudioContent( + file_key=data.get("file_key") or "", + duration_ms=data.get("duration"), + raw=data, + ) + + +def _parse_media(data: Dict[str, Any]) -> MediaContent: + return MediaContent( + file_key=data.get("file_key") or "", + image_key=data.get("image_key"), + duration_ms=data.get("duration"), + file_name=data.get("file_name"), + raw=data, + ) + + +def _parse_sticker(data: Dict[str, Any]) -> StickerContent: + return StickerContent(file_key=data.get("file_key") or "", raw=data) + + +def _parse_interactive(data: Dict[str, Any]) -> InteractiveContent: + """Build a minimal InteractiveContent from the event payload. + + The event payload only contains truncated metadata — the pipeline will + decide whether to re-fetch the full card JSON via the API. + """ + version = "unknown" + if isinstance(data, dict): + if "schema" in data or "header" in data or "body" in data: + version = "v2" + elif "card" in data or "config" in data or "elements" in data: + version = "v1" + return InteractiveContent(card=data, card_version=version, raw=data) + + +def _parse_share_chat(data: Dict[str, Any]) -> ShareChatContent: + return ShareChatContent(chat_id=data.get("chat_id") or "", raw=data) + + +def _parse_share_user(data: Dict[str, Any]) -> ShareUserContent: + return ShareUserContent(user_id=data.get("user_id") or "", raw=data) + + +def _parse_system(data: Dict[str, Any]) -> SystemContent: + return SystemContent( + template=data.get("template") or "", + from_user=data.get("from_user") or [], + to_chatters=data.get("to_chatters") or [], + raw=data, + ) + + +def _parse_location(data: Dict[str, Any]) -> LocationContent: + def _f(v: Any) -> Optional[float]: + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + return LocationContent( + name=data.get("name") or "", + longitude=_f(data.get("longitude")), + latitude=_f(data.get("latitude")), + raw=data, + ) + + +def _parse_video_chat(data: Dict[str, Any]) -> VideoChatContent: + return VideoChatContent( + topic=data.get("topic") or "", + start_time=data.get("start_time"), + raw=data, + ) + + +def _parse_calendar(data: Dict[str, Any]) -> CalendarContent: + return CalendarContent( + summary=data.get("summary") or "", + start_time=data.get("start_time"), + end_time=data.get("end_time"), + raw=data, + ) + + +def _parse_vote(data: Dict[str, Any]) -> VoteContent: + return VoteContent( + topic=data.get("topic") or "", + options=list(data.get("options") or []), + raw=data, + ) + + +def _parse_todo(data: Dict[str, Any]) -> TodoContent: + """Todo's ``summary`` on the wire is ``{title, content: PostElement[][]}``. + + We lift ``title`` + flat body text so downstream converters don't need to + re-walk the AST. Falls back to treating ``summary`` as a plain string if + the platform ever ships that shape. + """ + summary = data.get("summary") + title = "" + body = "" + if isinstance(summary, dict): + title = summary.get("title") or "" + body = _extract_post_plain_text(summary.get("content")) + elif isinstance(summary, str): + title = summary + return TodoContent( + title=title, + body=body, + due_time=data.get("due_time"), + raw=data, + ) + + +def _extract_post_plain_text(blocks: Any) -> str: + """Flatten a post-AST ``PostElement[][]`` into plain text. + + Mirrors node-sdk's ``extractPostPlainText``: keeps only ``text`` / ``a`` + element text, joins elements within a paragraph with empty string, joins + paragraphs with newline. + """ + if not isinstance(blocks, list): + return "" + lines: List[str] = [] + for para in blocks: + if not isinstance(para, list): + continue + parts: List[str] = [] + for el in para: + if not isinstance(el, dict): + continue + tag = el.get("tag") + text = el.get("text") + if tag in ("text", "a") and text: + parts.append(text) + if parts: + lines.append("".join(parts)) + return "\n".join(lines) + + +def _parse_merge_forward(data: Dict[str, Any]) -> MergeForwardContent: + """Build a loading=True merge_forward; pipeline is responsible for expansion.""" + return MergeForwardContent(loading=True, raw=data) + + +def _parse_folder(data: Dict[str, Any]) -> FolderContent: + return FolderContent( + file_key=data.get("file_key") or "", + file_name=data.get("file_name") or data.get("name") or "", + file_size=data.get("file_size"), + raw=data, + ) + + +def _parse_hongbao(data: Dict[str, Any]) -> HongbaoContent: + amount = data.get("amount") + try: + amount = int(amount) if amount is not None else None + except (TypeError, ValueError): + amount = None + return HongbaoContent( + text=data.get("text") or data.get("title") or "", + amount=amount, + raw=data, + ) + + +def _parse_general_calendar(data: Dict[str, Any]) -> GeneralCalendarContent: + return GeneralCalendarContent( + summary=data.get("summary") or data.get("title") or "", + start_time=data.get("start_time"), + end_time=data.get("end_time"), + raw=data, + ) + + +def _parse_share_calendar_event(data: Dict[str, Any]) -> ShareCalendarEventContent: + return ShareCalendarEventContent( + summary=data.get("summary") or "", + organizer=data.get("organizer_display_name") or data.get("organizer") or "", + start_time=data.get("start_time"), + end_time=data.get("end_time"), + raw=data, + ) + + +_PARSERS = { + "text": _parse_text, + "post": _parse_post, + "image": _parse_image, + "file": _parse_file, + "audio": _parse_audio, + # `media` and `video` share the same parser; the richer `MediaContent` + # dataclass carries the extra fields when they are present on the wire. + "media": _parse_media, + "video": _parse_media, + "sticker": _parse_sticker, + "interactive": _parse_interactive, + "share_chat": _parse_share_chat, + "share_user": _parse_share_user, + "system": _parse_system, + "location": _parse_location, + "folder": _parse_folder, + "hongbao": _parse_hongbao, + "video_chat": _parse_video_chat, + "calendar": _parse_calendar, + "general_calendar": _parse_general_calendar, + "share_calendar_event": _parse_share_calendar_event, + "vote": _parse_vote, + "todo": _parse_todo, + "merge_forward": _parse_merge_forward, +} + + +def parse_message_content(message_type: str, raw_content: Any) -> MessageContent: + """Synchronous first-pass parse. + + - `message_type` drives the dispatch. + - `raw_content` is the string or dict from EventMessage.content. + - Unknown types fall back to `UnknownContent` — never raises. + """ + data = _safe_json(raw_content) + parser = _PARSERS.get(message_type or "") + if parser is None: + return UnknownContent(message_type=message_type or "", raw=data) + try: + return parser(data) + except Exception as e: # pragma: no cover - defensive + logger.exception("parse_message_content failed for %s: %s", message_type, e) + return UnknownContent(message_type=message_type or "", raw=data) + + +SUPPORTED_MESSAGE_TYPES: Tuple[str, ...] = tuple(_PARSERS.keys()) diff --git a/lark_channel/channel/outbound/__init__.py b/lark_channel/channel/outbound/__init__.py new file mode 100644 index 0000000..fe1fb99 --- /dev/null +++ b/lark_channel/channel/outbound/__init__.py @@ -0,0 +1,25 @@ +"""Outbound pipeline: convert OutboundMessage → Lark API calls. + +Sub-packages: + markdown/ — markdown → post-AST + code-fence-aware splitter + media/ — SSRF guard + zero-dep duration parsers + uploader + streaming/— streaming controllers + queue/throttle primitives +""" + +from .markdown import markdown_to_post_ast, split_with_code_fences +from .media import assert_public_url, parse_mp4_duration, parse_opus_duration +from .retry import with_retry +from .routing import infer_receive_id_type +from .sender import OutboundSender, chunk_text + +__all__ = [ + "OutboundSender", + "assert_public_url", + "chunk_text", + "infer_receive_id_type", + "markdown_to_post_ast", + "parse_mp4_duration", + "parse_opus_duration", + "split_with_code_fences", + "with_retry", +] diff --git a/lark_channel/channel/outbound/markdown/__init__.py b/lark_channel/channel/outbound/markdown/__init__.py new file mode 100644 index 0000000..ef66c80 --- /dev/null +++ b/lark_channel/channel/outbound/markdown/__init__.py @@ -0,0 +1,9 @@ +"""Markdown → Lark post-AST conversion + code-fence-aware splitter.""" + +from .splitter import split_with_code_fences +from .to_post import markdown_to_post_ast + +__all__ = [ + "markdown_to_post_ast", + "split_with_code_fences", +] diff --git a/lark_channel/channel/outbound/markdown/splitter.py b/lark_channel/channel/outbound/markdown/splitter.py new file mode 100644 index 0000000..9c38f61 --- /dev/null +++ b/lark_channel/channel/outbound/markdown/splitter.py @@ -0,0 +1,68 @@ +"""Code-fence-aware markdown splitter. + +Aligned with node-sdk's ``channel/outbound/markdown/splitter.ts``. Splits a +markdown string into chunks at most ``limit`` chars while preserving fenced +code-block integrity across chunk boundaries. + +Plain-text line chunking used by :class:`OutboundSender` lives next to the +sender itself (node-aligned — sender inlines chunking) and is not exposed +here. +""" + +import re +from typing import List, Optional + +_FENCE_RE = re.compile(r"^```(\w*)\s*$") +_HEADING_RE = re.compile(r"^#{1,6}\s") + + +def split_with_code_fences(text: str, limit: int) -> List[str]: + """Markdown-aware splitter. + + Guarantees: + - Each returned chunk is <= ``limit`` chars (bar a rare hard-overflow + when a single line exceeds the limit). + - Splits inside a fenced code block close with ``` and the next chunk + reopens with the same language tag. + - Prefers to break just *before* a heading line when the current + buffer is already ~75% full, so headings lead their chunk. + """ + if len(text) <= limit: + return [text] + lines = text.split("\n") + out: List[str] = [] + buf: List[str] = [] + buf_len = 0 + fence_lang: Optional[str] = None # None when outside a fence + + def flush(): + nonlocal buf, buf_len + if not buf: + return + chunk = "\n".join(buf) + if fence_lang is not None: + chunk += "\n```" + out.append(chunk) + buf = [] + buf_len = 0 + if fence_lang is not None: + reopen = "```" + fence_lang + buf.append(reopen) + buf_len = len(reopen) + + for line in lines: + m = _FENCE_RE.match(line) + line_len = len(line) + (1 if buf else 0) + is_heading = bool(_HEADING_RE.match(line)) + near_full = buf_len > limit * 0.75 + + if buf_len + line_len > limit or (is_heading and near_full and buf): + flush() + + buf.append(line) + buf_len += line_len + if m: + fence_lang = (m.group(1) or "") if fence_lang is None else None + + flush() + return out diff --git a/lark_channel/channel/outbound/markdown/to_post.py b/lark_channel/channel/outbound/markdown/to_post.py new file mode 100644 index 0000000..f8f05ff --- /dev/null +++ b/lark_channel/channel/outbound/markdown/to_post.py @@ -0,0 +1,370 @@ +"""Markdown → Lark post-AST converter. + +Covers the common subset: headings, bold, italic, inline code, code blocks, +links, bullet / numbered lists, blockquotes, , and horizontal rules. +Unsupported syntax falls back to plain text, preserving readability. + +This is a pragmatic converter — not a full CommonMark parser — because the +Lark post AST only supports a limited vocabulary. Lines that don't match a +recognized pattern are emitted as single `text` runs with inline markers +stripped. +""" + +import re +from typing import Any, Dict, List, Tuple + +from ...types import Identity + +# Inline marker patterns (order matters: most specific first). +# Italic is tricky because `**bold**` also matches `*...*`. We require that +# the italic delimiter is not adjacent to another `*` (or `_`). +_INLINE_PATTERNS = [ + (re.compile(r"\*\*(.+?)\*\*"), "bold"), + (re.compile(r"__(.+?)__"), "bold"), + (re.compile(r"(?]+)>([^<]*)", re.IGNORECASE) +_ATTR_RE = re.compile(r'([\w_-]+)\s*=\s*"([^"]*)"') + + +def _parse_inline_runs(line: str) -> List[Dict[str, Any]]: + """Return list of text runs with style attributes for a single line.""" + if not line: + return [] + + # Collect disjoint match ranges (bold / italic / code / link / at), + # non-overlapping, leftmost-first. Whatever is left is plain text. + matches: List[Tuple[int, int, Dict[str, Any]]] = [] + + for pat, style in _INLINE_PATTERNS: + for m in pat.finditer(line): + matches.append((m.start(), m.end(), {"tag": "text", "text": m.group(1), "style": [style]})) + + for m in _LINK_RE.finditer(line): + matches.append((m.start(), m.end(), {"tag": "a", "text": m.group(1), "href": m.group(2)})) + + for m in _AT_RE.finditer(line): + attrs = dict(_ATTR_RE.findall(m.group(1))) + uid = attrs.get("user_id") or attrs.get("open_id") or "" + name = m.group(2) or attrs.get("user_name") or uid + node: Dict[str, Any] = {"tag": "at"} + if uid: + node["user_id"] = uid + if name: + node["user_name"] = name + matches.append((m.start(), m.end(), node)) + + matches.sort(key=lambda t: (t[0], t[1])) + # Remove overlapping matches (keep the earliest; drop overlaps). + filtered: List[Tuple[int, int, Dict[str, Any]]] = [] + cursor = 0 + for s, e, node in matches: + if s < cursor: + continue + filtered.append((s, e, node)) + cursor = e + + # Interleave with plain-text runs. + out: List[Dict[str, Any]] = [] + pos = 0 + for s, e, node in filtered: + if s > pos: + out.append({"tag": "text", "text": line[pos:s]}) + out.append(node) + pos = e + if pos < len(line): + out.append({"tag": "text", "text": line[pos:]}) + return [r for r in out if r.get("text") or r.get("tag") == "at" or r.get("tag") == "a"] + + +def _plain(text: str) -> List[Dict[str, Any]]: + return [{"tag": "text", "text": text}] if text else [] + + +def markdown_to_post_ast( + md: str, + title: str = "", + locale: str = "zh_cn", + mentions: "list[Identity] | None" = None, + table_mode: str = "off", + tag_md_mode: str = "structured", +) -> Dict[str, Any]: + """Produce a Lark post AST (`{locale: {title, content: [[...]]}}`) from Markdown. + + Mentions supplied via `mentions` are appended (inline @tags) to the first + paragraph so the recipient actually gets notified. + + ``tag_md_mode``: + - ``"structured"`` (default): parse Markdown into explicit post nodes + (``tag:text`` with style attributes, ``tag:a`` for links, + ``tag:code_block`` for fenced code, etc). Cross-client deterministic. + - ``"native"``: wrap the raw markdown into one or more ``tag:md`` rows + (split at code-fence boundaries) and let the Feishu client's own + markdown parser render natively. Renders headers/blockquotes/lists + with native styling, but rendering depends on Feishu client version. + """ + if tag_md_mode == "native": + return _build_native_md_ast(md, title=title, locale=locale, mentions=mentions) + lines = (md or "").splitlines() + paragraphs: List[List[Dict[str, Any]]] = [] + + i = 0 + n = len(lines) + + def _flush_paragraph(buf: List[str]) -> None: + if not buf: + return + para_text = "\n".join(buf).strip() + if not para_text: + return + paragraphs.append(_parse_inline_runs(para_text)) + buf.clear() + + buf: List[str] = [] + while i < n: + line = lines[i] + stripped = line.strip() + + # Horizontal rule + if re.fullmatch(r"-{3,}|\*{3,}|_{3,}", stripped or ""): + _flush_paragraph(buf) + paragraphs.append([{"tag": "hr"}]) + i += 1 + continue + + # ATX heading # + m = re.match(r"^(#{1,6})\s+(.*)", line) + if m: + _flush_paragraph(buf) + heading_text = m.group(2).strip() + run = _parse_inline_runs(heading_text) + for r in run: + if r.get("tag") == "text": + styles = r.setdefault("style", []) + if "bold" not in styles: + styles.append("bold") + paragraphs.append(run or _plain(heading_text)) + i += 1 + continue + + # Fenced code block + m = re.match(r"^```(\w*)\s*$", line) + if m: + _flush_paragraph(buf) + lang = m.group(1) or "" + code_lines: List[str] = [] + i += 1 + while i < n and not re.match(r"^```\s*$", lines[i]): + code_lines.append(lines[i]) + i += 1 + if i < n: + i += 1 # skip closing fence + paragraphs.append( + [ + { + "tag": "code_block", + "language": lang.upper() if lang else "TEXT", + "text": "\n".join(code_lines), + } + ] + ) + continue + + # Blockquote + if stripped.startswith(">"): + _flush_paragraph(buf) + quote_lines: List[str] = [] + while i < n and lines[i].lstrip().startswith(">"): + quote_lines.append(re.sub(r"^\s*>\s?", "", lines[i])) + i += 1 + quote_text = "\n".join(quote_lines) + run = _parse_inline_runs(quote_text) + # Lark post has no native blockquote; prefix each line with │ so it's + # visually distinguishable. + paragraphs.append([{"tag": "text", "text": "│ "}] + run) + continue + + # Bullet list / ordered list: emit each bullet as its own paragraph. + if re.match(r"^\s*[-*+]\s+", line) or re.match(r"^\s*\d+[.)]\s+", line): + _flush_paragraph(buf) + while i < n and ( + re.match(r"^\s*[-*+]\s+", lines[i]) + or re.match(r"^\s*\d+[.)]\s+", lines[i]) + ): + item_line = lines[i] + bullet_text = re.sub(r"^\s*[-*+]\s+", "• ", item_line) + bullet_text = re.sub(r"^\s*\d+[.)]\s+", "\\g<0>", bullet_text) + paragraphs.append(_parse_inline_runs(bullet_text)) + i += 1 + continue + + # Table: depend on table_mode setting. + if _looks_like_table_row(line): + _flush_paragraph(buf) + table_lines: List[str] = [line] + i += 1 + while i < n and _looks_like_table_row(lines[i]): + table_lines.append(lines[i]) + i += 1 + converted = _convert_table(table_lines, mode=table_mode) + paragraphs.extend(converted) + continue + + if not stripped: + _flush_paragraph(buf) + i += 1 + continue + + buf.append(line) + i += 1 + + _flush_paragraph(buf) + + # Inject @mentions at the start of the first paragraph so recipients are + # notified. + if mentions and paragraphs: + at_runs: List[Dict[str, Any]] = [] + for ident in mentions: + if not ident or not ident.open_id: + continue + at_runs.append( + { + "tag": "at", + "user_id": ident.open_id, + "user_name": ident.display_name or "", + } + ) + at_runs.append({"tag": "text", "text": " "}) + paragraphs[0] = at_runs + paragraphs[0] + + if not paragraphs: + paragraphs = [_plain("")] + + return {locale: {"title": title or "", "content": paragraphs}} + + +def _looks_like_table_row(line: str) -> bool: + stripped = (line or "").strip() + return stripped.count("|") >= 2 and stripped.startswith("|") and stripped.endswith("|") + + +def _convert_table(lines: List[str], mode: str) -> List[List[Dict[str, Any]]]: + """Convert a Markdown table into paragraphs based on `mode`.""" + if mode == "off": + return [_parse_inline_runs(ln) for ln in lines] + + # Drop the separator row (---|---). + rows = [ + [c.strip() for c in ln.strip().strip("|").split("|")] + for ln in lines + if not re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", ln) + ] + if not rows: + return [_parse_inline_runs(ln) for ln in lines] + + if mode == "bullets": + # "col1: val1 · col2: val2" per row, excluding the header row. + header = rows[0] + out: List[List[Dict[str, Any]]] = [] + for row in rows[1:]: + pairs = [f"{header[i] if i < len(header) else ''}: {c}" for i, c in enumerate(row)] + out.append([{"tag": "text", "text": "• " + " · ".join(pairs)}]) + return out or [_parse_inline_runs(lines[0])] + + if mode == "code": + src = "\n".join(lines) + return [[{"tag": "code_block", "language": "TEXT", "text": src}]] + + # mode == "table" + if mode == "table": + rendered = [] + for row in rows: + rendered.append([{"tag": "text", "text": " | ".join(row)}]) + return rendered + + # default fallback + return [_parse_inline_runs(ln) for ln in lines] + + +_FENCE_LINE_RE = re.compile(r"^```") + + +def _build_native_md_ast( + md: str, + title: str = "", + locale: str = "zh_cn", + mentions: "list[Identity] | None" = None, +) -> Dict[str, Any]: + """Pack raw markdown into one or more ``tag:md`` rows. + + Each fenced code block is its own row; prose between fences is its own + row. This mirrors known Feishu client behavior (md content immediately + after a code fence may be swallowed otherwise). + + Mentions are attached to the first row (consistent with structured mode). + """ + segments = _split_at_code_fences(md or "") + rows: List[List[Dict[str, Any]]] = [ + [{"tag": "md", "text": seg}] for seg in segments + ] + + if mentions and rows: + at_runs: List[Dict[str, Any]] = [] + for ident in mentions: + if not ident or not ident.open_id: + continue + at_runs.append({ + "tag": "at", + "user_id": ident.open_id, + "user_name": ident.display_name or "", + }) + at_runs.append({"tag": "text", "text": " "}) + rows[0] = at_runs + rows[0] + + return {locale: {"title": title or "", "content": rows}} + + +def _split_at_code_fences(text: str) -> List[str]: + """Split markdown at fenced-code-block boundaries. + + Returns a list of non-empty segments where each segment is either a + prose block (no fence lines) or one complete fenced code block + (open fence + body + close fence). Unclosed fences are absorbed into + the trailing segment as-is — no content is dropped. + + Distinct from ``splitter.split_with_code_fences``: this one is + length-agnostic and never inserts synthetic close/reopen fences. + """ + if not text: + return [] + lines = text.split("\n") + out: List[str] = [] + buf: List[str] = [] + in_fence = False + + def _flush(): + if buf: + out.append("\n".join(buf)) + buf.clear() + + for line in lines: + is_fence = bool(_FENCE_LINE_RE.match(line)) + if not in_fence and is_fence: + # Closing prose, opening fence + _flush() + buf.append(line) + in_fence = True + elif in_fence and is_fence: + # Closing fence + buf.append(line) + _flush() + in_fence = False + else: + buf.append(line) + + _flush() + return [s for s in out if s != ""] diff --git a/lark_channel/channel/outbound/media/__init__.py b/lark_channel/channel/outbound/media/__init__.py new file mode 100644 index 0000000..0119e91 --- /dev/null +++ b/lark_channel/channel/outbound/media/__init__.py @@ -0,0 +1,11 @@ +"""Media subsystem: SSRF guard + zero-dep duration parsers.""" + +from .duration_mp4 import parse_mp4_duration +from .duration_ogg import parse_opus_duration +from .ssrf_guard import assert_public_url + +__all__ = [ + "assert_public_url", + "parse_mp4_duration", + "parse_opus_duration", +] diff --git a/lark_channel/channel/outbound/media/duration_mp4.py b/lark_channel/channel/outbound/media/duration_mp4.py new file mode 100644 index 0000000..d54ae34 --- /dev/null +++ b/lark_channel/channel/outbound/media/duration_mp4.py @@ -0,0 +1,76 @@ +"""MP4 / ISO BMFF duration parser — zero dependencies. + +Walks the box tree looking for `moov/mvhd`, then reads `timescale` + `duration` +from the mvhd payload. Handles: + +- Box size encoded as the first 4 bytes (big-endian uint32) +- Extended size (`size == 1`): reads a 64-bit BE size at offset +8, payload + begins at offset +16 +- End-of-file markers (`size == 0` means "extends to EOF") +- Two `mvhd` layout versions: + - v0 (version byte 0): times are 32-bit, duration at +16 + - v1 (version byte 1): times are 64-bit, duration at +20 + +Returns `None` on any malformed input. +""" + +import struct +from typing import Optional, Tuple + + +def parse_mp4_duration(buf: bytes) -> Optional[int]: + """Return duration in milliseconds, or None if unparseable.""" + moov = _find_box(buf, 0, len(buf), b"moov") + if moov is None: + return None + moov_start, moov_end = moov + mvhd = _find_box(buf, moov_start, moov_end, b"mvhd") + if mvhd is None: + return None + mvhd_start, mvhd_end = mvhd + if mvhd_end - mvhd_start < 32: + return None + version = buf[mvhd_start] + try: + if version == 0: + # creation(4) + modification(4) + timescale(4) + duration(4) + timescale = struct.unpack_from(">I", buf, mvhd_start + 12)[0] + duration = struct.unpack_from(">I", buf, mvhd_start + 16)[0] + else: + # creation(8) + modification(8) + timescale(4) + duration(8) + timescale = struct.unpack_from(">I", buf, mvhd_start + 20)[0] + duration = struct.unpack_from(">Q", buf, mvhd_start + 24)[0] + except struct.error: + return None + if not timescale or duration <= 0: + return None + return int(round((duration / timescale) * 1000)) + + +def _find_box(buf: bytes, start: int, end: int, wanted: bytes) -> Optional[Tuple[int, int]]: + """Scan sibling boxes between [start, end); return (payload_start, payload_end).""" + i = start + while i + 8 <= end: + try: + size = struct.unpack_from(">I", buf, i)[0] + box_type = buf[i + 4 : i + 8] + except struct.error: + return None + header_len = 8 + if size == 1: + # Extended size — 64-bit follows + if i + 16 > end: + return None + size = struct.unpack_from(">Q", buf, i + 8)[0] + header_len = 16 + elif size == 0: + # Extends to EOF + size = end - i + if size < header_len or i + size > end: + return None + payload_start = i + header_len + payload_end = i + size + if box_type == wanted: + return payload_start, payload_end + i += size + return None diff --git a/lark_channel/channel/outbound/media/duration_ogg.py b/lark_channel/channel/outbound/media/duration_ogg.py new file mode 100644 index 0000000..ec7b4e1 --- /dev/null +++ b/lark_channel/channel/outbound/media/duration_ogg.py @@ -0,0 +1,35 @@ +"""Opus-in-OGG duration parser — zero dependencies. + +Algorithm: + +1. Scan backward from end-of-buffer for the last `OggS` magic (4-byte `b"OggS"`) +2. Read `granule_position` as little-endian int64 at offset +6 +3. Convert to milliseconds: Opus is always 48 kHz, so `ms = granule / 48` + +Returns `None` on any failure — malformed OGG, not-Opus, negative granule. +""" + +import struct +from typing import Optional + +OGG_MAGIC = b"OggS" +OPUS_SAMPLES_PER_MS = 48 # Opus mandates a 48 kHz output rate + + +def parse_opus_duration(buf: bytes) -> Optional[int]: + """Return duration in milliseconds, or None if unparseable.""" + if not buf or len(buf) < 27: + return None + # Scan backward for the last OggS page + i = len(buf) - 27 + while i >= 0: + if buf[i : i + 4] == OGG_MAGIC: + try: + granule = struct.unpack_from(" bool: + try: + addr = ipaddress.IPv4Address(ip) + except ValueError: + return False + for network, prefix in _BLOCKED_V4: + try: + if addr in ipaddress.IPv4Network(f"{network}/{prefix}", strict=False): + return True + except ValueError: + continue + return False + + +def _ipv6_blocked(ip: str) -> bool: + try: + addr = ipaddress.IPv6Address(ip) + except ValueError: + return False + if addr.is_loopback or addr.is_link_local or addr.is_multicast or addr.is_unspecified: + return True + # Unique local addresses (fc00::/7) + if addr.packed[0] in (0xfc, 0xfd): + return True + # IPv4-mapped (::ffff:a.b.c.d) delegates to IPv4 policy. + if addr.ipv4_mapped is not None: + return _ipv4_blocked(str(addr.ipv4_mapped)) + # 6to4 (2002::/16) embeds an IPv4 in bits 16-47 — delegate to IPv4 policy + # so 2002:a9fe:a9fe:: (encoding 169.254.169.254) is blocked. + if addr.sixtofour is not None: + return _ipv4_blocked(str(addr.sixtofour)) + # NAT64 well-known prefix 64:ff9b::/96 embeds an IPv4 in the low 32 bits. + # ipaddress has no property for this; check prefix manually. + if addr.packed[:12] == b"\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00": + embedded_v4 = ".".join(str(b) for b in addr.packed[12:]) + return _ipv4_blocked(embedded_v4) + # Teredo (2001::/32) embeds an IPv4 client address in bits 96-127 (inverted). + if addr.teredo is not None: + # teredo returns (server_v4, client_v4); the client is the one actually + # reachable by the packet's destination, so check both. + server_v4, client_v4 = addr.teredo + if _ipv4_blocked(str(server_v4)) or _ipv4_blocked(str(client_v4)): + return True + return False + + +async def assert_public_url( + url: str, + *, + allowlist: Optional[List[str]] = None, +) -> None: + """Raise FeishuChannelError(ssrf_blocked) if `url` resolves to a private IP. + + - Protocol must be http/https. + - If `hostname` is in `allowlist`, we skip DNS + IP checks. + - Otherwise we `getaddrinfo` the hostname and verify every resolved + address is public. + """ + try: + u = urlparse(url) + except ValueError as e: + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + f"invalid url: {e}", + context={"url": redact_url_for_log(url)}, + ) from e + safe_url = redact_url_for_log(url) + if u.scheme not in ("http", "https"): + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + f"protocol {u.scheme!r} not allowed", + context={"url": safe_url}, + ) + hostname = u.hostname or "" + if not hostname: + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + "url has no hostname", + context={"url": safe_url}, + ) + if allowlist and hostname in allowlist: + return + + # Resolve hostname via getaddrinfo; offload to thread so we don't block. + loop = asyncio.get_running_loop() + try: + infos = await loop.run_in_executor( + None, lambda: socket.getaddrinfo(hostname, None) + ) + except socket.gaierror as e: + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + f"dns resolve failed: {e}", + context={"url": safe_url, "hostname": hostname}, + ) from e + + for family, _type, _proto, _canon, sockaddr in infos: + ip = sockaddr[0] if isinstance(sockaddr, tuple) else None + if not ip: + continue + if family == socket.AF_INET and _ipv4_blocked(ip): + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + f"blocked ipv4 {ip}", + context={"url": safe_url, "ip": ip}, + ) + if family == socket.AF_INET6 and _ipv6_blocked(ip): + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + f"blocked ipv6 {ip}", + context={"url": safe_url, "ip": ip}, + ) + + +def redact_url_for_log(url: str) -> str: + """Return a URL safe for logs/errors: no credentials, query, or fragment.""" + try: + u = urlparse(url) + hostname = u.hostname or "" + if not hostname: + return "" + netloc = hostname + if u.port is not None: + netloc = f"{netloc}:{u.port}" + return u._replace(netloc=netloc, query="", fragment="").geturl() + except Exception: + return "" diff --git a/lark_channel/channel/outbound/media/uploader.py b/lark_channel/channel/outbound/media/uploader.py new file mode 100644 index 0000000..a36c9bd --- /dev/null +++ b/lark_channel/channel/outbound/media/uploader.py @@ -0,0 +1,316 @@ +"""Media upload helpers — resolve a :class:`MediaSource` to a Lark ``file_key``. + +Extracted from :mod:`..sender` so the sender can stay focused on message +composition. Aligned with node-sdk's ``channel/outbound/media/uploader.ts``. + +The uploader: + 1. Passes through pre-resolved ``key`` sources unchanged. + 2. Gathers bytes for ``buffer``, ``file``, and ``url`` sources (SSRF- + guarding URLs by default). + 3. Delegates the actual upload to the :class:`SendDriver`'s + ``upload_image`` / ``upload_file`` callback. + +Runtime failures are surfaced as :class:`FeishuChannelError`; ``None`` is +reserved for caller-constructed "nothing to upload" inputs. +""" + +import asyncio +import inspect +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from lark_channel.core.log import logger + +from ...errors import FeishuChannelError, FeishuChannelErrorCode +from ...types import MediaSource +from .ssrf_guard import assert_public_url, redact_url_for_log + +# 50 MiB default cap on URL-sourced downloads. Prevents an attacker URL that +# returns an unbounded body from exhausting worker RAM. Tune via +# ``_url_download_cap`` on the ``MediaSource`` if callers need a larger cap. +_URL_DOWNLOAD_DEFAULT_CAP = 50 * 1024 * 1024 + + +async def resolve_media_key( + driver, + source: Optional[MediaSource], + kind: str, + *, + file_name: Optional[str] = None, + file_type: Optional[str] = None, + ssrf_allowlist: Optional[List[str]] = None, +) -> Optional[str]: + """Return a Lark file_key for ``source``, uploading if needed. + + ``kind`` selects the driver endpoint: ``"image"`` → ``upload_image``, + anything else → ``upload_file``. ``ssrf_allowlist`` (if given) supplies the + sender-level default from ``OutboundConfig.ssrf_allowlist``; an explicit + per-source allowlist still takes precedence. + + **Error propagation.** Three failure modes, each surfaced to the caller + with a typed :class:`FeishuChannelError` code so the sender can map it + onto a ``SendResult.fail`` with the right taxonomy instead of the + previous "empty body" catch-all (which lost the real code / msg): + + - ``UPLOAD_FAILED`` — the upload API returned a non-zero code or the + underlying transport raised (network / TLS / auth). ``context`` carries + ``raw_code`` and ``raw_msg`` pulled from the server response so the + caller can see exactly why it failed. + - ``SSRF_BLOCKED`` — ``gather_buffer`` raised (URL + no allowlist, or a + resolved IP in a blocked CIDR). Bubbled through unchanged. + - Returns ``None`` only for "nothing to upload" cases: ``source`` is + None, or ``source.kind == "key"`` with an empty ``key``. These are + caller-constructed inputs, not runtime failures. + """ + if source is None: + return None + if source.kind == "key" and source.key: + return source.key + + uploader = driver.upload_image if kind == "image" else driver.upload_file + if uploader is None: + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"media uploader missing on driver; cannot send {kind}", + context={"kind": kind}, + ) + + # Propagate an explicit allowlist onto the source, if the caller supplied + # one AND the source itself does not already set one. This lets the + # outbound config's default allowlist apply to every URL download. + if ssrf_allowlist and getattr(source, "_ssrf_allowlist", None) is None: + try: + source._ssrf_allowlist = list(ssrf_allowlist) # type: ignore[attr-defined] + except Exception: # pragma: no cover - frozen dataclass + pass + + # ``gather_buffer`` raises FeishuChannelError(SSRF_BLOCKED) for URL + # sources without an allowlist; let that propagate. For buffer / file + # sources that just fail to read, it still returns (None, default) — + # we map that to UPLOAD_FAILED so the caller doesn't get "empty body". + buffer, fname = await gather_buffer(source, default_name=file_name or "upload") + if buffer is None: + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"could not gather bytes for {kind} (source kind={source.kind!r})", + context={"kind": kind, "source_kind": source.kind}, + ) + + kwargs: Dict[str, Any] = {"data": buffer, "file_name": fname} + if file_type: + kwargs["file_type"] = file_type + try: + raw = await _maybe_await(uploader(**kwargs)) + except FeishuChannelError: + # Already typed — don't double-wrap. + raise + except Exception as e: + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"{kind} upload transport error: {e}", + context={"kind": kind}, + ) from e + resp = _unwrap(raw) + if resp.get("code") != 0: + raw_code = resp.get("code") + raw_msg = resp.get("msg") or "" + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"{kind} upload rejected by server: code={raw_code} msg={raw_msg}", + context={"kind": kind, "raw_code": raw_code, "raw_msg": raw_msg}, + ) + data = resp.get("data") or {} + key = data.get("image_key") or data.get("file_key") + if not key: + # Response came back with code=0 but no key — malformed server + # response or mismatched endpoint. Surface instead of swallowing. + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"{kind} upload succeeded (code=0) but response missing image_key / file_key", + context={"kind": kind, "data_keys": list(data.keys())}, + ) + return key + + +async def gather_buffer( + source: MediaSource, default_name: str +) -> Tuple[Optional[bytes], str]: + """Collect a :class:`MediaSource`'s bytes + filename. + + File reads are off-loaded to a worker thread so a large attachment does + not block the event loop. URL downloads stream with a byte cap to bound + memory use and hard-require an allowlist to contain SSRF blast radius — + see the module-level policy note and :func:`.ssrf_guard.assert_public_url`. + """ + if source.kind == "buffer" and source.buffer is not None: + return source.buffer, default_name + if source.kind == "file" and source.path: + try: + path_str = source.path + loop = asyncio.get_running_loop() + data = await loop.run_in_executor( + None, lambda: Path(path_str).read_bytes() + ) + return data, os.path.basename(path_str) or default_name + except OSError as e: + # Preserve the concrete OSError reason (FileNotFoundError, + # PermissionError, IsADirectoryError, ...) via ``from e`` and + # surface it as a typed ``UPLOAD_FAILED`` so callers keep the + # concrete diagnostic instead of seeing a generic send failure. + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"could not read local file {source.path!r}: {e}", + context={"path": source.path, "source_kind": "file"}, + ) from e + if source.kind == "url" and source.url: + safe_url = redact_url_for_log(source.url) + # SSRF guard is mandatory for URL downloads unless an explicit + # hostname allowlist is provided by the caller. The guard protects + # against DNS-resolved private / loopback / metadata IPs, but it + # *cannot* close the TOCTOU window between DNS check and HTTP connect + # when hostname DNS is attacker-controlled (see ssrf_guard module + # docstring). An allowlist of trusted hostnames is the only way to + # make URL uploads safe in hostile-input environments. + allowlist = getattr(source, "_ssrf_allowlist", None) + ssrf = getattr(source, "_ssrf_guard", True) + if not ssrf: + logger.warning( + "outbound: SSRF guard explicitly disabled for %s — this is " + "only safe when the URL is fully trusted", + safe_url, + ) + elif not allowlist: + # No allowlist configured → hard stop. Raise a typed error so + # the caller sees ``code=SSRF_BLOCKED`` and can surface that to + # the user; silently returning ``(None, name)`` would be + # ambiguous with a transient network failure. + raise FeishuChannelError( + FeishuChannelErrorCode.SSRF_BLOCKED, + ( + f"refusing URL download for {safe_url} — no SSRF " + "allowlist configured; set " + "OutboundConfig.ssrf_allowlist to trusted hostnames or " + "use kind='buffer'/'file' instead" + ), + context={"url": safe_url}, + ) + else: + # Let assert_public_url's FeishuChannelError propagate up as + # the typed SSRF_BLOCKED error. The earlier swallow-to-None + # made the block indistinguishable from a download failure. + await assert_public_url(source.url, allowlist=allowlist) + + cap = int(getattr(source, "_url_download_cap", _URL_DOWNLOAD_DEFAULT_CAP)) + try: + import httpx # type: ignore + + async with httpx.AsyncClient( + timeout=30, follow_redirects=False + ) as client: + async with client.stream("GET", source.url) as r: + r.raise_for_status() + # Short-circuit when Content-Length already exceeds cap. + cl = r.headers.get("content-length") + if cl is not None: + try: + if int(cl) > cap: + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + ( + f"URL download refused — response " + f"content-length={cl} exceeds cap " + f"({cap} bytes)" + ), + context={ + "url": safe_url, + "content_length": cl, + "cap": cap, + }, + ) + except ValueError: + pass + chunks: List[bytes] = [] + total = 0 + async for chunk in r.aiter_bytes(): + total += len(chunk) + if total > cap: + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + ( + f"URL download exceeded cap mid-stream " + f"({total} > {cap} bytes)" + ), + context={ + "url": safe_url, + "bytes_read": total, + "cap": cap, + }, + ) + chunks.append(chunk) + name = filename_from_url(source.url, default_name) + return b"".join(chunks), name + except FeishuChannelError: + # Already typed (either our own UPLOAD_FAILED cap violation or a + # SSRF_BLOCKED from assert_public_url running inside httpx). + raise + except Exception as e: + # Network / TLS / DNS / HTTP-status failures — preserve the + # concrete exception via ``from e`` instead of losing it. + raise FeishuChannelError( + FeishuChannelErrorCode.UPLOAD_FAILED, + f"URL download of {safe_url} failed: {e}", + context={"url": safe_url, "source_kind": "url"}, + ) from e + # Fallthrough: source.kind wasn't one of buffer/file/url, or required + # attribute missing (e.g. kind="file" with path=None). Caller-constructed + # input, not a runtime failure — keep the None-None return contract. + return None, default_name + + +def filename_from_url(url: str, default: str) -> str: + try: + from urllib.parse import urlparse + + p = urlparse(url).path or "" + base = os.path.basename(p) + # Defense in depth: strip NUL and any path-traversal artefacts from the + # attacker-controlled URL path before the name flows into the uploader. + base = base.replace("\x00", "").replace("/", "").replace("\\", "") + if len(base) > 255: + base = base[-255:] + return base or default + except Exception: # pragma: no cover - defensive + return default + + +async def _maybe_await(v: Any) -> Any: + if inspect.isawaitable(v): + return await v + return v + + +def _unwrap(result: Any) -> Dict[str, Any]: + """Normalize a driver upload response to a plain dict shape.""" + if result is None: + return {"code": -1, "msg": "empty response"} + if isinstance(result, dict): + return result + code = getattr(result, "code", None) + if code is None: + return {"code": -1, "msg": "unknown response"} + data = getattr(result, "data", None) + out: Dict[str, Any] = {"code": code, "msg": getattr(result, "msg", "") or ""} + if data is not None: + if isinstance(data, dict): + out["data"] = data + else: + try: + out["data"] = { + k: getattr(data, k) + for k in dir(data) + if not k.startswith("_") and not callable(getattr(data, k)) + } + except Exception: # pragma: no cover - defensive + out["data"] = {} + return out diff --git a/lark_channel/channel/outbound/retry.py b/lark_channel/channel/outbound/retry.py new file mode 100644 index 0000000..c341517 --- /dev/null +++ b/lark_channel/channel/outbound/retry.py @@ -0,0 +1,94 @@ +"""Exponential-backoff retry for transient outbound failures. + +Defaults: + - 3 attempts total (1 initial + 2 retries) + - ``500ms * 3 ** attempt`` backoff (500ms, 1.5s, 4.5s ...), capped at + ``DEFAULT_MAX_DELAY_MS`` to avoid multi-minute waits under repeated + failure. + +If the ``SendError`` carries a ``retry_after_seconds`` value (populated from +an upstream ``Retry-After`` header on 429s), that value overrides the +computed backoff — honouring server-signalled rate limits rather than +hammering with a smaller client-side delay. We still cap the absolute wait +to ``DEFAULT_MAX_RETRY_AFTER_S`` so a misbehaving server can't stall a +caller for minutes. + +Only retries when the caller flags the last result as retryable; other +failures are returned immediately. +""" + +import asyncio +import random +from typing import Awaitable, Callable, Optional + +from ..errors import SendError, is_retryable +from ..types import SendResult + +DEFAULT_MAX_ATTEMPTS = 3 +DEFAULT_BASE_DELAY_MS = 500 +DEFAULT_MAX_DELAY_MS = 30_000 +DEFAULT_MAX_RETRY_AFTER_S = 60.0 + + +async def with_retry( + op: Callable[[int], Awaitable[SendResult]], + *, + max_attempts: int = DEFAULT_MAX_ATTEMPTS, + base_delay_ms: int = DEFAULT_BASE_DELAY_MS, + max_delay_ms: int = DEFAULT_MAX_DELAY_MS, + jitter: bool = True, +) -> SendResult: + """Call `op(attempt)` up to `max_attempts` times with backoff. + + `op` is expected to return a `SendResult`. If `result.success` is False + and the error is retryable (see `errors.is_retryable`), we back off and + try again; otherwise we short-circuit. + """ + last: Optional[SendResult] = None + for attempt in range(max_attempts): + result = await op(attempt) + if result.success: + return result + last = result + if result.error is None or not _should_retry(result.error): + return result + # Not the last attempt → sleep then retry + if attempt >= max_attempts - 1: + return result + delay = _compute_delay( + result.error, attempt, + base_delay_ms=base_delay_ms, + max_delay_ms=max_delay_ms, + jitter=jitter, + ) + await asyncio.sleep(delay) + if last is None: + raise RuntimeError("with_retry: no attempt was made (max_attempts<=0?)") + return last + + +def _compute_delay( + err: SendError, attempt: int, *, + base_delay_ms: int, max_delay_ms: int, jitter: bool, +) -> float: + """Compute seconds to sleep before the next retry. + + Server-signalled ``retry_after_seconds`` wins when present; otherwise + fall back to exponential backoff with optional jitter, capped at + ``max_delay_ms``. + """ + if err.retry_after_seconds is not None and err.retry_after_seconds > 0: + # Bound to a sane ceiling so a broken upstream can't stall the + # caller for multi-minute waits. + return min(float(err.retry_after_seconds), DEFAULT_MAX_RETRY_AFTER_S) + delay_ms = min(base_delay_ms * (3 ** attempt), max_delay_ms) + delay = delay_ms / 1000.0 + if jitter: + delay *= 0.7 + 0.6 * random.random() + return delay + + +def _should_retry(err: SendError) -> bool: + # Honor both the `retryable` flag set by `classify_error` (legacy) and + # the aligned error-code predicate. + return bool(err.retryable or is_retryable(err.code)) diff --git a/lark_channel/channel/outbound/routing.py b/lark_channel/channel/outbound/routing.py new file mode 100644 index 0000000..7dfdef4 --- /dev/null +++ b/lark_channel/channel/outbound/routing.py @@ -0,0 +1,25 @@ +"""Infer `receive_id_type` for Lark's `POST /im/v1/messages` endpoint. + +Lark requires you to say whether `receive_id` is an open_id, chat_id, user_id, +union_id, or email. In channel use cases the id format is usually obvious from +the prefix, so we auto-detect. +""" + +from typing import Literal + +ReceiveIdType = Literal["open_id", "chat_id", "user_id", "union_id", "email"] + + +def infer_receive_id_type(receive_id: str) -> ReceiveIdType: + if not receive_id: + return "chat_id" + if "@" in receive_id: + return "email" + if receive_id.startswith("ou_"): + return "open_id" + if receive_id.startswith("oc_"): + return "chat_id" + if receive_id.startswith("on_"): + return "union_id" + # Anything else is likely a user_id or custom identifier. + return "user_id" diff --git a/lark_channel/channel/outbound/sender.py b/lark_channel/channel/outbound/sender.py new file mode 100644 index 0000000..f4ef5ea --- /dev/null +++ b/lark_channel/channel/outbound/sender.py @@ -0,0 +1,745 @@ +"""Outbound sender: translates OutboundMessage → Lark SDK calls. + +The sender is deliberately I/O-light: it composes the right request body and +delegates to a caller-supplied :class:`SendDriver`. In production the driver +is backed by ``lark_channel.Client``; tests inject fakes. + +Media-upload concerns (resolving a :class:`MediaSource` into a Lark +``file_key``) live in :mod:`.media.uploader`. +""" + +import inspect +import json +import uuid +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union + +from lark_channel.core.log import logger + +from ..config import OutboundConfig +from ..errors import ( + FeishuChannelError, + FeishuChannelErrorCode, + SendError, + classify_error, + is_format_error, + is_reply_target_gone, +) +from .retry import with_retry +from .media.uploader import resolve_media_key +from ..types import ( + OutboundAudio, + OutboundCard, + OutboundFile, + OutboundImage, + OutboundMessage, + OutboundPost, + OutboundShareChat, + OutboundShareUser, + OutboundSticker, + OutboundText, + OutboundVideo, + SendResult, +) +from .markdown import markdown_to_post_ast, split_with_code_fences +from .routing import infer_receive_id_type + +# ---------------------------------------------------------------------------- +# Plain-text chunking — kept local to the sender (node-aligned). Only the +# markdown-aware `split_with_code_fences` lives in :mod:`.markdown.splitter`. +# ---------------------------------------------------------------------------- + +_ChunkMode = str # "newline" | "paragraph" | "none" + + +def chunk_text(text: str, limit: int = 3500, mode: _ChunkMode = "newline") -> list: + """Split ``text`` into ordered chunks of <= ``limit`` chars. + + - ``newline``: prefers breaking at the last ``\\n`` within the window. + - ``paragraph``: prefers breaking at blank-line boundaries. + - ``none``: hard slice at ``limit`` chars. + """ + if not text: + return [] + if limit <= 0: + return [text] + if len(text) <= limit: + return [text] + if mode == "none": + return [text[i : i + limit] for i in range(0, len(text), limit)] + if mode == "paragraph": + return _chunk_by_delim(text, limit, delim="\n\n") + return _chunk_by_delim(text, limit, delim="\n") + + +def _chunk_by_delim(text: str, limit: int, delim: str) -> list: + chunks = [] + i = 0 + n = len(text) + delim_len = len(delim) + while i < n: + if n - i <= limit: + chunks.append(text[i:]) + break + window = text[i : i + limit] + idx = window.rfind(delim) + if idx <= 0: + chunks.append(window) + i += limit + else: + chunks.append(window[:idx]) + i += idx + delim_len + return [c for c in chunks if c] + + +# ---------------------------------------------------------------------------- +# Driver protocol — the thin seam between the sender and the Lark HTTP client. +# Each method takes a dict "body" payload + keyword args and returns a dict +# with at least {code, msg, data?}. Async or sync callables are both allowed. +# ---------------------------------------------------------------------------- + +SendFn = Callable[..., Union[Dict[str, Any], Awaitable[Dict[str, Any]]]] + + +@dataclass +class SendDriver: + create_message: SendFn + reply_message: SendFn + patch_message: Optional[SendFn] = None + delete_message: Optional[SendFn] = None + forward_message: Optional[SendFn] = None + upload_image: Optional[SendFn] = None + upload_file: Optional[SendFn] = None + + +class _OversizeHookFailure(Exception): + """Internal: marker for an OutboundConfig.on_oversize hook exception so + the broad ``except Exception`` in send() can re-raise the original + instead of swallowing into SendResult.fail(UNKNOWN). Hook exceptions + propagate to the caller without silent fallback. + """ + + def __init__(self, original: BaseException): + self.original = original + super().__init__(str(original)) + + +class _UnsupportedMediaCaption(Exception): + """Internal: media caption shape is not supported by Lark post messages.""" + + def __init__(self, media_kind: str): + self.media_kind = media_kind + super().__init__( + f"{media_kind} caption is not supported by Lark post messages; " + "send the caption as a separate message if two-message semantics are acceptable" + ) + + +async def _maybe_await(v: Any) -> Any: + if inspect.isawaitable(v): + return await v + return v + + +def _unwrap(result: Any) -> Dict[str, Any]: + """Normalize driver output to a plain dict shape.""" + if result is None: + return {"code": -1, "msg": "empty response"} + if isinstance(result, dict): + return result + # If it's an object from the SDK with .code / .msg / .data, fall back. + code = getattr(result, "code", None) + if code is None: + return {"code": -1, "msg": "unknown response"} + data = getattr(result, "data", None) + out = {"code": code, "msg": getattr(result, "msg", "") or ""} + if data is not None: + # data may be a model object; try to coerce to dict. + if isinstance(data, dict): + out["data"] = data + else: + try: + out["data"] = { + k: getattr(data, k) + for k in dir(data) + if not k.startswith("_") and not callable(getattr(data, k)) + } + except Exception: # pragma: no cover - defensive + out["data"] = {} + return out + + +def _extract_message_id(resp: Dict[str, Any]) -> Optional[str]: + data = resp.get("data") or {} + if not isinstance(data, dict): + return None + return data.get("message_id") or data.get("id") + + +# ---------------------------------------------------------------------------- +# Content builders — turn an OutboundMessage into a `msg_type` + content JSON. +# ---------------------------------------------------------------------------- + + +def _build_text(msg: OutboundText) -> Dict[str, str]: + # Inject tags for mentioned identities so they get notified. + at_prefix = "" + for ident in msg.mentions or []: + if ident and ident.open_id: + name = ident.display_name or "" + at_prefix += f'{name} ' + content = at_prefix + (msg.text or "") + return {"msg_type": "text", "content": json.dumps({"text": content}, ensure_ascii=False)} + + +def _build_post( + msg: OutboundPost, + table_mode: str = "off", + tag_md_mode: str = "structured", +) -> Dict[str, str]: + """Build a Feishu post-message body. + + Feishu's ``im.v1.message.create`` expects ``content`` for ``msg_type=post`` + to be the JSON-serialised locale map directly (``{zh_cn: {title, content}}``) + — NO outer ``{"post": ...}`` wrapper. Wrapping it yields server error + ``230001 invalid message content``. Node SDK verified at sender.ts uses + the same unwrapped shape. + """ + if msg.post is not None: + post = msg.post + elif msg.markdown is not None: + post = markdown_to_post_ast( + msg.markdown, + title=msg.title or "", + mentions=list(msg.mentions or []), + table_mode=table_mode, + tag_md_mode=tag_md_mode, + ) + else: + post = {"zh_cn": {"title": msg.title or "", "content": [[{"tag": "text", "text": ""}]]}} + return {"msg_type": "post", "content": json.dumps(post, ensure_ascii=False)} + + +def _build_media_caption_post( + *, + caption: str, + media_node: Dict[str, Any], + table_mode: str = "off", + tag_md_mode: str = "structured", +) -> Dict[str, str]: + post = markdown_to_post_ast( + caption, + title="", + table_mode=table_mode, + tag_md_mode=tag_md_mode, + ) + zh = post.setdefault("zh_cn", {"title": "", "content": []}) + rows = zh.setdefault("content", []) + rows.append([media_node]) + return {"msg_type": "post", "content": json.dumps(post, ensure_ascii=False)} + + +def _build_card(msg: OutboundCard) -> Dict[str, str]: + if msg.card_id: + content = {"type": "card", "data": {"card_id": msg.card_id}} + else: + content = msg.card + return {"msg_type": "interactive", "content": json.dumps(content, ensure_ascii=False)} + + +def _build_image(image_key: str) -> Dict[str, str]: + return {"msg_type": "image", "content": json.dumps({"image_key": image_key}, ensure_ascii=False)} + + +def _build_file(file_key: str) -> Dict[str, str]: + return {"msg_type": "file", "content": json.dumps({"file_key": file_key}, ensure_ascii=False)} + + +def _build_audio(file_key: str) -> Dict[str, str]: + return {"msg_type": "audio", "content": json.dumps({"file_key": file_key}, ensure_ascii=False)} + + +def _build_video(file_key: str) -> Dict[str, str]: + return {"msg_type": "media", "content": json.dumps({"file_key": file_key}, ensure_ascii=False)} + + +def _build_share_chat(chat_id: str) -> Dict[str, str]: + return { + "msg_type": "share_chat", + "content": json.dumps({"chat_id": chat_id}, ensure_ascii=False), + } + + +def _build_share_user(user_id: str) -> Dict[str, str]: + return { + "msg_type": "share_user", + "content": json.dumps({"user_id": user_id}, ensure_ascii=False), + } + + +def _build_sticker(file_key: str) -> Dict[str, str]: + return { + "msg_type": "sticker", + "content": json.dumps({"file_key": file_key}, ensure_ascii=False), + } + + +# ---------------------------------------------------------------------------- +# Sender +# ---------------------------------------------------------------------------- + + +class OutboundSender: + def __init__( + self, + driver: SendDriver, + config: Optional[OutboundConfig] = None, + *, + on_success: Optional[Callable[[str], None]] = None, + ) -> None: + self._driver = driver + self._config = config or OutboundConfig() + # Optional callback invoked with the fresh message_id after each + # successful send — used by the channel to track own messages for the + # reaction 'own' filter. + self._on_success: Optional[Callable[[str], None]] = on_success + # Retry / SSRF knobs are read from ``self._config.retry`` and + # ``self._config.ssrf_allowlist`` at call time (see ``with_retry`` + # invocations below and ``resolve_media_key`` call sites). + + def _markdown_modes(self) -> Tuple[str, str]: + conv = self._config.markdown_converter + if getattr(conv, "enabled", True): + return conv.table_mode, getattr(conv, "tag_md_mode", "structured") + return "off", "structured" + + async def materialize_for_edit(self, msg: OutboundMessage) -> Dict[str, str]: + """Materialize exactly one text/post body for message editing. + + Unlike send materialization, this method does not chunk, does not call + the oversize hook, and does not apply reply or receive-id semantics. + """ + if isinstance(msg, OutboundText): + return _build_text(msg) + if isinstance(msg, OutboundPost): + table_mode, tag_md_mode = self._markdown_modes() + return _build_post(msg, table_mode=table_mode, tag_md_mode=tag_md_mode) + raise TypeError( + f"materialize_for_edit: unsupported message type {type(msg).__name__}; " + "only OutboundText and OutboundPost are editable" + ) + + async def send( + self, + message: OutboundMessage, + *, + receive_id: Optional[str] = None, + receive_id_type: Optional[str] = None, + reply_to: Optional[str] = None, + reply_in_thread: Optional[bool] = None, + reply_target_gone: str = "fresh", + uuid_: Optional[str] = None, + ) -> SendResult: + """Route a single OutboundMessage through the driver. + + Caller supplies either `receive_id` or `reply_to`. If both are given, + reply wins — we use `/im/v1/messages/:id/reply`. + """ + try: + body_list = await self._materialize( + message, + chat_id=receive_id or "", + receive_id_type=receive_id_type or "", + ) + except _OversizeHookFailure as f: + # Hook exceptions propagate to the caller without fallback. + raise f.original from f.original + except _UnsupportedMediaCaption as e: + logger.warning("outbound: unsupported media caption: %s", e) + return SendResult.fail(SendError( + code=FeishuChannelErrorCode.FORMAT_ERROR, + retryable=False, + hint=str(e), + )) + except FeishuChannelError as e: + # Preserve the typed error code (SSRF_BLOCKED, UPLOAD_FAILED, …) + # from the uploader / ssrf guard; wrapping into UNKNOWN loses + # the signal callers want to match on. + logger.warning("outbound: materialize blocked: %s", e) + return SendResult.fail(SendError( + code=e.code, retryable=False, hint=str(e), + )) + except Exception as e: + logger.exception("outbound: materialize failed: %s", e) + return SendResult.fail(SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=False, hint=str(e))) + + if not body_list: + return SendResult.fail(SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=False, hint="empty body")) + + # Collect every successful chunk's message_id. For single-chunk + # messages we return the familiar ``SendResult.ok(message_id=...)``. + # For multi-chunk (long markdown / post → multiple POST /messages + # requests), we also populate ``chunk_ids`` so callers can observe + # that the logical message was split. Node-sdk aligned. + chunk_message_ids: List[str] = [] + last_result: SendResult = SendResult.fail(SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=False)) + for idx, body in enumerate(body_list): + req_uuid = uuid_ if (idx == 0 and uuid_) else str(uuid.uuid4()) + # Only apply `reply_to` to the first chunk; subsequent chunks are + # fresh messages so they all render in the original chat. + effective_reply_to = reply_to if idx == 0 else None + result = await self._send_one_with_fallback( + body=body, + receive_id=receive_id, + receive_id_type=receive_id_type, + reply_to=effective_reply_to, + reply_in_thread=reply_in_thread, + reply_target_gone=reply_target_gone, + uuid_=req_uuid, + ) + last_result = result + if not result.success: + return result + if result.message_id: + chunk_message_ids.append(result.message_id) + # Augment the final success result with chunk_ids when >1 chunk. + if len(chunk_message_ids) > 1: + return SendResult.ok( + message_id=chunk_message_ids[0], + raw=last_result.raw, + chunk_ids=list(chunk_message_ids), + ) + return last_result + + async def _send_one_with_fallback( + self, + *, + body: Dict[str, str], + receive_id: Optional[str], + receive_id_type: Optional[str], + reply_to: Optional[str], + reply_in_thread: Optional[bool], + reply_target_gone: str, + uuid_: str, + ) -> SendResult: + """One send attempt with retry + two graceful downgrades. + + - `target_revoked` when replying → retry as a fresh create. + - `format_error` on `post` → downgrade to plain text. + """ + async def attempt(_: int) -> SendResult: + if reply_to: + return await self._reply(reply_to, body, reply_in_thread, uuid_) + rid = receive_id or "" + rit = receive_id_type or infer_receive_id_type(rid) + return await self._create(rid, rit, body, uuid_) + + result = await with_retry( + attempt, + max_attempts=self._config.retry.max_attempts, + base_delay_ms=self._config.retry.base_delay_ms, + ) + if result.success or result.error is None: + return result + + err = result.error + # Downgrade 1: reply target gone → fresh send + if is_reply_target_gone(err.code) and reply_to: + if reply_target_gone == "fail": + return result + logger.info("outbound: reply target gone, retrying as fresh message") + async def fresh(_: int) -> SendResult: + rid = receive_id or "" + rit = receive_id_type or infer_receive_id_type(rid) + return await self._create(rid, rit, body, uuid_) + return await with_retry(fresh, max_attempts=self._config.retry.max_attempts, + base_delay_ms=self._config.retry.base_delay_ms) + + # Downgrade 2: post rejected → fallback to plain text + if is_format_error(err.code) and body.get("msg_type") == "post": + logger.info("outbound: post format rejected, falling back to plain text") + plain = _post_to_plain_text_from_body(body.get("content", "")) + if plain: + text_body = {"msg_type": "text", "content": json.dumps({"text": plain}, ensure_ascii=False)} + async def fallback(_: int) -> SendResult: + if reply_to: + return await self._reply(reply_to, text_body, reply_in_thread, uuid_) + rid = receive_id or "" + rit = receive_id_type or infer_receive_id_type(rid) + return await self._create(rid, rit, text_body, uuid_) + return await with_retry(fallback, max_attempts=self._config.retry.max_attempts, + base_delay_ms=self._config.retry.base_delay_ms) + + return result + + async def _materialize( + self, + msg: OutboundMessage, + *, + chat_id: str = "", + receive_id_type: str = "", + ) -> List[Dict[str, str]]: + if isinstance(msg, OutboundText): + text = msg.text or "" + replacement = await self._maybe_oversize_hook( + text, chat_id=chat_id, receive_id_type=receive_id_type, + ) + if replacement: + replaced = OutboundText(text=replacement, mentions=msg.mentions) + return [_build_text(replaced)] + + # Chunking for long text + chunks = chunk_text( + text, + limit=self._config.text_chunk_limit, + mode=self._config.chunk_mode, + ) or [""] + out: List[Dict[str, str]] = [] + for i, chunk in enumerate(chunks): + part = OutboundText( + text=chunk, + mentions=msg.mentions if i == 0 else [], + ) + out.append(_build_text(part)) + return out + if isinstance(msg, OutboundPost): + table_mode, tag_md_mode = self._markdown_modes() + # Markdown-sourced posts get split (code-fence aware) when the raw + # markdown exceeds the configured chunk limit; each chunk becomes + # its own post body. Pre-built ``msg.post`` ASTs are sent as-is — + # they're opaque to us. Mentions go only on the first chunk so + # ``@user`` doesn't fire again on every fragment. + if msg.markdown is not None and len(msg.markdown) > self._config.text_chunk_limit: + replacement = await self._maybe_oversize_hook( + msg.markdown, chat_id=chat_id, receive_id_type=receive_id_type, + ) + if replacement: + replaced = OutboundText(text=replacement, mentions=msg.mentions) + return [_build_text(replaced)] + md_chunks = split_with_code_fences( + msg.markdown, self._config.text_chunk_limit, + ) + out: List[Dict[str, str]] = [] + for i, chunk in enumerate(md_chunks): + part = OutboundPost( + markdown=chunk, + title=msg.title if i == 0 else "", + mentions=msg.mentions if i == 0 else [], + ) + out.append(_build_post(part, table_mode=table_mode, tag_md_mode=tag_md_mode)) + return out + return [_build_post(msg, table_mode=table_mode, tag_md_mode=tag_md_mode)] + if isinstance(msg, OutboundCard): + return [_build_card(msg)] + allowlist = self._config.ssrf_allowlist + if isinstance(msg, OutboundImage): + key = await resolve_media_key( + self._driver, msg.source, "image", ssrf_allowlist=allowlist + ) + if not key: + return [] + if msg.caption: + table_mode, tag_md_mode = self._markdown_modes() + return [_build_media_caption_post( + caption=msg.caption, + media_node={"tag": "img", "image_key": key}, + table_mode=table_mode, + tag_md_mode=tag_md_mode, + )] + return [_build_image(key)] + if isinstance(msg, OutboundFile): + if msg.caption: + raise _UnsupportedMediaCaption("file") + key = await resolve_media_key( + self._driver, msg.source, "file", + file_name=msg.file_name, ssrf_allowlist=allowlist, + ) + if not key: + return [] + return [_build_file(key)] + if isinstance(msg, OutboundAudio): + if msg.caption: + raise _UnsupportedMediaCaption("audio") + key = await resolve_media_key( + self._driver, msg.source, "file", + file_type="opus", ssrf_allowlist=allowlist, + ) + return [_build_audio(key)] if key else [] + if isinstance(msg, OutboundVideo): + key = await resolve_media_key( + self._driver, msg.source, "file", + file_type="mp4", ssrf_allowlist=allowlist, + ) + if msg.caption and key: + table_mode, tag_md_mode = self._markdown_modes() + return [_build_media_caption_post( + caption=msg.caption, + media_node={"tag": "media", "file_key": key}, + table_mode=table_mode, + tag_md_mode=tag_md_mode, + )] + return [_build_video(key)] if key else [] + if isinstance(msg, OutboundShareChat): + return [_build_share_chat(msg.chat_id)] if msg.chat_id else [] + if isinstance(msg, OutboundShareUser): + return [_build_share_user(msg.user_id)] if msg.user_id else [] + if isinstance(msg, OutboundSticker): + return [_build_sticker(msg.file_key)] if msg.file_key else [] + return [] + + async def _maybe_oversize_hook( + self, text: str, *, chat_id: str, receive_id_type: str, + ) -> Optional[str]: + """Return non-empty replacement when the hook supplies one. + + - No hook configured -> None (caller chunks normally). + - text within limit -> None (no oversize). + - hook returns None / empty -> None (fallback). + - hook returns non-empty str -> that string (single send). + - hook raises -> wrapped in _OversizeHookFailure so the caller's + broad `except Exception` doesn't accidentally swallow it; send() + unwraps and re-raises the original. + """ + hook = self._config.on_oversize + if hook is None: + return None + if len(text) <= self._config.text_chunk_limit: + return None + from ..config import OversizeContext + estimated = max( + 1, + (len(text) + self._config.text_chunk_limit - 1) // self._config.text_chunk_limit, + ) + ctx = OversizeContext( + text=text, + chat_id=chat_id, + receive_id_type=receive_id_type, + estimated_chunks=estimated, + ) + try: + result = await hook(ctx) + except Exception as e: + raise _OversizeHookFailure(e) from e + if result: + return result + return None + + async def _create( + self, + receive_id: str, + receive_id_type: str, + body: Dict[str, str], + uuid_: str, + ) -> SendResult: + try: + raw = await _maybe_await( + self._driver.create_message( + receive_id_type=receive_id_type, + receive_id=receive_id, + msg_type=body["msg_type"], + content=body["content"], + uuid=uuid_, + ) + ) + except Exception as e: + logger.exception("outbound: create_message raised: %s", e) + return SendResult.fail(SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=True, hint=str(e))) + result = self._to_result(raw) + if not result.success: + content_len = len(body.get("content", "") or "") + logger.warning( + "outbound: create_message FAILED receive_id_type=%s receive_id=%s msg_type=%s " + "request_content_len=%s response=%s", + receive_id_type, receive_id, body.get("msg_type"), + content_len, + result.raw, + ) + return result + + async def _reply( + self, + message_id: str, + body: Dict[str, str], + reply_in_thread: Optional[bool], + uuid_: str, + ) -> SendResult: + try: + kwargs: Dict[str, Any] = { + "message_id": message_id, + "msg_type": body["msg_type"], + "content": body["content"], + "uuid": uuid_, + } + if reply_in_thread is not None: + kwargs["reply_in_thread"] = reply_in_thread + raw = await _maybe_await(self._driver.reply_message(**kwargs)) + except Exception as e: + logger.exception("outbound: reply_message raised: %s", e) + return SendResult.fail(SendError(code=FeishuChannelErrorCode.UNKNOWN, retryable=True, hint=str(e))) + result = self._to_result(raw) + if not result.success: + content_len = len(body.get("content", "") or "") + logger.warning( + "outbound: reply_message FAILED message_id=%s msg_type=%s " + "request_content_len=%s response=%s", + message_id, body.get("msg_type"), + content_len, + result.raw, + ) + return result + + def _to_result(self, raw: Any) -> SendResult: + resp = _unwrap(raw) + code = resp.get("code") or 0 + if code == 0: + mid = _extract_message_id(resp) + if mid and self._on_success is not None: + try: + self._on_success(mid) + except Exception as e: # pragma: no cover - defensive + logger.debug("outbound: _on_success hook raised: %s", e) + return SendResult.ok(message_id=mid, raw=resp) + return SendResult.fail( + classify_error(int(code), resp.get("msg") or ""), + raw=resp, + ) + + +def _post_to_plain_text_from_body(content: str) -> str: + """Extract a best-effort plain-text rendering from a post JSON body. + + ``content`` is the already-serialised post body emitted by + :func:`_build_post`, i.e. ``{zh_cn: {title, content: [[...]]}}`` — NOT + wrapped in an outer ``{"post": ...}``. Used by the format_error → plain- + text downgrade path. Returns empty string on any structural mismatch. + """ + try: + data = json.loads(content) + if not isinstance(data, dict) or not data: + return "" + locale = next(iter(data.values())) + if not isinstance(locale, dict): + return "" + out_lines: List[str] = [] + title = locale.get("title") + if title: + out_lines.append(str(title)) + for para in locale.get("content") or []: + pieces: List[str] = [] + for el in para or []: + if not isinstance(el, dict): + continue + if el.get("tag") == "text": + pieces.append(el.get("text") or "") + elif el.get("tag") == "a": + pieces.append(el.get("text") or el.get("href") or "") + elif el.get("tag") == "at": + pieces.append(f"@{el.get('user_name') or el.get('user_id') or ''}") + elif el.get("tag") == "img": + pieces.append("[image]") + elif el.get("tag") == "code_block": + pieces.append(el.get("text") or "") + out_lines.append("".join(pieces)) + return "\n".join(l for l in out_lines if l is not None).strip() or "[message]" + except Exception: + return "" diff --git a/lark_channel/channel/outbound/streaming/__init__.py b/lark_channel/channel/outbound/streaming/__init__.py new file mode 100644 index 0000000..92b9c4e --- /dev/null +++ b/lark_channel/channel/outbound/streaming/__init__.py @@ -0,0 +1,7 @@ +"""Streaming primitives: throttle, update queue, and text-merge helpers.""" + +from .merge_text import merge_streaming_text +from .throttle import Throttle +from .update_queue import UpdateQueue + +__all__ = ["Throttle", "UpdateQueue", "merge_streaming_text"] diff --git a/lark_channel/channel/outbound/streaming/card_stream.py b/lark_channel/channel/outbound/streaming/card_stream.py new file mode 100644 index 0000000..353bb5d --- /dev/null +++ b/lark_channel/channel/outbound/streaming/card_stream.py @@ -0,0 +1,104 @@ +"""User-driven card streaming. + +The producer hands the controller a sequence of full card snapshots (or a +transform function). Unlike `MarkdownStreamController`, which manages content +string state, this variant gives the caller direct control over card JSON. + +Use case: complex cards with progress bars, timestamps, dynamic elements. +""" + +from typing import Any, Awaitable, Callable, Dict, Union + +from lark_channel.core.log import logger + +from .throttle import Throttle +from .update_queue import UpdateQueue + + +STREAM_TERMINATED_FOOTER_ELEMENT = { + "tag": "markdown", + "content": "— generation interrupted", +} + + +CardSnapshot = Union[Dict[str, Any], Callable[[Dict[str, Any]], Dict[str, Any]]] + + +class CardStreamController: + def __init__( + self, + *, + initial: Dict[str, Any], + ensure_created: Callable[[Dict[str, Any]], Awaitable[str]], + patch_card: Callable[[str, Dict[str, Any]], Awaitable[Any]], + min_ms: int = 100, + min_chars: int = 50, + ) -> None: + self._ensure_created = ensure_created + self._patch_card = patch_card + self._current: Dict[str, Any] = dict(initial) + self._message_id = "" + self._queue = UpdateQueue() + self._throttle = Throttle( + min_ms=min_ms, min_chars=min_chars, on_fire=self._on_fire, + ) + + @property + def message_id(self) -> str: + return self._message_id + + @property + def current(self) -> Dict[str, Any]: + return self._current + + async def update(self, next_card: CardSnapshot) -> None: + await self._ensure_started() + if callable(next_card): + self._current = next_card(self._current) + else: + self._current = dict(next_card) + # Approximate delta size for throttle — roll up serialized length + import json as _json + approx = len(_json.dumps(self._current, ensure_ascii=False)) + self._throttle.note(approx) + + async def run(self, producer: Callable[["CardStreamController"], Awaitable[None]]) -> str: + import asyncio as _asyncio + try: + await producer(self) + except _asyncio.CancelledError: + raise + except Exception as e: + logger.warning("card stream: producer raised: %s", e) + self._throttle.dispose() + # Add a terminated footer element + body = self._current.setdefault("body", {"elements": []}) + elements = body.setdefault("elements", []) + elements.append(STREAM_TERMINATED_FOOTER_ELEMENT) + await self._flush_final() + raise + self._throttle.flush_now() + await self._queue.drain() + return self._message_id + + # ---- internals ----------------------------------------------------------- + async def _ensure_started(self) -> None: + if self._message_id: + return + self._message_id = await self._ensure_created(self._current) + + def _on_fire(self) -> None: + if not self._message_id: + return + snapshot = dict(self._current) + self._queue.enqueue(lambda: self._patch_card(self._message_id, snapshot)) + + async def _flush_final(self) -> None: + if not self._message_id: + try: + self._message_id = await self._ensure_created(self._current) + except Exception: + return + snapshot = dict(self._current) + self._queue.enqueue(lambda: self._patch_card(self._message_id, snapshot)) + await self._queue.drain() diff --git a/lark_channel/channel/outbound/streaming/markdown_stream.py b/lark_channel/channel/outbound/streaming/markdown_stream.py new file mode 100644 index 0000000..ff72570 --- /dev/null +++ b/lark_channel/channel/outbound/streaming/markdown_stream.py @@ -0,0 +1,213 @@ +"""Markdown-centric streaming controller. + +Pre-allocates a CardKit card with a single markdown element, then ticks the +element's content forward via sequenced ``update_card_element_content`` calls +so out-of-order HTTP delivery cannot rewind visible content. On producer error +the controller appends a terminated-footer, performs one final element update, +drains the queue, calls ``finish_streaming_card``, and re-raises. + +Distinct from :class:`CardStreamController` (same directory), which accepts a +full card JSON per tick and backs that with generic ``patch_card``. +""" + +from typing import Any, Awaitable, Callable, Dict, Optional + +from lark_channel.core.log import logger + +from .merge_text import merge_streaming_text +from .throttle import Throttle +from .update_queue import UpdateQueue + + +ELEMENT_ID = "stream_md" +INITIAL_TEXT = "Thinking..." +TERMINATED_FOOTER = "\n\n— _(generation interrupted)_" + + +# Public type aliases for the 4 cardkit dependencies the controller needs. +CreateCardInstance = Callable[[Dict[str, Any]], Awaitable[str]] +SendCardByReference = Callable[..., Awaitable[Any]] +UpdateCardElementContent = Callable[[str, str, str, int], Awaitable[None]] +FinishStreamingCard = Callable[[str, int], Awaitable[None]] + + +class MarkdownStreamController: + """User-facing controller passed to the producer closure. + + ``append()`` / ``set_content()`` are the only two methods the producer + calls. Everything else is plumbing invoked from :class:`FeishuChannel`. + """ + + def __init__( + self, + *, + to: str, + receive_id_type: str, + reply_to: Optional[str], + reply_in_thread: Optional[bool], + create_card_instance: CreateCardInstance, + send_card_by_reference: SendCardByReference, + update_card_element_content: UpdateCardElementContent, + finish_streaming_card: FinishStreamingCard, + reply_target_gone: str = "fresh", + min_ms: int = 100, + min_chars: int = 50, + initial_text: str = INITIAL_TEXT, + element_id: str = ELEMENT_ID, + ) -> None: + self._to = to + self._rit = receive_id_type + self._reply_to = reply_to + self._reply_in_thread = reply_in_thread + self._reply_target_gone = reply_target_gone + self._create_card_instance = create_card_instance + self._send_card_by_reference = send_card_by_reference + self._update_card_element_content = update_card_element_content + self._finish_streaming_card = finish_streaming_card + self._initial_text = initial_text + self._element_id = element_id + + self._card_id: Optional[str] = None + self._message_id: str = "" + self._sequence: int = 0 + self._content: str = "" + + self._queue = UpdateQueue() + self._throttle = Throttle( + min_ms=min_ms, + min_chars=min_chars, + on_fire=self._on_fire, + ) + + @property + def message_id(self) -> str: + return self._message_id + + @property + def card_id(self) -> Optional[str]: + return self._card_id + + # ---- producer-facing API ------------------------------------------------- + async def append(self, chunk: str) -> None: + if not chunk: + return + await self._ensure_started() + new_content = merge_streaming_text(self._content, chunk) + delta = len(new_content) - len(self._content) + self._content = new_content + self._throttle.note(max(1, delta)) + + async def set_content(self, full: str) -> None: + await self._ensure_started() + self._content = full or "" + # Force immediate flush — mirrors node passing MAX_SAFE_INTEGER. + self._throttle.flush_now() + + async def run( + self, producer: Callable[["MarkdownStreamController"], Awaitable[None]] + ) -> str: + """Drive the producer; return the message_id. + + Success path: drain queue → ``finish_streaming_card``. + Error path: append footer → push one final update → drain → + ``finish_streaming_card`` → re-raise. + """ + import asyncio as _asyncio + + try: + await producer(self) + except _asyncio.CancelledError: + raise + except Exception as e: + logger.warning("markdown-stream: producer raised: %s", e) + await self._fail_terminal() + raise + await self._complete_terminal() + return self._message_id + + # ---- internals ----------------------------------------------------------- + async def _ensure_started(self) -> None: + if self._card_id: + return + spec = { + "schema": "2.0", + "config": {"streaming_mode": True, "summary": {"content": ""}}, + "body": { + "elements": [ + { + "tag": "markdown", + "element_id": self._element_id, + "content": self._initial_text, + } + ] + }, + } + self._card_id = await self._create_card_instance(spec) + kwargs = { + "receive_id_type": self._rit, + "reply_to": self._reply_to, + "reply_in_thread": self._reply_in_thread, + } + if self._reply_target_gone != "fresh": + kwargs["reply_target_gone"] = self._reply_target_gone + result = await self._send_card_by_reference( + self._to, + self._card_id, + **kwargs, + ) + self._message_id = getattr(result, "message_id", "") or "" + + def _on_fire(self) -> None: + """Throttle callback: snapshot content, stamp seq, enqueue update.""" + if not self._card_id: + return + self._sequence += 1 + seq = self._sequence + text = self._content or "..." + card_id = self._card_id + element_id = self._element_id + self._queue.enqueue( + lambda: self._update_card_element_content(card_id, element_id, text, seq) + ) + + async def _complete_terminal(self) -> None: + # Flush any pending throttle work first. + self._throttle.flush_now() + await self._queue.drain() + if self._card_id: + self._sequence += 1 + try: + await self._finish_streaming_card(self._card_id, self._sequence) + except Exception as e: # pragma: no cover + logger.warning( + "markdown-stream: finish_streaming_card failed: %s", e + ) + + async def _fail_terminal(self) -> None: + # If the card never got created (producer raised before first append), + # synthesize it so the user at least sees the error footer. + if not self._card_id: + try: + await self._ensure_started() + except Exception: # pragma: no cover + return + self._throttle.dispose() + self._content = (self._content or "") + TERMINATED_FOOTER + + self._sequence += 1 + seq = self._sequence + card_id = self._card_id + element_id = self._element_id + text = self._content + self._queue.enqueue( + lambda: self._update_card_element_content(card_id, element_id, text, seq) + ) + await self._queue.drain() + + self._sequence += 1 + try: + await self._finish_streaming_card(self._card_id, self._sequence) + except Exception as e: # pragma: no cover + logger.warning( + "markdown-stream: finish_streaming_card (error path) failed: %s", e + ) diff --git a/lark_channel/channel/outbound/streaming/merge_text.py b/lark_channel/channel/outbound/streaming/merge_text.py new file mode 100644 index 0000000..161d44a --- /dev/null +++ b/lark_channel/channel/outbound/streaming/merge_text.py @@ -0,0 +1,35 @@ +"""Smart concat for streaming text deltas. + +Different streaming producers yield text in different ways: +- **Delta**: each chunk is a new suffix (`"Hello"` → `" world"`). +- **Accumulated**: each chunk is the running total + (`"Hello"` → `"Hello world"`). +- **Mixed** — some frameworks switch styles mid-stream. + +`merge_streaming_text(prev, next)` handles all three: + + if next startswith prev: # accumulated; use next + return next + if prev startswith next: # producer rewound; keep prev + return prev + # else: compute the largest suffix of prev that is also a prefix of next, + # drop that prefix from next, and concatenate. +""" + + +def merge_streaming_text(prev: str, chunk: str) -> str: + if not chunk: + return prev + if not prev: + return chunk + if chunk.startswith(prev): + return chunk + if prev.startswith(chunk): + return prev + + # Find the longest suffix of prev that is a prefix of chunk. + max_overlap = min(len(prev), len(chunk)) + for size in range(max_overlap, 0, -1): + if prev[-size:] == chunk[:size]: + return prev + chunk[size:] + return prev + chunk diff --git a/lark_channel/channel/outbound/streaming/throttle.py b/lark_channel/channel/outbound/streaming/throttle.py new file mode 100644 index 0000000..fa6e337 --- /dev/null +++ b/lark_channel/channel/outbound/streaming/throttle.py @@ -0,0 +1,87 @@ +"""Dual-threshold throttle: fire on (ms_elapsed >= min_ms) OR (pending >= chars). + +Exposes three methods: + +- `note(delta_chars)` — record that new content arrived; may schedule or fire +- `flush_now()` — force-fire immediately +- `dispose()` — cancel pending timers + +`on_fire` is the flush callback (sync; caller ensures it enqueues, not blocks). +""" + +import asyncio +import time +from typing import Callable, Optional + + +class Throttle: + def __init__( + self, + *, + min_ms: int = 100, + min_chars: int = 50, + on_fire: Callable[[], None], + ) -> None: + self._min_ms = min_ms + self._min_chars = min_chars + self._on_fire = on_fire + self._pending_chars = 0 + self._last_fire_ms = 0 + self._timer: Optional[asyncio.TimerHandle] = None + self._running = False + + def note(self, delta_chars: int) -> None: + self._pending_chars += max(0, delta_chars) + if self._running: + return # a fire is already in flight; the current `on_fire` will sweep + if self._pending_chars >= self._min_chars: + self._cancel_timer() + self._do_fire() + return + if self._timer is not None: + return + now = _now_ms() + elapsed = now - self._last_fire_ms + wait_ms = max(0, self._min_ms - elapsed) + loop = asyncio.get_running_loop() + self._timer = loop.call_later(wait_ms / 1000.0, self._do_fire) + + def flush_now(self) -> None: + self._cancel_timer() + self._do_fire() + + def dispose(self) -> None: + self._cancel_timer() + + # ---- internals ---------------------------------------------------------- + def _do_fire(self) -> None: + # Clear timer/state BEFORE the synchronous ``on_fire`` call — if the + # callback triggers a re-entrant ``note`` (unusual but legal) we want + # the state consistent for that call, and we don't want + # ``self._timer`` to still look "pending" during the fire. + self._timer = None + self._pending_chars = 0 + self._last_fire_ms = _now_ms() + self._running = True + try: + self._on_fire() + finally: + # ``_running`` guards re-entry into ``on_fire`` from a synchronous + # ``note`` called inside the fire callback; the actual HTTP work + # runs off in ``UpdateQueue`` so nothing is "in flight" from our + # perspective once ``on_fire`` returns. + self._running = False + + def _cancel_timer(self) -> None: + if self._timer is not None: + try: + self._timer.cancel() + except Exception: # pragma: no cover + pass + self._timer = None + + +def _now_ms() -> int: + # ``time.monotonic`` is immune to NTP steps + DST jumps; ``time.time`` is + # not. For an interval-based throttle, the former is always correct. + return int(time.monotonic() * 1000) diff --git a/lark_channel/channel/outbound/streaming/update_queue.py b/lark_channel/channel/outbound/streaming/update_queue.py new file mode 100644 index 0000000..c50c3d4 --- /dev/null +++ b/lark_channel/channel/outbound/streaming/update_queue.py @@ -0,0 +1,103 @@ +"""Coalescing task queue for streaming card updates. + +Semantics: at any time, **at most one task runs and at most one task is +pending**. When a new task is enqueued while another is still pending +(queued but not yet started), the new one **replaces** the pending task — +only the most recent snapshot survives. If a task is currently running, +the new enqueue becomes the new pending; when the running task finishes, +it chains the pending task. + +This matches the streaming-card use case where each enqueued task is a +snapshot of the current accumulated content. Only the latest snapshot +matters for user-visible state: + +- ``MarkdownStreamController`` uses the server-sequenced element-update + API (``update_card_element_content(card_id, elem_id, content, seq)``). + The server orders updates by ``seq``; dropping an intermediate client- + side task simply skips one round-trip — the next (latest) task still + carries a higher seq and ends up winning. +- ``CardStreamController`` uses the non-sequenced ``patch_card`` API. + Coalescing guarantees at most one HTTP is in flight at a time, so + client-side serial execution is sufficient ordering. + +**Intentional divergence from node-sdk**: node's ``UpdateQueue`` is a +strict FIFO that serializes *every* enqueued task. Python coalesces — +fewer HTTP calls under fast producers, no change to final state, but a +caller observing HTTP traffic will see fewer intermediate states in +Python than in node. Documented in the channel README. + +Failure handling: a task exception is logged but NOT retried. The next +``enqueue`` naturally supersedes. Callers (e.g. ``MarkdownStreamController``) +should flush a final-state task in their terminal path so the user sees +complete content even if an intermediate update failed. +""" + +import asyncio +from typing import Any, Awaitable, Callable, Optional + +from lark_channel.core.log import logger + + +class UpdateQueue: + """Coalescing queue: at most 1 running + 1 pending task at any time.""" + + def __init__(self) -> None: + self._running: Optional[asyncio.Task] = None + self._pending: Optional[Callable[[], Awaitable[Any]]] = None + + def enqueue(self, task: Callable[[], Awaitable[Any]]) -> None: + """Schedule ``task``. If a task is pending (not yet started), it is + silently replaced — only the latest survives. + + Returns nothing: callers cannot rely on the task running, since it + may be superseded before start. Await :meth:`drain` to wait for + the queue to quiesce. + """ + self._pending = task + if self._running is None or self._running.done(): + self._start_next() + + async def drain(self) -> None: + """Wait until the queue is fully idle. + + Iterates because each running task, upon completion, may chain a + pending task in its ``finally`` block — so we must re-check the + tail state after awaiting. + + ``CancelledError`` propagates — a caller cancelling the drainer is + a signal we should not absorb. Other exceptions are swallowed after + being logged inside ``_runner`` so we can continue draining. + """ + while self._running is not None and not self._running.done(): + try: + await self._running + except asyncio.CancelledError: + raise + except Exception: + # Failures are already logged in _runner; keep draining. + pass + + # ---- internals ---------------------------------------------------------- + def _start_next(self) -> None: + """Claim `_pending` and kick off its task. Called from `enqueue` and + from inside a completing task's `finally` block.""" + if self._pending is None: + self._running = None + return + task = self._pending + self._pending = None + loop = asyncio.get_running_loop() + + async def _runner() -> None: + try: + await task() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("UpdateQueue: task failed: %s", e) + finally: + # Chain the next pending (if any new enqueue happened while + # we were running). Single-threaded asyncio means no race. + self._start_next() + + self._running = loop.create_task(_runner()) diff --git a/lark_channel/channel/quote.py b/lark_channel/channel/quote.py new file mode 100644 index 0000000..e48f9bf --- /dev/null +++ b/lark_channel/channel/quote.py @@ -0,0 +1,168 @@ +import inspect +import json +from typing import Any, Callable, Dict, Iterable, List, Optional + +from .types import InboundMessage, QuoteResolution, QuotedContext + + +class QuoteResolver: + def __init__(self, *, fetcher: Callable[[str], Any]) -> None: + self._fetcher = fetcher + + async def fetch_quoted_context(self, message_id: str) -> Optional[QuotedContext]: + try: + raw = self._fetcher(message_id) + if inspect.isawaitable(raw): + raw = await raw + except Exception: + return None + raw = _object_to_dict(raw) + if not isinstance(raw, dict): + return None + code = raw.get("code") + if code is not None and code != 0: + return None + return _context_from_raw(message_id, raw) + + async def resolve_quoted_contexts( + self, + messages: Iterable[InboundMessage], + *, + chat_mode: Optional[str], + ) -> Dict[str, QuoteResolution]: + batch = list(messages) + batch_ids = {msg.id for msg in batch} + parent_ids: List[str] = [] + results: Dict[str, QuoteResolution] = {} + + for msg in batch: + parent_id = msg.reply.message_id if msg.reply else None + root_id = _message_raw_field(msg.raw, "root_id") + if not parent_id: + results[msg.id] = QuoteResolution(msg.id, None, "no_parent") + elif chat_mode in ("topic", "thread") and parent_id == root_id: + results[msg.id] = QuoteResolution(msg.id, parent_id, "topic_root") + elif parent_id in batch_ids: + results[msg.id] = QuoteResolution(msg.id, parent_id, "in_batch") + else: + results[msg.id] = QuoteResolution(msg.id, parent_id, "fetch_failed") + if parent_id not in parent_ids: + parent_ids.append(parent_id) + + fetched = {} + for parent_id in parent_ids: + fetched[parent_id] = await self.fetch_quoted_context(parent_id) + + for msg in batch: + current = results[msg.id] + parent_id = current.parent_message_id + if ( + current.decision == "fetch_failed" + and parent_id in fetched + and fetched[parent_id] is not None + ): + results[msg.id] = QuoteResolution( + msg.id, + parent_id, + "resolved", + fetched[parent_id], + ) + return results + + +def _context_from_raw(message_id: str, raw: Dict[str, Any]) -> QuotedContext: + data = raw.get("data") if isinstance(raw.get("data"), dict) else raw + item = _first_message_item(data) + body = item.get("body") if isinstance(item.get("body"), dict) else {} + content = _decode_content( + body.get("content") if body.get("content") is not None else item.get("content") + ) + sender = _object_to_dict(item.get("sender") or {}) + return QuotedContext( + message_id=item.get("message_id") or message_id, + text=_flatten_content(content), + sender_id=_sender_id(sender), + create_time=_as_int(item.get("create_time")), + content_type=item.get("msg_type") or item.get("message_type"), + raw=item, + ) + + +def _first_message_item(raw: Dict[str, Any]) -> Dict[str, Any]: + items = raw.get("items") + if isinstance(items, list) and items and isinstance(items[0], dict): + return items[0] + return raw + + +def _message_raw_field(raw: Dict[str, Any], key: str) -> Any: + if not isinstance(raw, dict): + return None + nested = raw.get("message") + if isinstance(nested, dict) and nested.get(key) is not None: + return nested.get(key) + return raw.get(key) + + +def _sender_id(sender: Dict[str, Any]) -> Optional[str]: + if not isinstance(sender, dict): + return None + return ( + sender.get("open_id") + or sender.get("user_id") + or sender.get("union_id") + or sender.get("id") + ) + + +def _decode_content(content: Any) -> Any: + if isinstance(content, str): + try: + return json.loads(content) + except ValueError: + return {"text": content} + return content or {} + + +def _flatten_content(content: Any) -> str: + if isinstance(content, dict): + parts: List[str] = [] + for key in ("text", "title", "content", "summary"): + value = content.get(key) + if isinstance(value, str) and value: + parts.append(value) + elif isinstance(value, (dict, list)): + nested = _flatten_content(value) + if nested: + parts.append(nested) + for key in ("elements", "blocks", "items", "messages"): + value = content.get(key) + if isinstance(value, list): + parts.extend(part for part in (_flatten_content(item) for item in value) if part) + return "\n".join(part for part in parts if part) + if isinstance(content, list): + return "\n".join(part for part in (_flatten_content(item) for item in content) if part) + return str(content) if content else "" + + +def _object_to_dict(value: Any) -> Any: + if isinstance(value, dict): + return {key: _object_to_dict(val) for key, val in value.items()} + if isinstance(value, list): + return [_object_to_dict(item) for item in value] + if hasattr(value, "__dict__"): + return { + key: _object_to_dict(val) + for key, val in value.__dict__.items() + if not key.startswith("_") + } + return value + + +def _as_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/lark_channel/channel/safety/__init__.py b/lark_channel/channel/safety/__init__.py new file mode 100644 index 0000000..8882647 --- /dev/null +++ b/lark_channel/channel/safety/__init__.py @@ -0,0 +1,37 @@ +"""Safety pipeline — dedup, stale detection, policy, lock, batch+queue.""" + +from .chat_pipeline import ChatPipeline, ChatPipelineManager, merge_batch +from .pipeline import SafetyPipeline +from .policy_gate import PolicyDecision, PolicyGate +from .processing_lock import ProcessingLock +from .dedup_cache import SeenCache +from .stale_detector import DEFAULT_STALE_MS, is_stale +from .types import ( + BatchConfig, + ChatQueueConfig, + DedupConfig, + MediaBatchConfig, + RejectEvent, + RejectReason, + TextBatchConfig, +) + +__all__ = [ + "BatchConfig", + "ChatPipeline", + "ChatPipelineManager", + "ChatQueueConfig", + "DEFAULT_STALE_MS", + "DedupConfig", + "MediaBatchConfig", + "PolicyDecision", + "PolicyGate", + "ProcessingLock", + "RejectEvent", + "RejectReason", + "SafetyPipeline", + "SeenCache", + "TextBatchConfig", + "is_stale", + "merge_batch", +] diff --git a/lark_channel/channel/safety/chat_pipeline.py b/lark_channel/channel/safety/chat_pipeline.py new file mode 100644 index 0000000..8c62a95 --- /dev/null +++ b/lark_channel/channel/safety/chat_pipeline.py @@ -0,0 +1,371 @@ +"""Per-chat batch aggregation + serial handler execution. + +Problem solved: a user firing rapid-fire messages ("help" / "write a" / +"quicksort") should be treated as ONE request, not three. Also: we must +never run two handlers concurrently for the same chat (races on shared +conversation state). + +`ChatPipeline` owns both responsibilities for a given scope (usually chat_id +or file_token): + +- **Batch**: incoming messages go into a buffer; a debounce timer fires after + `delay_ms` (or `long_delay_ms` if accumulated chars ≥ `long_threshold_chars`). + The timer, `max_messages`, and `max_chars` are three parallel flush triggers. +- **Serialize**: flushes are enqueued on a single task chain so a slow handler + blocks later flushes in the same scope. + +`ChatPipelineManager` is a keyed registry — one pipeline per chat_id. +""" + +import asyncio +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ..types import InboundMessage +from .types import ChatQueueConfig, TextBatchConfig + +FlushHandler = Callable[[InboundMessage, List[InboundMessage]], Awaitable[None]] + + +def merge_batch(batch: List[InboundMessage]) -> InboundMessage: + """Merge a batch of inbound messages into a single virtual message. + + The last message's metadata (id, sender, timestamps) wins; `content.text` + is joined with `\\n\\n`; mentions and raw are unified. + """ + if len(batch) == 1: + return batch[0] + last = batch[-1] + texts: List[str] = [] + all_mentions = list(last.mentions or []) + mentioned_all = False + mentioned_bot = False + chat_mode = last.chat_mode + seen_mention_ids = set() + for m in batch: + content = m.content + text = getattr(content, "text", "") or getattr(content, "title", "") or "" + if text: + texts.append(text) + if m.mentioned_all: + mentioned_all = True + if m.mentioned_bot: + mentioned_bot = True + if chat_mode is None and m.chat_mode is not None: + chat_mode = m.chat_mode + for mention in m.mentions or []: + sig = mention.open_id or mention.user_id or mention.key + if sig in seen_mention_ids: + continue + seen_mention_ids.add(sig) + if mention not in all_mentions: + all_mentions.append(mention) + from ..types import InboundMessage as _IM, TextContent + + merged_content = last.content + if isinstance(merged_content, TextContent) and texts: + merged_content = TextContent(text="\n\n".join(texts), raw=merged_content.raw) + merged = _IM( + id=last.id, + create_time=last.create_time, + conversation=last.conversation, + sender=last.sender, + mentions=all_mentions, + mentioned_all=mentioned_all or last.mentioned_all, + mentioned_bot=mentioned_bot or last.mentioned_bot, + reply=last.reply, + content=merged_content, + raw=last.raw, + chat_mode=chat_mode, + ) + return merged + + +class ChatPipeline: + """Single-scope batch + serialize pipeline. + + Two modes: + - `serial_only=False` (default): full batch + serialize. Used for messages. + - `serial_only=True`: no batching; `run(task)` chains tasks FIFO. Used for + cardAction / comment. + """ + + def __init__( + self, + scope: str, + config: TextBatchConfig, + loop: asyncio.AbstractEventLoop, + *, + serial_only: bool = False, + merge_while_busy: bool = True, + ) -> None: + self._scope = scope + self._config = config + self._loop = loop + self._serial_only = serial_only + self._merge_while_busy = merge_while_busy + + self._buffer: List[InboundMessage] = [] + self._buffer_chars = 0 + self._blocked_batches: List[Tuple[List[InboundMessage], FlushHandler]] = [] + self._timer: Optional[asyncio.TimerHandle] = None + self._pending_handler: Optional[FlushHandler] = None + self._tail: asyncio.Task = self._loop.create_task(self._noop()) + self._blocked = False + self._deferred_runs: List[Tuple[Callable[[], Awaitable[Any]], asyncio.Future]] = [] + + async def _noop(self) -> None: + return None + + # ---- message batch path -------------------------------------------------- + def push(self, msg: InboundMessage, handler: FlushHandler) -> None: + """Buffer a message and arm the debounce timer.""" + if self._blocked and not self._merge_while_busy: + self._blocked_batches.append(([msg], handler)) + return + + self._buffer.append(msg) + text = getattr(msg.content, "text", "") or "" + self._buffer_chars += len(text) + if self._pending_handler is None: + self._pending_handler = handler + + if self._blocked: + return + + # Flush on caps + if self._should_flush_for_caps(): + self._flush_now() + return + + self._schedule_timer() + + def block(self) -> None: + self._blocked = True + self._cancel_timer() + + def unblock(self) -> None: + self._blocked = False + if self._blocked_batches: + if self._buffer: + self._cancel_timer() + self._flush_async(release_deferred=False) + self._flush_blocked_batches() + return + if self._buffer: + if self._should_flush_for_caps(): + self._flush_now() + return + self._schedule_timer() + + def cancel_buffer(self) -> None: + self._cancel_timer() + self._buffer = [] + self._buffer_chars = 0 + self._blocked_batches = [] + self._pending_handler = None + self._release_deferred_runs() + + def enable_batching(self) -> None: + self._serial_only = False + + def _flush_async(self, *, release_deferred: bool = True) -> None: + if not self._buffer: + if release_deferred and not self._blocked_batches: + self._release_deferred_runs() + return + batch = self._buffer + handler = self._pending_handler + self._buffer = [] + self._buffer_chars = 0 + self._pending_handler = None + if handler is None: + if release_deferred: + self._release_deferred_runs() + return + merged = merge_batch(batch) + self._enqueue(lambda: handler(merged, batch)) + if release_deferred: + self._release_deferred_runs() + + def _flush_blocked_batches(self) -> None: + if not self._blocked_batches: + self._release_deferred_runs() + return + batches = self._blocked_batches + self._blocked_batches = [] + for batch, handler in batches: + merged = merge_batch(batch) + self._enqueue( + lambda h=handler, m=merged, b=batch: h(m, b) + ) + self._release_deferred_runs() + + # ---- free-standing serialize path --------------------------------------- + def run(self, task: Callable[[], Awaitable[Any]]) -> "asyncio.Future[Any]": + """Chain a task onto the serial queue; returns a future for its result.""" + # Force any pending buffered batch to flush first. While blocked, keep + # later same-scope work behind the buffered batch until it is released. + if self._blocked and (self._buffer or self._blocked_batches): + fut = self._loop.create_future() + self._deferred_runs.append((task, fut)) + return fut + if self._buffer: + self._cancel_timer() + self._flush_async() + return self._enqueue(task) + + # ---- internals ----------------------------------------------------------- + def _enqueue(self, task: Callable[[], Awaitable[Any]]) -> "asyncio.Future[Any]": + prev_tail = self._tail + + async def runner(): + # ``prev_tail`` is captured via closure. Without the ``nonlocal`` + # + explicit ``prev_tail = None`` below, each runner would hold a + # reference to its predecessor for its entire lifetime, and a + # long chat-history serialization chain (one runner per message + # in a busy group) would keep every completed ancestor alive in + # memory until the whole chain drains. Clearing the closure cell + # as soon as we're past the await lets Python reap ancestors + # eagerly. + nonlocal prev_tail + if prev_tail is not None: + try: + await prev_tail + except asyncio.CancelledError: + raise + except Exception: # prior task failure shouldn't block us + pass + prev_tail = None # drop the closure's grip on the previous task + return await task() + + next_task = self._loop.create_task(runner()) + self._tail = next_task + return next_task + + def _release_deferred_runs(self) -> None: + if not self._deferred_runs: + return + deferred = self._deferred_runs + self._deferred_runs = [] + for task, outer in deferred: + if outer.cancelled(): + continue + inner = self._enqueue(task) + + def _complete( + f: "asyncio.Future[Any]", + target: "asyncio.Future[Any]" = outer, + ) -> None: + if target.cancelled(): + return + try: + target.set_result(f.result()) + except asyncio.CancelledError: + target.cancel() + except Exception as e: + target.set_exception(e) + + inner.add_done_callback(_complete) + + def _cancel_timer(self) -> None: + if self._timer is not None: + try: + self._timer.cancel() + except Exception: # pragma: no cover - defensive + pass + self._timer = None + + def _should_flush_for_caps(self) -> bool: + return ( + len(self._buffer) >= self._config.max_messages + or self._buffer_chars >= self._config.max_chars + ) + + def _flush_now(self) -> None: + self._cancel_timer() + self._flush_async() + + def _schedule_timer(self) -> None: + # delayMs=0 or serial_only → immediate flush + if self._config.delay_ms <= 0 or self._serial_only: + self._flush_now() + return + + # Debounce: long messages get longer delay + delay_ms = ( + self._config.long_delay_ms + if self._buffer_chars >= self._config.long_threshold_chars + else self._config.delay_ms + ) + self._cancel_timer() + self._timer = self._loop.call_later(delay_ms / 1000.0, self._flush_async) + + async def dispose(self) -> None: + """Flush any buffered batch and wait for outstanding tasks.""" + self._cancel_timer() + if self._buffer: + self._flush_async(release_deferred=not self._blocked_batches) + if self._blocked_batches: + self._flush_blocked_batches() + elif not self._buffer: + self._release_deferred_runs() + try: + await self._tail + except (asyncio.CancelledError, Exception): + pass + + +class ChatPipelineManager: + """Map of scope → ChatPipeline, lazily created.""" + + def __init__( + self, + config: TextBatchConfig, + loop: asyncio.AbstractEventLoop, + queue_config: Optional[ChatQueueConfig] = None, + ) -> None: + self._config = config + self._loop = loop + queue_config = queue_config or ChatQueueConfig() + self._queue_enabled = queue_config.enabled + self._merge_while_busy = queue_config.merge_while_busy + self._pipelines: Dict[str, ChatPipeline] = {} + + def push(self, scope: str, msg: InboundMessage, handler: FlushHandler) -> None: + self._pipeline(scope, serial_only=False).push(msg, handler) + + def run(self, scope: str, task: Callable[[], Awaitable[Any]]) -> "asyncio.Future[Any]": + return self._pipeline(scope, serial_only=True).run(task) + + def block_scope(self, scope: str) -> None: + self._pipeline(scope, serial_only=False).block() + + def unblock_scope(self, scope: str) -> None: + self._pipeline(scope, serial_only=False).unblock() + + def cancel_scope(self, scope: str) -> None: + self._pipeline(scope, serial_only=False).cancel_buffer() + + def _pipeline(self, scope: str, *, serial_only: bool) -> ChatPipeline: + pipeline = self._pipelines.get(scope) + if pipeline is None: + pipeline = ChatPipeline( + scope, + self._config, + self._loop, + serial_only=serial_only, + merge_while_busy=self._merge_while_busy, + ) + self._pipelines[scope] = pipeline + elif not serial_only: + pipeline.enable_batching() + return pipeline + + async def dispose(self) -> None: + for pipeline in list(self._pipelines.values()): + await pipeline.dispose() + self._pipelines.clear() + + @property + def queue_enabled(self) -> bool: + return self._queue_enabled diff --git a/lark_channel/channel/safety/dedup_cache.py b/lark_channel/channel/safety/dedup_cache.py new file mode 100644 index 0000000..4e01812 --- /dev/null +++ b/lark_channel/channel/safety/dedup_cache.py @@ -0,0 +1,144 @@ +"""Two-tier dedup cache: in-memory LRU + optional shared Cache. + +- **Memory layer** (default): bounded LRU + TTL, no external dependencies, no + cross-process coherence. +- **Cache layer** (optional): pluggable `lark_channel.core.cache.ICache` — usually + Redis-backed. Lets multiple agent workers share dedup state, and survives + restarts. + +`has(id)` reads memory first; on miss it consults the Cache layer and +back-fills memory if the Cache layer has a hit. `add(id)` writes both. + +**Concurrency caveat — single-process dedup is not atomic across workers.** +``has`` + ``add`` is a check-then-act sequence; two concurrent callers for +the same event id may both observe "not seen" and both proceed to the +handler. The in-process :class:`~.processing_lock.ProcessingLock` breaks +the tie within a single process, but does **not** cross process boundaries. + +If you run multiple agent workers behind a shared ``ICache`` (e.g. Redis), +the current ``ICache`` interface does not expose an atomic SETNX primitive, +so cross-process duplicate delivery is possible for a short window. The +safe patterns are: + +1. **Single worker per app_id.** Route all events for one Feishu app to a + single process (sticky routing / leader election). This is the default + assumption and the only configuration fully covered by the tests. +2. **Idempotent handlers.** If you must run multiple workers, design your + event handlers to be idempotent on the event id. + +A future version may add ``ICache.set_if_not_exists`` so the cache layer +itself can serialize concurrent first-writers; until then, treat the cache +layer as a best-effort speedup, not a coherence boundary. +""" + +import threading +import time +from collections import OrderedDict +from typing import Optional + +from lark_channel.core.cache import ICache +from lark_channel.core.log import logger + +DEFAULT_TTL_SECONDS = 12 * 3600 +DEFAULT_MAX_ENTRIES = 5000 +DEFAULT_SWEEP_SECONDS = 5 * 60 +DEFAULT_NAMESPACE = "channel:seen:" + + +class SeenCache: + """Thread-safe two-layer dedup cache. + + Use `has_sync` / `add_sync` for purely in-memory use; the async variants + also consult / write the injected `ICache`. Both APIs coexist because the + underlying `ICache` is synchronous, but we may later plug in an async + Redis client — the async signatures keep that upgrade path open without + breaking callers. + """ + + def __init__( + self, + cache: Optional[ICache] = None, + *, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + max_entries: int = DEFAULT_MAX_ENTRIES, + sweep_seconds: int = DEFAULT_SWEEP_SECONDS, + namespace: str = DEFAULT_NAMESPACE, + ) -> None: + self._cache = cache + self._ttl = ttl_seconds + self._max = max_entries + self._sweep = sweep_seconds + self._ns = namespace + + self._memory: "OrderedDict[str, float]" = OrderedDict() + self._lock = threading.Lock() + self._last_sweep = time.time() + + # ---- sync API ------------------------------------------------------------ + def has_sync(self, id_: str) -> bool: + with self._lock: + self._maybe_sweep_locked() + exp = self._memory.get(id_) + if exp is not None: + if exp > time.time(): + self._memory.move_to_end(id_) + return True + self._memory.pop(id_, None) + # Memory miss: check the external cache. + if self._cache is not None: + hit = self._cache_get(self._key(id_)) + if hit: + # Back-fill memory. + with self._lock: + self._memory[id_] = time.time() + self._ttl + self._memory.move_to_end(id_) + self._evict_locked() + return True + return False + + def add_sync(self, id_: str) -> None: + expire_at = time.time() + self._ttl + with self._lock: + self._memory[id_] = expire_at + self._memory.move_to_end(id_) + self._evict_locked() + if self._cache is not None: + try: + self._cache.set(self._key(id_), "1", int(expire_at)) + except Exception as e: # pragma: no cover - defensive + logger.debug("seen_cache: ICache.set failed: %s", e) + + # ---- async wrappers (identical semantics) -------------------------------- + async def has(self, id_: str) -> bool: + return self.has_sync(id_) + + async def add(self, id_: str) -> None: + self.add_sync(id_) + + # ---- internals ----------------------------------------------------------- + def _key(self, id_: str) -> str: + return f"{self._ns}{id_}" + + def _cache_get(self, key: str) -> Optional[str]: + try: + return self._cache.get(key) # type: ignore[union-attr] + except Exception as e: + logger.debug("seen_cache: ICache.get failed: %s", e) + return None + + def _evict_locked(self) -> None: + while len(self._memory) > self._max: + self._memory.popitem(last=False) + + def _maybe_sweep_locked(self) -> None: + now = time.time() + if now - self._last_sweep < self._sweep: + return + self._last_sweep = now + expired = [k for k, exp in self._memory.items() if exp <= now] + for k in expired: + self._memory.pop(k, None) + + def size(self) -> int: + with self._lock: + return len(self._memory) diff --git a/lark_channel/channel/safety/media_pipeline.py b/lark_channel/channel/safety/media_pipeline.py new file mode 100644 index 0000000..4b82eec --- /dev/null +++ b/lark_channel/channel/safety/media_pipeline.py @@ -0,0 +1,146 @@ +"""Media batching: debounce + bundle consecutive compatible media messages. + +Parallel to ``ChatPipelineManager`` but with simpler semantics: + +- Key: ``(chat_id, kind, reply_to, thread_id)``. +- Compatible run: same key, same kind, within ``delay_ms``. +- Incompatible push (different kind, or text intervenes) flushes the + current batch first, then starts a new one with the new message. +- ``max_items`` cap: flushes the current batch and starts fresh. + +Output is a single ``InboundMessage`` whose ``batched_sources`` carries the +ordered list of source messages. The merged message is the last source +(latest metadata: id, create_time, mentions); ``content`` and ``resources`` +are unchanged from the last source - consumers should iterate +``batched_sources`` to access all media. +""" + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from lark_channel.core.log import logger + +from ..types import InboundMessage +from .types import MediaBatchConfig + +MediaFlushHandler = Callable[[InboundMessage], Awaitable[None]] + + +def _kind_of(msg: InboundMessage) -> str: + """Identify the media kind for batching. Empty string means non-media.""" + return msg.raw_content_type or "" + + +def _key_of(msg: InboundMessage) -> Tuple[str, str, str, str]: + chat_id = msg.conversation.chat_id or "" + kind = _kind_of(msg) + reply_to = msg.reply.message_id if msg.reply else "" + thread_id = getattr(msg.conversation, "thread_id", "") or "" + return (chat_id, kind, reply_to, thread_id) + + +def _attach_sources_to_last(sources: List[InboundMessage]) -> InboundMessage: + """Attach a media batch to the last source message and return that carrier. + + Use the last message as the carrier (latest metadata), attach all + sources as ``batched_sources`` in arrival order. + """ + if len(sources) == 1: + return sources[0] + last = sources[-1] + last.batched_sources = list(sources) + return last + + +@dataclass +class _Bucket: + sources: List[InboundMessage] = field(default_factory=list) + timer: Optional[asyncio.TimerHandle] = None + + +class MediaPipelineManager: + """Per-key debounce + flush. Compatible with ``ChatPipelineManager``'s + lifecycle: ``push(msg, handler)`` schedules; ``dispose()`` drains. + """ + + def __init__( + self, + config: MediaBatchConfig, + loop: asyncio.AbstractEventLoop, + ) -> None: + self._config = config + self._loop = loop + self._buckets: Dict[Tuple[str, str, str, str], _Bucket] = {} + self._handler: Optional[MediaFlushHandler] = None + + @property + def enabled(self) -> bool: + return self._config.enabled + + def is_compatible(self, msg: InboundMessage) -> bool: + kind = _kind_of(msg) + return self._config.enabled and kind in self._config.compatible_kinds + + async def push(self, msg: InboundMessage, handler: MediaFlushHandler) -> None: + """Add ``msg`` to its bucket. Flushes the bucket if max_items reached.""" + self._handler = handler + key = _key_of(msg) + bucket = self._buckets.setdefault(key, _Bucket()) + bucket.sources.append(msg) + + if len(bucket.sources) >= self._config.max_items: + self._cancel_timer(bucket) + await self._flush_bucket(key) + return + + self._cancel_timer(bucket) + bucket.timer = self._loop.call_later( + self._config.delay_ms / 1000.0, + lambda: self._spawn_flush(key), + ) + + def _spawn_flush(self, key: Tuple[str, str, str, str]) -> None: + """Schedule ``_flush_bucket(key)`` with a logging done-callback so + timer-driven flush failures do not vanish into a fire-and-forget task. + """ + task = self._loop.create_task(self._flush_bucket(key)) + + def _on_done(t: "asyncio.Task[Any]") -> None: + if t.cancelled(): + return + exc = t.exception() + if exc is not None: + logger.warning("media_pipeline: bucket flush failed: %s", exc) + + task.add_done_callback(_on_done) + + async def flush_incompatible_for(self, msg: InboundMessage) -> None: + """Called when a non-compatible message arrives in a chat that has a + pending bucket - flushes any bucket sharing the chat_id.""" + chat_id = msg.conversation.chat_id or "" + keys_to_flush = [k for k in list(self._buckets.keys()) if k[0] == chat_id] + for k in keys_to_flush: + await self._flush_bucket(k) + + async def _flush_bucket(self, key: Tuple[str, str, str, str]) -> None: + bucket = self._buckets.pop(key, None) + if bucket is None or not bucket.sources: + return + self._cancel_timer(bucket) + merged = _attach_sources_to_last(bucket.sources) + if self._handler is not None: + await self._handler(merged) + + def _cancel_timer(self, bucket: _Bucket) -> None: + if bucket.timer is not None: + try: + bucket.timer.cancel() + except Exception: + pass + bucket.timer = None + + async def dispose(self) -> None: + """Flush any remaining buckets - call during channel shutdown.""" + for key in list(self._buckets.keys()): + await self._flush_bucket(key) diff --git a/lark_channel/channel/safety/pipeline.py b/lark_channel/channel/safety/pipeline.py new file mode 100644 index 0000000..d1909b1 --- /dev/null +++ b/lark_channel/channel/safety/pipeline.py @@ -0,0 +1,293 @@ +"""SafetyPipeline — three-tier entry point for events. + +Exposes three push methods, each enforcing a different subset of the full +pipeline: + + push_message — full pipeline (stale / dedup / policy / lock / batch / queue) + push_action — dedup + lock + queue (cardAction / comment) + push_light — dedup only (reaction) + +The pipeline owns a `PolicyGate`, `SeenCache`, `ProcessingLock`, and +`ChatPipelineManager`, composing them into a single call site. +""" + +import asyncio +import inspect +from typing import Awaitable, Callable, Optional + +from lark_channel.core.cache import ICache +from lark_channel.core.log import logger + +from ..config import PolicyConfig +from ..types import InboundMessage +from .chat_pipeline import ChatPipelineManager +from .media_pipeline import MediaPipelineManager +from .policy_gate import PolicyGate +from .processing_lock import ProcessingLock +from .dedup_cache import SeenCache +from .stale_detector import DEFAULT_STALE_MS, is_stale +from .types import ( + ChatQueueConfig, + DedupConfig, + MediaBatchConfig, + RejectEvent, + RejectReason, + TextBatchConfig, +) + +MessageDispatch = Callable[[InboundMessage], Awaitable[None]] +OnReject = Callable[[RejectEvent], None] + + +class SafetyPipeline: + """Facade that wires stale / dedup / policy / lock / batch / queue.""" + + def __init__( + self, + *, + loop: asyncio.AbstractEventLoop, + on_message: MessageDispatch, + on_reject: Optional[OnReject] = None, + policy: Optional[PolicyConfig] = None, + cache: Optional[ICache] = None, + dedup_config: Optional[DedupConfig] = None, + batch_config: Optional[TextBatchConfig] = None, + media_batch_config: Optional[MediaBatchConfig] = None, + queue_config: Optional[ChatQueueConfig] = None, + stale_window_ms: int = DEFAULT_STALE_MS, + processing_lock_ttl_ms: int = 5 * 60 * 1000, + drop_self_sent: bool = True, + ) -> None: + self._loop = loop + self._on_message = on_message + self._on_reject = on_reject + self._stale_window_ms = stale_window_ms + self._drop_self_sent = drop_self_sent + self._bot_open_id: Optional[str] = None + + dedup_config = dedup_config or DedupConfig() + self._seen = SeenCache( + cache=cache, + ttl_seconds=dedup_config.ttl_seconds, + max_entries=dedup_config.max_entries, + sweep_seconds=dedup_config.sweep_seconds, + ) + self._lock = ProcessingLock(ttl_ms=processing_lock_ttl_ms) + self._policy = PolicyGate(policy or PolicyConfig()) + self._manager = ChatPipelineManager( + config=batch_config or TextBatchConfig(), + loop=loop, + queue_config=queue_config or ChatQueueConfig(), + ) + self._media = MediaPipelineManager( + config=media_batch_config or MediaBatchConfig(), + loop=loop, + ) + + # ---- public accessors ---------------------------------------------------- + @property + def seen(self) -> SeenCache: + return self._seen + + def set_bot_open_id(self, open_id: Optional[str]) -> None: + self._bot_open_id = open_id + self._policy.set_bot_open_id(open_id) + + def update_policy(self, **changes) -> None: + self._policy.update_policy(**changes) + + def get_policy(self) -> PolicyConfig: + return self._policy.get_policy() + + def block_batch_scope(self, scope: str) -> None: + self._manager.block_scope(scope) + + def unblock_batch_scope(self, scope: str) -> None: + self._manager.unblock_scope(scope) + + def cancel_batch_scope(self, scope: str) -> None: + self._manager.cancel_scope(scope) + + def _emit_reject(self, msg: InboundMessage, reason: "RejectReason") -> None: + """Emit a :class:`RejectEvent` to the caller-supplied hook, if any. + + Kept internal because the event taxonomy is deliberately narrow; only + :meth:`push_message` calls this. Swallows handler exceptions so a + buggy reject callback can't break message processing. + """ + if self._on_reject is None: + return + try: + self._on_reject(RejectEvent( + message_id=msg.id, + chat_id=msg.conversation.chat_id, + sender_id=msg.sender.open_id, + reason=reason, + )) + except Exception as e: # pragma: no cover + logger.warning("safety: on_reject handler raised: %s", e) + + # ---- tier 1: full message pipeline --------------------------------------- + async def push_message(self, msg: InboundMessage) -> None: + """Run a message through the complete safety gauntlet.""" + # 1. Stale detector — emits RejectEvent(reason="stale") so subscribers + # can observe the drop. + if is_stale(msg.create_time * 1000 if msg.create_time < 10**12 else msg.create_time, + self._stale_window_ms): + logger.debug("safety: stale drop message_id=%s", msg.id) + self._emit_reject(msg, "stale") + return + + # 2. Dedup (seen cache) — emits RejectEvent(reason="duplicate"); same + # rationale as the stale branch above. + if await self._seen.has(msg.id): + logger.debug("safety: dedup drop message_id=%s", msg.id) + self._emit_reject(msg, "duplicate") + return + + # 2.5 Self-sent filter — only when bot identity is known. Conservative: + # unknown identity skips the filter so legitimate user messages + # during startup aren't dropped. + if ( + self._drop_self_sent + and self._bot_open_id is not None + and msg.sender.open_id == self._bot_open_id + ): + logger.debug("safety: self-sent drop message_id=%s", msg.id) + self._emit_reject(msg, "self_sent") + return + + # 3. Policy gate + decision = self._policy.evaluate(msg) + if not decision.allowed: + if decision.reason is not None: + self._emit_reject(msg, decision.reason) + else: + logger.debug("safety: policy drop message_id=%s reason=None", msg.id) + return + + # 4. Processing lock + if not self._lock.acquire(msg.id): + logger.debug("safety: lock contention drop message_id=%s", msg.id) + self._emit_reject(msg, "lock_contention") + return + + # 5. Media batching takes precedence over the chat batch/queue path + # when the message is a compatible media kind. The chat queue is + # bypassed for batched media — the merged dispatch goes directly + # to the handler. Text and other kinds fall through to the + # existing batch+queue flow, but first we flush any pending media + # bucket on the same chat so order is preserved. + if self._media.is_compatible(msg): + await self._media.push(msg, self._media_flush_handler) + return + + # Non-media (or non-compatible kind): flush any pending media bucket + # in this chat first so order is preserved across the kind switch. + if self._media.enabled: + await self._media.flush_incompatible_for(msg) + + # 5a. batch + serialize (or direct if queue disabled) + if self._manager.queue_enabled: + self._manager.push( + msg.conversation.chat_id or msg.id, + msg, + self._message_flush_handler, + ) + else: + await self._message_flush_handler(msg, [msg]) + + async def _media_flush_handler(self, merged: InboundMessage) -> None: + try: + await _maybe_await(self._on_message(merged)) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("safety: on_message handler raised: %s", e) + finally: + sources = merged.batched_sources or [merged] + for m in sources: + try: + await self._seen.add(m.id) + except Exception: # pragma: no cover + pass + self._lock.release(m.id) + + async def _message_flush_handler( + self, merged: InboundMessage, sources: "list[InboundMessage]" + ) -> None: + try: + await _maybe_await(self._on_message(merged)) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("safety: on_message handler raised: %s", e) + finally: + # Mark each source message as seen + release locks + for m in sources: + try: + await self._seen.add(m.id) + except Exception: # pragma: no cover + pass + self._lock.release(m.id) + + # ---- tier 2: cardAction / comment (dedup + lock + serial) ---------------- + async def push_action( + self, + event_id: str, + queue_scope: str, + handler: Callable[[], Awaitable[None]], + ) -> None: + if await self._seen.has(event_id): + logger.debug("safety: dedup drop action event_id=%s", event_id) + return + if not self._lock.acquire(event_id): + logger.debug("safety: lock contention drop action event_id=%s", event_id) + return + + async def runner() -> None: + try: + await _maybe_await(handler()) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("safety: action handler raised: %s", e) + finally: + try: + await self._seen.add(event_id) + except Exception: # pragma: no cover + pass + self._lock.release(event_id) + + self._manager.run(queue_scope, runner) + + # ---- tier 3: reaction (dedup only) --------------------------------------- + async def push_light( + self, + event_id: str, + handler: Callable[[], Awaitable[None]], + ) -> None: + if await self._seen.has(event_id): + logger.debug("safety: dedup drop light event_id=%s", event_id) + return + try: + await _maybe_await(handler()) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("safety: light handler raised: %s", e) + finally: + try: + await self._seen.add(event_id) + except Exception: # pragma: no cover + pass + + async def dispose(self) -> None: + await self._manager.dispose() + await self._media.dispose() + + +async def _maybe_await(v): + if inspect.isawaitable(v): + return await v + return v diff --git a/lark_channel/channel/safety/policy_gate.py b/lark_channel/channel/safety/policy_gate.py new file mode 100644 index 0000000..8245667 --- /dev/null +++ b/lark_channel/channel/safety/policy_gate.py @@ -0,0 +1,169 @@ +"""Declarative policy judgement with run-time updatability. + +Decision order: + +Group chat: + 1. ``group_policy`` / per-chat override gates the chat or sender. + 2. ``require_mention`` checks bot mention or optionally ``@all``. + 3. ``respond_to_mention_all=False`` blocks standalone ``@all`` mentions. + +DM: + - ``dm_policy``: open / allowlist / blocklist / disabled + - ``allow_from`` / ``deny_from`` match configured sender identity fields + +Returns `(allowed: bool, reason?: RejectReason)`. +""" + +import threading +from dataclasses import dataclass +from typing import List, Optional, Set + +from ..config import PolicyConfig +from ..types import Identity, InboundMessage +from .types import RejectReason + + +_VALID_SENDER_IDENTITY_FIELDS = {"open_id", "user_id", "union_id"} + + +def _sender_identity_values(sender: Identity, fields: List[str]) -> Set[str]: + values: Set[str] = set() + for field in fields or ["open_id"]: + if field not in _VALID_SENDER_IDENTITY_FIELDS: + raise ValueError(f"invalid sender identity field: {field}") + value = str(getattr(sender, field, "") or "") + if value: + values.add(value) + return values + + +def _matches_any_sender_identity(sender_ids: Set[str], candidates) -> bool: + return bool(sender_ids and candidates and sender_ids.intersection(set(candidates))) + + +@dataclass +class PolicyDecision: + allowed: bool + reason: Optional[RejectReason] = None + + +class PolicyGate: + def __init__(self, policy: Optional[PolicyConfig] = None) -> None: + self._policy = policy or PolicyConfig() + self._bot_open_id: Optional[str] = None + self._lock = threading.Lock() + + def set_bot_open_id(self, open_id: Optional[str]) -> None: + with self._lock: + self._bot_open_id = open_id + + def get_policy(self) -> PolicyConfig: + with self._lock: + return self._policy + + def update_policy(self, **changes) -> None: + """Run-time partial update. Unknown keys are ignored.""" + with self._lock: + for k, v in changes.items(): + if hasattr(self._policy, k): + setattr(self._policy, k, v) + + def evaluate(self, msg: InboundMessage) -> PolicyDecision: + with self._lock: + policy = self._policy + bot_open_id = self._bot_open_id + + sender_ids = _sender_identity_values(msg.sender, policy.sender_identity_fields) + + # 1. admin bypass — always allowed regardless of any other gate + if policy.admins and _matches_any_sender_identity(sender_ids, policy.admins): + return PolicyDecision(True) + + chat_type = msg.conversation.chat_type + if chat_type in ("group", "topic"): + return self._evaluate_group(msg, policy, bot_open_id, sender_ids) + return self._evaluate_dm(msg, policy, sender_ids) + + def _evaluate_group( + self, + msg: InboundMessage, + policy: PolicyConfig, + bot_open_id: Optional[str], + sender_ids: Set[str], + ) -> PolicyDecision: + override = (policy.group_overrides or {}).get(msg.conversation.chat_id) + + # explicit per-override disable + if override and override.enabled is False: + return PolicyDecision(False, "policy_group_disabled") + + policy_kind = override.policy if override and override.policy else policy.group_policy + + if policy_kind == "disabled": + return PolicyDecision(False, "policy_group_disabled") + + if policy_kind == "blocklist": + # Per-override blocklist gates the chat's sender identities. + # Global group_blocklist gates chat_ids. + if override and override.blocklist is not None: + if _matches_any_sender_identity(sender_ids, override.blocklist): + return PolicyDecision(False, "policy_blocklist") + elif policy.group_blocklist and msg.conversation.chat_id in policy.group_blocklist: + return PolicyDecision(False, "policy_blocklist") + # Otherwise fall through — blocklist mode permits everyone not listed. + + elif policy_kind == "admin_only": + if not policy.admins or not _matches_any_sender_identity(sender_ids, policy.admins): + return PolicyDecision(False, "policy_admin_only") + # Admins fall through to require_mention; an admin who forgot to + # @-mention the bot in a group still hits the mention gate. + + elif policy_kind == "allowlist": + # Per-override allowlist gates the chat's sender identities. + # Global group_allowlist gates chat_ids. + if override and override.allowlist is not None: + if not _matches_any_sender_identity(sender_ids, override.allowlist): + return PolicyDecision(False, "policy_group_not_in_allowlist") + else: + if not policy.group_allowlist or msg.conversation.chat_id not in policy.group_allowlist: + return PolicyDecision(False, "policy_group_not_in_allowlist") + + # require_mention / mention_all (unchanged) + require_mention = ( + override.require_mention + if override and override.require_mention is not None + else policy.require_mention + ) + respond_mention_all = ( + override.respond_to_mention_all + if override and override.respond_to_mention_all is not None + else policy.respond_to_mention_all + ) + mentioned_bot = bool( + bot_open_id and any(m.open_id == bot_open_id for m in msg.mentions) + ) + if require_mention: + if not mentioned_bot and not (respond_mention_all and msg.mentioned_all): + return PolicyDecision(False, "policy_no_mention") + + if msg.mentioned_all and not respond_mention_all and not mentioned_bot: + return PolicyDecision(False, "policy_mention_all_blocked") + + if policy.allow_from and not _matches_any_sender_identity(sender_ids, policy.allow_from): + return PolicyDecision(False, "policy_sender_not_allowed") + + return PolicyDecision(True) + + def _evaluate_dm( + self, msg: InboundMessage, policy: PolicyConfig, sender_ids: Set[str] + ) -> PolicyDecision: + if policy.dm_policy == "disabled": + return PolicyDecision(False, "policy_dm_disabled") + if policy.dm_policy == "blocklist": + if policy.deny_from and _matches_any_sender_identity(sender_ids, policy.deny_from): + return PolicyDecision(False, "policy_blocklist") + return PolicyDecision(True) + if policy.dm_policy == "allowlist": + if not policy.allow_from or not _matches_any_sender_identity(sender_ids, policy.allow_from): + return PolicyDecision(False, "policy_dm_not_in_allowlist") + return PolicyDecision(True) diff --git a/lark_channel/channel/safety/processing_lock.py b/lark_channel/channel/safety/processing_lock.py new file mode 100644 index 0000000..0c2669a --- /dev/null +++ b/lark_channel/channel/safety/processing_lock.py @@ -0,0 +1,81 @@ +"""Short-TTL in-memory lock that complements `SeenCache`. + +The two pieces of dedup state serve different purposes: + +- `SeenCache` = "this id finished processing at some point" — long TTL (12h), + survives across reconnects, optionally shared via Redis. +- `ProcessingLock` = "this id is being processed RIGHT NOW by this worker" — + short TTL (5min), memory-only. Needed because `SeenCache.add()` only happens + AFTER the handler returns, so during a long-running handler a WS reconnect could + re-deliver the same event and we'd process it twice. + +`acquire(id)` returns False when another coroutine still holds the lock. +""" + +import threading +import time + +DEFAULT_TTL_MS = 5 * 60 * 1000 +# How often to walk the internal dict purging expired entries. Cheap in +# absolute terms (a single pass over a dict that is bounded in practice by +# in-flight request concurrency), but bounded so we never do it more than +# once per minute even under heavy traffic. +DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000 + + +class ProcessingLock: + def __init__( + self, + ttl_ms: int = DEFAULT_TTL_MS, + *, + sweep_interval_ms: int = DEFAULT_SWEEP_INTERVAL_MS, + ) -> None: + self._ttl_ms = ttl_ms + self._sweep_interval_ms = sweep_interval_ms + self._locks: "dict[str, float]" = {} + self._mu = threading.Lock() + # Start "due for sweep right now" so the first `acquire` call also + # clears anything left over from a prior instance that shared the + # dict (there isn't one today, but belt-and-suspenders). + self._last_sweep_ms = 0 + + @staticmethod + def _now_ms() -> int: + # Monotonic clock: immune to NTP steps and DST jumps. Wall-clock + # would let a clock-rewind silently extend held locks past the + # intended TTL (or expire them early). + return int(time.monotonic() * 1000) + + def _maybe_sweep_locked(self, now_ms: int) -> None: + """Drop expired entries if we haven't swept recently. Call with + ``self._mu`` already held.""" + if now_ms - self._last_sweep_ms < self._sweep_interval_ms: + return + expired = [k for k, exp in self._locks.items() if exp <= now_ms] + for k in expired: + self._locks.pop(k, None) + self._last_sweep_ms = now_ms + + def acquire(self, id_: str) -> bool: + """Return True if lock was acquired, False if already held and fresh.""" + now_ms = self._now_ms() + with self._mu: + self._maybe_sweep_locked(now_ms) + exp = self._locks.get(id_) + if exp is not None and exp > now_ms: + return False + self._locks[id_] = now_ms + self._ttl_ms + return True + + def release(self, id_: str) -> None: + with self._mu: + self._locks.pop(id_, None) + + def size(self) -> int: + now_ms = self._now_ms() + with self._mu: + expired = [k for k, exp in self._locks.items() if exp <= now_ms] + for k in expired: + self._locks.pop(k, None) + self._last_sweep_ms = now_ms + return len(self._locks) diff --git a/lark_channel/channel/safety/stale_detector.py b/lark_channel/channel/safety/stale_detector.py new file mode 100644 index 0000000..2ab0c59 --- /dev/null +++ b/lark_channel/channel/safety/stale_detector.py @@ -0,0 +1,19 @@ +"""Drop messages that are too old to care about. + +Motivation: after a WebSocket reconnect, Feishu replays recent events it +believes we missed. If our process just restarted, that means we'll receive +messages from before the crash — which the user already stopped expecting a +reply to. We silently drop events older than `window_ms` (default 30 min). +""" + +import time + +DEFAULT_STALE_MS = 30 * 60 * 1000 + + +def is_stale(create_time_ms: int, window_ms: int = DEFAULT_STALE_MS) -> bool: + """Return True if `create_time_ms` is older than `window_ms` ago.""" + if not create_time_ms: + return False + now_ms = int(time.time() * 1000) + return (now_ms - create_time_ms) > window_ms diff --git a/lark_channel/channel/safety/types.py b/lark_channel/channel/safety/types.py new file mode 100644 index 0000000..fae568c --- /dev/null +++ b/lark_channel/channel/safety/types.py @@ -0,0 +1,101 @@ +"""Safety-layer primitive types. + +``DedupConfig``, ``TextBatchConfig``, and ``ChatQueueConfig`` used to live +here but were promoted into :mod:`..config` so the public schema is +self-contained and to break a circular import (safety eagerly imports +``SafetyPipeline`` which needs :class:`PolicyConfig`). They are re-exported +from this module as back-compat aliases. + +``MediaBatchConfig`` lives here and is consumed by +:class:`MediaPipelineManager` (wired into :class:`SafetyPipeline` so +compatible-kind media within ``delay_ms`` are merged into a single dispatch +carrying ``InboundMessage.batched_sources``). Default is ``enabled=False`` +so existing deployments see no behaviour change. ``BatchConfig`` is kept as +a legacy compound shape for back-compat and is not consumed by the pipeline. +""" + +from dataclasses import dataclass, field +from typing import Literal + +# Re-export from the public schema so existing `from .types import …` call +# sites inside the safety package keep working. +from ..config import ChatQueueConfig, DedupConfig, TextBatchConfig # noqa: F401 + +__all__ = [ + "BatchConfig", + "ChatQueueConfig", + "DedupConfig", + "MediaBatchConfig", + "RejectEvent", + "RejectReason", + "TextBatchConfig", +] + +# ---- Reject taxonomy -------------------------------------------------------- + +RejectReason = Literal[ + # Non-policy reasons + "stale", + "duplicate", + "lock_contention", + "self_sent", + # Policy reasons (unified policy_ prefix) + "policy_dm_disabled", + "policy_group_disabled", + "policy_dm_not_in_allowlist", + "policy_group_not_in_allowlist", + "policy_blocklist", + "policy_admin_only", + "policy_no_mention", + "policy_mention_all_blocked", + "policy_sender_not_allowed", +] + + +@dataclass +class RejectEvent: + """Emitted by SafetyPipeline when a message is filtered out. + + Carries just enough info for the caller to log or surface the decision. + Reasons include both policy decisions and runtime safety gates such as + stale messages, duplicate delivery, lock contention, and self-sent drops. + """ + + message_id: str + chat_id: str + sender_id: str + reason: RejectReason + + +# ---- Batch config ---------------------------------------------------------- + + +@dataclass +class MediaBatchConfig: + """Debounce + bundle successive media messages in the same chat. + + A run of compatible media messages (same kind, same chat, no intervening + text) within ``delay_ms`` is collapsed into a single dispatch carrying + :attr:`InboundMessage.batched_sources` — the list of source messages in + arrival order. + + Default: ``enabled=False`` so existing deployments see no behavior change. + Opt in by setting ``enabled=True``. + """ + + enabled: bool = False + delay_ms: int = 800 + max_items: int = 9 + compatible_kinds: frozenset = field( + default_factory=lambda: frozenset({"image", "file", "audio", "video"}) + ) + + +@dataclass +class BatchConfig: + """Reserved: text + media batch settings as a pair (legacy shape, kept + for back-compat — new code should use TextBatchConfig + MediaBatchConfig + directly via SafetyConfig).""" + + text: TextBatchConfig = field(default_factory=TextBatchConfig) + media: MediaBatchConfig = field(default_factory=MediaBatchConfig) diff --git a/lark_channel/channel/tests/__init__.py b/lark_channel/channel/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lark_channel/channel/tests/conftest.py b/lark_channel/channel/tests/conftest.py new file mode 100644 index 0000000..b1685be --- /dev/null +++ b/lark_channel/channel/tests/conftest.py @@ -0,0 +1 @@ +"""Shared pytest fixtures for the channel test suite.""" diff --git a/lark_channel/channel/tests/snapshots/markdown_structured.json b/lark_channel/channel/tests/snapshots/markdown_structured.json new file mode 100644 index 0000000..5b1def4 --- /dev/null +++ b/lark_channel/channel/tests/snapshots/markdown_structured.json @@ -0,0 +1,362 @@ +{ + "01_plain": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "这是普通文本,没有任何 markdown。" + } + ] + ], + "title": "" + } + }, + "02_bold": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "下面是 " + }, + { + "style": [ + "bold" + ], + "tag": "text", + "text": "粗体" + }, + { + "tag": "text", + "text": " 文字。" + } + ] + ], + "title": "" + } + }, + "03_italic": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "下面是 " + }, + { + "style": [ + "italic" + ], + "tag": "text", + "text": "斜体" + }, + { + "tag": "text", + "text": " 文字。" + } + ] + ], + "title": "" + } + }, + "04_bold_italic": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "组合 " + }, + { + "style": [ + "bold" + ], + "tag": "text", + "text": "*粗+斜" + }, + { + "tag": "text", + "text": "* 看一下。" + } + ] + ], + "title": "" + } + }, + "05_h1": { + "zh_cn": { + "content": [ + [ + { + "style": [ + "bold" + ], + "tag": "text", + "text": "H1 标题(应该字号最大)" + } + ] + ], + "title": "" + } + }, + "06_h2": { + "zh_cn": { + "content": [ + [ + { + "style": [ + "bold" + ], + "tag": "text", + "text": "H2 标题(中号)" + } + ] + ], + "title": "" + } + }, + "07_h3": { + "zh_cn": { + "content": [ + [ + { + "style": [ + "bold" + ], + "tag": "text", + "text": "H3 标题(小号)" + } + ] + ], + "title": "" + } + }, + "08_quote": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "│ " + }, + { + "tag": "text", + "text": "这是引用块第一行\n引用块第二行" + } + ] + ], + "title": "" + } + }, + "09_nested_quote": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "│ " + }, + { + "tag": "text", + "text": "外层引用\n> 嵌套引用" + } + ] + ], + "title": "" + } + }, + "10_ul": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "• 苹果" + } + ], + [ + { + "tag": "text", + "text": "• 香蕉" + } + ], + [ + { + "tag": "text", + "text": "• 樱桃" + } + ] + ], + "title": "" + } + }, + "11_ol": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "1. 第一" + } + ], + [ + { + "tag": "text", + "text": "2. 第二" + } + ], + [ + { + "tag": "text", + "text": "3. 第三" + } + ] + ], + "title": "" + } + }, + "12_nested_ul": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "• 顶层" + } + ], + [ + { + "tag": "text", + "text": "• 子项 1" + } + ], + [ + { + "tag": "text", + "text": "• 子项 2" + } + ] + ], + "title": "" + } + }, + "13_inline_code": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "命令是 " + }, + { + "style": [ + "code" + ], + "tag": "text", + "text": "git status" + }, + { + "tag": "text", + "text": " 用来查看状态。" + } + ] + ], + "title": "" + } + }, + "14_link": { + "zh_cn": { + "content": [ + [ + { + "tag": "text", + "text": "查看 " + }, + { + "href": "https://example.com", + "tag": "a", + "text": "文档" + }, + { + "tag": "text", + "text": " 了解更多。" + } + ] + ], + "title": "" + } + }, + "15_code_fence": { + "zh_cn": { + "content": [ + [ + { + "language": "PYTHON", + "tag": "code_block", + "text": "print('hi')" + } + ] + ], + "title": "" + } + }, + "16_composite": { + "zh_cn": { + "content": [ + [ + { + "style": [ + "bold" + ], + "tag": "text", + "text": "标题" + } + ], + [ + { + "style": [ + "bold" + ], + "tag": "text", + "text": "重点" + }, + { + "tag": "text", + "text": ":下面是清单。" + } + ], + [ + { + "tag": "text", + "text": "• a" + } + ], + [ + { + "tag": "text", + "text": "• b" + } + ], + [ + { + "tag": "text", + "text": "│ " + }, + { + "tag": "text", + "text": "引用" + } + ], + [ + { + "language": "PYTHON", + "tag": "code_block", + "text": "print('done')" + } + ] + ], + "title": "" + } + } +} diff --git a/lark_channel/channel/tests/test_additional_message_converters.py b/lark_channel/channel/tests/test_additional_message_converters.py new file mode 100644 index 0000000..b215e29 --- /dev/null +++ b/lark_channel/channel/tests/test_additional_message_converters.py @@ -0,0 +1,56 @@ +"""Parser tests for additional msg_type converters.""" + +import json + +from lark_channel.channel.normalize.registry import parse_message_content +from lark_channel.channel.types import ( + FolderContent, + GeneralCalendarContent, + HongbaoContent, + MediaContent, + ShareCalendarEventContent, +) + + +def _c(d): + return json.dumps(d, ensure_ascii=False) + + +def test_folder_parsed(): + c = parse_message_content("folder", _c({"file_name": "archive", "file_size": 1024})) + assert isinstance(c, FolderContent) + assert c.file_name == "archive" + assert c.file_size == 1024 + + +def test_hongbao_parsed(): + c = parse_message_content("hongbao", _c({"text": "恭喜发财", "amount": "888"})) + assert isinstance(c, HongbaoContent) + assert c.text == "恭喜发财" + assert c.amount == 888 + + +def test_general_calendar_parsed(): + c = parse_message_content( + "general_calendar", + _c({"summary": "Q3 Review", "start_time": 1700000000, "end_time": 1700003600}), + ) + assert isinstance(c, GeneralCalendarContent) + assert c.summary == "Q3 Review" + assert c.start_time == 1700000000 + + +def test_share_calendar_event_parsed(): + c = parse_message_content( + "share_calendar_event", + _c({"summary": "Demo", "organizer_display_name": "Alice"}), + ) + assert isinstance(c, ShareCalendarEventContent) + assert c.summary == "Demo" + assert c.organizer == "Alice" + + +def test_video_aliased_to_media(): + c = parse_message_content("video", _c({"file_key": "v_x", "duration": 5000})) + assert isinstance(c, MediaContent) + assert c.file_key == "v_x" diff --git a/lark_channel/channel/tests/test_bot_identity.py b/lark_channel/channel/tests/test_bot_identity.py new file mode 100644 index 0000000..2e18c8a --- /dev/null +++ b/lark_channel/channel/tests/test_bot_identity.py @@ -0,0 +1,152 @@ +"""Bot identity fetch — unit tests with mocked Transport.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from lark_channel.channel.bot_identity import BotIdentity, fetch_bot_identity +from lark_channel.core.model import Config, RawResponse + + +def _raw(body: dict, status: int = 200) -> RawResponse: + r = RawResponse() + r.status_code = status + r.content = json.dumps(body).encode("utf-8") + return r + + +def _fake_verify(cfg, request, option): + """Stub `core.token.auth.verify` so unit tests don't hit the real + tenant_access_token endpoint. The real function populates + ``option.tenant_access_token``; we just write a sentinel here. + """ + option.tenant_access_token = "t-test" + + +@pytest.mark.asyncio +async def test_fetch_uses_bot_v3_info_first(): + config = Config() + config.app_id = "cli_x" + config.app_secret = "sec" + + bot_v3_resp = _raw({ + "code": 0, "msg": "ok", + "data": {"bot": {"open_id": "ou_bot_1", "user_id": "u1", + "app_name": "Demo Bot", "app_id": "cli_x"}}, + }) + with patch("lark_channel.channel.bot_identity._verify_auth", side_effect=_fake_verify), \ + patch("lark_channel.channel.bot_identity.Transport.aexecute", + new=AsyncMock(return_value=bot_v3_resp)): + ident = await fetch_bot_identity(config) + assert isinstance(ident, BotIdentity) + assert ident.open_id == "ou_bot_1" + assert ident.name == "Demo Bot" + assert ident.user_id == "u1" + + +@pytest.mark.asyncio +async def test_fetch_falls_back_to_application_get(): + config = Config() + config.app_id = "cli_y" + config.app_secret = "sec" + + bot_v3_fail = _raw({"code": 99999, "msg": "blocked"}) + app_resp = _raw({ + "code": 0, + "data": {"app": {"app_name": "App Y", "bot_info": {"open_id": "ou_fallback"}}}, + }) + calls = [bot_v3_fail, app_resp] + + async def fake_aexecute(cfg, req, opt): + return calls.pop(0) + + with patch("lark_channel.channel.bot_identity._verify_auth", side_effect=_fake_verify), \ + patch("lark_channel.channel.bot_identity.Transport.aexecute", side_effect=fake_aexecute): + ident = await fetch_bot_identity(config) + assert ident is not None and ident.open_id == "ou_fallback" + assert ident.name == "App Y" + + +@pytest.mark.asyncio +async def test_fetch_returns_none_on_all_failures(): + config = Config() + config.app_id = "cli_z" + config.app_secret = "sec" + + with patch("lark_channel.channel.bot_identity._verify_auth", side_effect=_fake_verify), \ + patch("lark_channel.channel.bot_identity.Transport.aexecute", + new=AsyncMock(side_effect=RuntimeError("network"))): + ident = await fetch_bot_identity(config) + assert ident is None + + +@pytest.mark.asyncio +async def test_fetch_handles_top_level_payload(): + """Regression: /bot/v3/info returns {bot, code, msg} with no `data` + envelope — unlike most Feishu OpenAPI endpoints. The parser must + unwrap both shapes or the fetch fails silently even on HTTP 200. + """ + config = Config() + config.app_id = "cli_t" + config.app_secret = "sec" + + # Real shape observed from the production /bot/v3/info endpoint. + flat_resp = _raw({ + "bot": { + "open_id": "ou_real_bot", + "app_name": "Demo Bot", + "activate_status": 2, + }, + "code": 0, + "msg": "ok", + }) + with patch("lark_channel.channel.bot_identity._verify_auth", side_effect=_fake_verify), \ + patch("lark_channel.channel.bot_identity.Transport.aexecute", + new=AsyncMock(return_value=flat_resp)): + ident = await fetch_bot_identity(config) + + assert ident is not None, "flat payload must be parsed, not treated as failure" + assert ident.open_id == "ou_real_bot" + assert ident.name == "Demo Bot" + + +@pytest.mark.asyncio +async def test_fetch_injects_tenant_token_into_request(): + """Regression: `fetch_bot_identity` must run the auth-verify step before + calling `Transport.aexecute`. Without it, `option.tenant_access_token` + stays None and the outbound header becomes literal ``Bearer None``, which + Feishu rejects with 400 — leaving bot identity unresolved and breaking + group @Bot detection. + """ + config = Config() + config.app_id = "cli_w" + config.app_secret = "sec" + + bot_v3_resp = _raw({ + "code": 0, + "data": {"bot": {"open_id": "ou_bot", "app_id": "cli_w"}}, + }) + + captured = {} + + async def spy_aexecute(cfg, req, opt): + # Record the option.tenant_access_token that the caller prepared. + captured["token"] = opt.tenant_access_token + return bot_v3_resp + + # Stub `verify` so we don't actually hit the token endpoint but we *do* + # observe that the verify step is invoked with the same (req, option) + # that later reaches aexecute. + with patch("lark_channel.channel.bot_identity._verify_auth", side_effect=_fake_verify) as verify_mock, \ + patch("lark_channel.channel.bot_identity.Transport.aexecute", side_effect=spy_aexecute): + ident = await fetch_bot_identity(config) + + assert ident is not None and ident.open_id == "ou_bot" + # Verify was actually called before aexecute (otherwise the captured + # token would be None). + assert verify_mock.called, "fetch_bot_identity must invoke token.auth.verify" + assert captured["token"] == "t-test", ( + "tenant token must be set on option before aexecute — otherwise the " + "request goes out with `Bearer None`" + ) diff --git a/lark_channel/channel/tests/test_card_builder.py b/lark_channel/channel/tests/test_card_builder.py new file mode 100644 index 0000000..7ce1767 --- /dev/null +++ b/lark_channel/channel/tests/test_card_builder.py @@ -0,0 +1,119 @@ +"""Card builder tests.""" + +from lark_channel.channel.card.builder import card + + +def test_minimal_card(): + c = card().markdown("hi").build() + assert c.version == "v2" + assert c.data["schema"] == "2.0" + assert c.data["body"]["elements"][0] == {"tag": "markdown", "content": "hi"} + + +def test_header(): + c = card().header(title="H", subtitle="sub", template="blue").markdown("x").build() + assert c.data["header"]["title"]["content"] == "H" + assert c.data["header"]["subtitle"]["content"] == "sub" + assert c.data["header"]["template"] == "blue" + + +def test_button_emitted_as_top_level_v2(): + c = ( + card() + .button(label="Approve", action={"type": "approve"}, style="primary") + .build() + ) + el = c.data["body"]["elements"][0] + # CardKit v2 dropped the `action` wrapper — button is top-level + assert el["tag"] == "button" + assert el["type"] == "primary" + assert el["value"] == {"type": "approve"} + assert el["text"]["content"] == "Approve" + + +def test_divider_and_image(): + c = card().divider().image("img_x", alt="A").build() + body = c.data["body"]["elements"] + assert body[0]["tag"] == "hr" + assert body[1]["tag"] == "img" + assert body[1]["img_key"] == "img_x" + + +def test_raw_passthrough(): + c = card().raw({"tag": "custom_element", "payload": 1}).build() + assert c.data["body"]["elements"][0] == {"tag": "custom_element", "payload": 1} + + +def test_column_set_embeds_subbuilder_elements(): + col = card().markdown("a") + col2 = card().markdown("b") + c = card().column_set([col, col2]).build() + cs = c.data["body"]["elements"][0] + assert cs["tag"] == "column_set" + assert len(cs["columns"]) == 2 + assert cs["columns"][0]["elements"][0]["content"] == "a" + + +def test_progress_bar_renders_percent(): + c = card().progress(42, label="Deploying").build() + md = c.data["body"]["elements"][0]["content"] + assert "42%" in md + + +def test_streaming_flag_sets_config(): + c = card().streaming(True).markdown("...").build() + assert c.data["config"].get("streaming_mode") is True + + +def test_table_emits_native_table_component(): + # Previously `.table()` stuffed a GFM pipe-table into a markdown element + # where Feishu silently drops pipe syntax. It must now emit the native + # Card 2.0 `table` component. + c = card().table( + headers=["Name", "Score"], + rows=[["Alice", "90"], ["Bob", "80"]], + ).build() + el = c.data["body"]["elements"][0] + assert el["tag"] == "table" + assert el["columns"] == [ + {"name": "col_0", "display_name": "Name", "data_type": "text"}, + {"name": "col_1", "display_name": "Score", "data_type": "text"}, + ] + assert el["rows"] == [ + {"col_0": "Alice", "col_1": "90"}, + {"col_0": "Bob", "col_1": "80"}, + ] + + +def test_table_auto_picks_lark_md_for_inline_markdown(): + c = card().table( + headers=["Name", "Status"], + rows=[["**Alice**", "ok"], ["Bob", "`done`"]], + ).build() + cols = c.data["body"]["elements"][0]["columns"] + assert cols[0]["data_type"] == "lark_md" # **Alice** + assert cols[1]["data_type"] == "lark_md" # `done` + + +def test_table_pads_short_rows_and_clamps_page_size(): + c = card().table( + headers=["a", "b", "c"], + rows=[["1"], ["2", "3", "4", "5"]], + page_size=99, + ).build() + el = c.data["body"]["elements"][0] + assert el["page_size"] == 10 + assert el["rows"] == [ + {"col_0": "1", "col_1": "", "col_2": ""}, + {"col_0": "2", "col_1": "3", "col_2": "4"}, + ] + + +def test_table_rejects_mismatched_data_types(): + import pytest + with pytest.raises(ValueError): + card().table( + headers=["a", "b"], + rows=[["1", "2"]], + data_types=["text"], + ) diff --git a/lark_channel/channel/tests/test_cardkit_preallocation.py b/lark_channel/channel/tests/test_cardkit_preallocation.py new file mode 100644 index 0000000..a56888e --- /dev/null +++ b/lark_channel/channel/tests/test_cardkit_preallocation.py @@ -0,0 +1,111 @@ +"""Cardkit preallocation API tests (node-aligned). + +Covers :meth:`FeishuChannel.create_card_instance`, +:meth:`send_card_by_reference`, :meth:`update_card_element_content`, +:meth:`finish_streaming_card`. +""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from lark_channel.channel import FeishuChannel +from lark_channel.channel.errors import FeishuChannelError + + +@pytest.fixture +def channel() -> FeishuChannel: + return FeishuChannel(app_id="cli_x", app_secret="s") + + +@pytest.mark.asyncio +async def test_create_card_instance_returns_card_id(channel): + channel._driver.cardkit_create = AsyncMock( + return_value={"code": 0, "data": {"card_id": "AAQ_card_xyz"}} + ) + cid = await channel.create_card_instance({"schema": "2.0"}) + assert cid == "AAQ_card_xyz" + call = channel._driver.cardkit_create.await_args + body = call.kwargs["body"] + assert body["type"] == "card_json" + assert json.loads(body["data"]) == {"schema": "2.0"} + + +@pytest.mark.asyncio +async def test_create_card_instance_failure_raises(channel): + channel._driver.cardkit_create = AsyncMock( + return_value={"code": 400, "msg": "bad spec"} + ) + with pytest.raises(FeishuChannelError): + await channel.create_card_instance({"schema": "2.0"}) + + +@pytest.mark.asyncio +async def test_create_card_instance_missing_id_raises(channel): + channel._driver.cardkit_create = AsyncMock(return_value={"code": 0, "data": {}}) + with pytest.raises(FeishuChannelError): + await channel.create_card_instance({"schema": "2.0"}) + + +@pytest.mark.asyncio +async def test_send_card_by_reference_builds_interactive_message(channel): + channel._sender.send = AsyncMock() + await channel.send_card_by_reference("oc_chat_1", "card_abc") + call = channel._sender.send.await_args + sent = call.args[0] + # OutboundCard with the reference-shape card payload + assert sent.card == {"type": "card", "data": {"card_id": "card_abc"}} + assert call.kwargs["receive_id"] == "oc_chat_1" + assert call.kwargs["receive_id_type"] == "chat_id" + + +@pytest.mark.asyncio +async def test_send_card_by_reference_explicit_receive_type(channel): + channel._sender.send = AsyncMock() + await channel.send_card_by_reference( + "user@example.com", "card_abc", receive_id_type="email" + ) + assert channel._sender.send.await_args.kwargs["receive_id_type"] == "email" + + +@pytest.mark.asyncio +async def test_update_card_element_content_passes_sequence(channel): + channel._driver.cardkit_update_element = AsyncMock(return_value={"code": 0}) + await channel.update_card_element_content( + "card_abc", "el_1", "hello world", 7 + ) + call = channel._driver.cardkit_update_element.await_args + assert call.kwargs["card_id"] == "card_abc" + assert call.kwargs["element_id"] == "el_1" + assert call.kwargs["body"] == {"content": "hello world", "sequence": 7} + + +@pytest.mark.asyncio +async def test_update_card_element_content_failure_raises(channel): + channel._driver.cardkit_update_element = AsyncMock( + return_value={"code": 400, "msg": "sequence out-of-order"} + ) + with pytest.raises(FeishuChannelError): + await channel.update_card_element_content("card_abc", "el_1", "...", 2) + + +@pytest.mark.asyncio +async def test_finish_streaming_card_posts_streaming_false(channel): + channel._driver.cardkit_update_settings = AsyncMock(return_value={"code": 0}) + await channel.finish_streaming_card("card_abc", 99) + call = channel._driver.cardkit_update_settings.await_args + assert call.kwargs["card_id"] == "card_abc" + body = call.kwargs["body"] + assert body["sequence"] == 99 + parsed = json.loads(body["settings"]) + assert parsed == {"config": {"streaming_mode": False}} + + +@pytest.mark.asyncio +async def test_finish_streaming_card_failure_raises(channel): + channel._driver.cardkit_update_settings = AsyncMock( + return_value={"code": 400, "msg": "sequence out-of-order"} + ) + with pytest.raises(FeishuChannelError): + await channel.finish_streaming_card("card_abc", 1) diff --git a/lark_channel/channel/tests/test_channel_low_level.py b/lark_channel/channel/tests/test_channel_low_level.py new file mode 100644 index 0000000..26a332b --- /dev/null +++ b/lark_channel/channel/tests/test_channel_low_level.py @@ -0,0 +1,364 @@ +"""Low-level FeishuChannel helpers: recall / add_reaction / update_card / +download_resource / disconnect / client accessors.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from lark_channel.channel import FeishuChannel + + +@pytest.fixture +def channel(): + return FeishuChannel(app_id="cli_x", app_secret="s") + + +@pytest.mark.asyncio +async def test_update_card_calls_underlying_patch(channel): + channel._driver.patch_message = AsyncMock(return_value={"code": 0}) + r = await channel.update_card("om_1", {"schema": "2.0"}) + assert r.success is True + channel._driver.patch_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_card_failure_returns_failed_result(channel): + channel._driver.patch_message = AsyncMock( + return_value={"code": 230001, "msg": "invalid card"} + ) + r = await channel.update_card("om_1", {"schema": "2.0"}) + assert r.success is False + assert r.error is not None + assert r.error.raw_code == 230001 + assert r.raw == {"code": 230001, "msg": "invalid card"} + + +@pytest.mark.asyncio +async def test_recall_message_success(channel): + channel._driver.delete_message = AsyncMock(return_value={"code": 0}) + r = await channel.recall_message("om_2") + assert r.success is True + + +@pytest.mark.asyncio +async def test_recall_message_failure_surfaces(channel): + channel._driver.delete_message = AsyncMock(return_value={"code": 230002, "msg": "not exist"}) + r = await channel.recall_message("om_missing") + assert r.success is False + assert r.error is not None + + +@pytest.mark.asyncio +async def test_add_reaction_success(channel): + channel._driver.add_reaction = AsyncMock(return_value={"code": 0}) + r = await channel.add_reaction("om_1", "THUMBSUP") + assert r.success is True + + +@pytest.mark.asyncio +async def test_remove_reaction_success(channel): + channel._driver.remove_reaction = AsyncMock(return_value={"code": 0}) + r = await channel.remove_reaction("om_1", "rxn_1") + assert r.success is True + + +@pytest.mark.asyncio +async def test_remove_reaction_by_emoji_lists_then_deletes_first_match(channel): + channel._driver.list_reactions = AsyncMock( + return_value={ + "code": 0, + "data": { + "items": [ + {"reaction_id": "r1", "reaction_type": {"emoji_type": "SMILE"}}, + {"reaction_id": "r2", "reaction_type": {"emoji_type": "THUMBSUP"}}, + ] + }, + } + ) + channel._driver.remove_reaction = AsyncMock(return_value={"code": 0}) + + result = await channel.remove_reaction_by_emoji("om_1", "THUMBSUP") + + assert result.success is True + channel._driver.list_reactions.assert_awaited_once_with( + message_id="om_1", + emoji_type="THUMBSUP", + page_token=None, + page_size=None, + ) + channel._driver.remove_reaction.assert_awaited_once_with( + message_id="om_1", + reaction_id="r2", + ) + + +@pytest.mark.asyncio +async def test_remove_reaction_by_emoji_returns_failed_result_when_no_match(channel): + channel._driver.list_reactions = AsyncMock( + return_value={"code": 0, "data": {"items": []}} + ) + + result = await channel.remove_reaction_by_emoji("om_1", "SMILE") + + assert result.success is False + assert result.error is not None + + +@pytest.mark.asyncio +async def test_fetch_inbound_message_returns_normalized_message_without_changing_fetch_message(channel): + raw = { + "code": 0, + "data": { + "items": [ + { + "message_id": "om_1", + "create_time": "123", + "chat_id": "oc_1", + "chat_type": "group", + "message_type": "text", + "content": json.dumps({"text": "hello @_user_1"}), + "mentions": [ + {"key": "@_user_1", "id": {"open_id": "ou_bot"}, "name": "Bot"} + ], + "sender": { + "sender_id": {"open_id": "ou_sender", "user_id": "u1"}, + "sender_type": "user", + }, + } + ] + }, + } + channel._driver.fetch_message = AsyncMock(return_value=raw) + channel._pipeline.set_bot_open_id("ou_bot") + + assert await channel.fetch_message("om_1") == raw + inbound = await channel.fetch_inbound_message("om_1") + + assert inbound is not None + assert inbound.id == "om_1" + assert inbound.content_text == "hello @Bot" + assert inbound.mentioned_bot is True + assert inbound.raw["message_id"] == "om_1" + + +@pytest.mark.asyncio +async def test_fetch_inbound_message_handles_get_message_sender_id_type(channel): + raw = { + "code": 0, + "data": { + "items": [ + { + "message_id": "om_1", + "create_time": 123, + "chat_id": "oc_1", + "msg_type": "text", + "body": {"content": json.dumps({"text": "hello"})}, + "sender": { + "id": "u_sender", + "id_type": "user_id", + "sender_type": "user", + }, + "mentions": [], + } + ] + }, + } + channel._driver.fetch_message = AsyncMock(return_value=raw) + + inbound = await channel.fetch_inbound_message("om_1") + + assert inbound is not None + assert inbound.content_text == "hello" + assert inbound.sender.open_id == "" + assert inbound.sender.user_id == "u_sender" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("id_type", ["open_id", None]) +async def test_fetch_inbound_message_maps_open_id_sender_variants(channel, id_type): + sender = { + "id": "ou_sender", + "sender_type": "user", + } + if id_type is not None: + sender["id_type"] = id_type + raw = { + "code": 0, + "data": { + "items": [ + { + "message_id": "om_1", + "create_time": 123, + "chat_id": "oc_1", + "msg_type": "text", + "body": {"content": json.dumps({"text": "hello"})}, + "sender": sender, + "mentions": [], + } + ] + }, + } + channel._driver.fetch_message = AsyncMock(return_value=raw) + + inbound = await channel.fetch_inbound_message("om_1") + + assert inbound is not None + assert inbound.sender.open_id == "ou_sender" + assert inbound.sender.user_id is None + + +@pytest.mark.asyncio +async def test_fetch_inbound_message_bypasses_pipeline_dedup(channel): + message = { + "message_id": "om_dedup", + "create_time": "123", + "chat_id": "oc_1", + "chat_type": "p2p", + "message_type": "text", + "content": json.dumps({"text": "hello"}), + "mentions": [], + } + sender = { + "sender_id": {"open_id": "ou_sender", "user_id": "u1"}, + "sender_type": "user", + } + + first = await channel._pipeline.process( + event_id="e1", + message_event=message, + sender=sender, + ) + duplicate = await channel._pipeline.process( + event_id="e2", + message_event=message, + sender=sender, + ) + channel._driver.fetch_message = AsyncMock( + return_value={"code": 0, "data": {"items": [{**message, "sender": sender}]}} + ) + + fetched = await channel.fetch_inbound_message("om_dedup") + + assert first is not None + assert duplicate is None + assert fetched is not None + assert fetched.id == "om_dedup" + + +@pytest.mark.asyncio +async def test_download_resource_delegates_to_hook(channel): + channel._download_media = AsyncMock(return_value=b"\x89PNG...") + data = await channel.download_resource("img_xxx", resource_type="image") + assert data == b"\x89PNG..." + + +def test_client_exposes_underlying(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + assert channel.client is channel._client + + +def test_dispatcher_accessor_triggers_build(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + channel._ensure_bg_loop() + d = channel.dispatcher + assert d is not None + + +def test_bot_identity_accessor_default_none(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + assert channel.bot_identity is None + + +def test_update_policy_syncs_into_channel_config(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + channel._ensure_bg_loop() # spin safety up + channel.update_policy(dm_policy="disabled", require_mention=False) + assert channel.get_policy().dm_policy == "disabled" + assert channel.get_policy().require_mention is False + + +@pytest.mark.asyncio +async def test_disconnect_drains_safety_and_stops(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + channel._ensure_bg_loop() + await channel.disconnect() + # After disconnect() the bg loop + ws client + thread are torn down, + # and the started flag is reset so a subsequent connect() can re-run. + # ``_shutdown`` is cleared at the end of stop() so the channel can be + # reconnected later; we assert on the + # observable state that actually matters. + assert channel._bg_loop is None + assert channel._ws_client is None + assert channel._started is False + + +@pytest.mark.asyncio +async def test_connect_is_idempotent_when_started(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + channel._started = True # pretend start() already ran + # Should return quickly without attempting to start WS + await channel.connect() + + +@pytest.mark.asyncio +async def test_send_stream_unknown_kind_raises(): + channel = FeishuChannel(app_id="cli_x", app_secret="s") + with pytest.raises(TypeError): + await channel.stream("oc_x", {"nonsense": "body"}) + + +@pytest.mark.asyncio +async def test_send_unknown_keys_raises(): + from lark_channel.channel._coerce import coerce_outbound as _coerce_outbound + with pytest.raises(TypeError): + _coerce_outbound({"unknown_key": "x"}) + + +@pytest.mark.asyncio +async def test_send_media_buffer_coercion(): + from lark_channel.channel._coerce import coerce_outbound as _coerce_outbound + from lark_channel.channel.types import OutboundFile + ob = _coerce_outbound({"file": {"source": b"\x01\x02", "fileName": "f.bin"}}) + assert isinstance(ob, OutboundFile) + assert ob.source.kind == "buffer" + + +@pytest.mark.asyncio +async def test_send_media_source_media_ref_passes_through(): + from lark_channel.channel._coerce import coerce_outbound as _coerce_outbound + from lark_channel.channel.types import MediaSource, OutboundImage + ob = _coerce_outbound({"image": {"source": MediaSource(kind="key", key="img_x")}}) + assert isinstance(ob, OutboundImage) + assert ob.source.key == "img_x" + + +@pytest.mark.asyncio +async def test_send_markdown_stream_invokes_producer(channel): + # MarkdownStream now uses the CardKit preallocation flow — mock the 4 + # channel methods it calls. Verify seq-ordered element updates + finish. + channel.create_card_instance = AsyncMock(return_value="card_xyz") + + from lark_channel.channel.types import SendResult as _SR + channel.send_card_by_reference = AsyncMock( + return_value=_SR.ok(message_id="om_fake_stream"), + ) + channel.update_card_element_content = AsyncMock(return_value=None) + channel.finish_streaming_card = AsyncMock(return_value=None) + + got_controller = [] + + async def producer(s): + got_controller.append(s) + await s.append("hello") + + result = await channel.stream("oc_1", {"markdown": producer}) + assert result.success is True + assert result.message_id == "om_fake_stream" + assert got_controller # producer ran + channel.create_card_instance.assert_awaited_once() + channel.send_card_by_reference.assert_awaited_once() + channel.finish_streaming_card.assert_awaited_once() + # At least one element update was issued via the seq-ordered API, + # NOT via the generic patch API. + assert channel.update_card_element_content.await_count >= 1 diff --git a/lark_channel/channel/tests/test_chunker.py b/lark_channel/channel/tests/test_chunker.py new file mode 100644 index 0000000..fd84fe9 --- /dev/null +++ b/lark_channel/channel/tests/test_chunker.py @@ -0,0 +1,38 @@ +"""Tests for the outbound text chunker.""" + +from lark_channel.channel.outbound.sender import chunk_text + + +def test_short_text_passthrough(): + assert chunk_text("hi", limit=4000) == ["hi"] + + +def test_empty_returns_empty_list(): + assert chunk_text("", limit=4000) == [] + + +def test_newline_boundary(): + body = "abc\n" * 100 # 400 chars + chunks = chunk_text(body, limit=50, mode="newline") + assert all(len(c) <= 50 for c in chunks) + # Re-joining with the delimiter should yield original (minus trimmed trailing) + joined = "\n".join(chunks) + # Accept either trailing newline or not — reconstruction is approximate. + assert joined.replace("\n", "") == body.replace("\n", "") + + +def test_hard_cut_when_no_delimiter(): + body = "x" * 123 + chunks = chunk_text(body, limit=30, mode="newline") + assert chunks == ["x" * 30, "x" * 30, "x" * 30, "x" * 30, "x" * 3] + + +def test_paragraph_mode(): + body = "para1 line1\npara1 line2\n\npara2 line1\n\npara3" + chunks = chunk_text(body, limit=20, mode="paragraph") + assert chunks + + +def test_none_mode_hard_cut(): + chunks = chunk_text("abcdefghij", limit=3, mode="none") + assert chunks == ["abc", "def", "ghi", "j"] diff --git a/lark_channel/channel/tests/test_client_handlers.py b/lark_channel/channel/tests/test_client_handlers.py new file mode 100644 index 0000000..3166854 --- /dev/null +++ b/lark_channel/channel/tests/test_client_handlers.py @@ -0,0 +1,316 @@ +"""FeishuChannel's per-event async handlers — exercised with fake P2 payloads. + +Drives `_handle_*_event` directly, bypassing the WS / dispatcher layer. +Uses the public `channel.on("event_name", handler)` registration API. +""" + +import json +from types import SimpleNamespace + +import pytest + +from lark_channel.channel import FeishuChannel as _ChannelClient +from lark_channel.channel.config import InboundConfig +from lark_channel.channel.safety import RejectEvent +from lark_channel.api.im.v1.model.p2_im_message_reaction_created_v1 import ( + P2ImMessageReactionCreatedV1, +) +from lark_channel.channel.types import CardActionPayload +from lark_channel.event.callback.model.p2_card_action_trigger import P2CardActionTrigger + + +def _client(**kwargs): + return _ChannelClient(app_id="cli_x", app_secret="s", **kwargs) + + +# ---- interaction handler ------------------------------------------------- + + +def _fake_card_action(*, action_value, tag="button", message_id="om_xyz", + operator_open_id="ou_op"): + """SimpleNamespace mimicking P2CardActionTrigger attribute tree (bypasses + the generated model's strict typing that forbids string action.value).""" + return SimpleNamespace( + event=SimpleNamespace( + action=SimpleNamespace(tag=tag, value=action_value), + operator=SimpleNamespace(open_id=operator_open_id), + context=SimpleNamespace(open_message_id=message_id), + ), + ) + + +@pytest.mark.asyncio +async def test_handle_interaction_event_passes_parsed_action(): + c = _client() + got = [] + c.on("cardAction", lambda event: got.append(event)) + data = _fake_card_action(action_value=json.dumps({"kind": "rate", "score": "up"})) + await c._handle_interaction_event(data) + assert len(got) == 1 + event = got[0] + assert event.action.value == {"kind": "rate", "score": "up"} + assert event.message_id == "om_xyz" + assert event.operator.open_id == "ou_op" + assert event.action.tag == "button" + + +@pytest.mark.asyncio +async def test_handle_interaction_event_non_json_value_wrapped(): + c = _client() + got = [] + c.on("cardAction", lambda event: got.append(event)) + data = _fake_card_action(action_value="plain-string-not-json") + await c._handle_interaction_event(data) + assert got[0].action.value == {"value": "plain-string-not-json"} + + +@pytest.mark.asyncio +async def test_handle_interaction_event_exposes_cardkit_form_fields(): + c = _client() + got = [] + c.on("cardAction", lambda event: got.append(event)) + data = SimpleNamespace( + event=SimpleNamespace( + action=SimpleNamespace( + tag="form", + value={"legacy": "v"}, + form_value={"field": "value"}, + input_value="typed", + options=["a", "b"], + checked=True, + ), + operator=SimpleNamespace(open_id="ou_op"), + context=SimpleNamespace(open_message_id="om_xyz", open_chat_id="oc_1"), + ), + ) + + await c._handle_interaction_event(data) + + assert got[0].action.value == {"legacy": "v"} + assert got[0].action.form_value == {"field": "value"} + assert got[0].action.input_value == "typed" + assert got[0].action.options == ["a", "b"] + assert got[0].action.checked is True + + +def test_card_action_payload_form_fields_do_not_affect_equality_or_repr(): + a = CardActionPayload(tag="form", value={"legacy": "v"}) + b = CardActionPayload( + tag="form", + value={"legacy": "v"}, + form_value={"field": "value"}, + input_value="typed", + options=["a"], + checked=True, + ) + + assert a == b + text = repr(b) + assert "form_value" not in text + assert "input_value" not in text + assert "options" not in text + assert "checked" not in text + + +@pytest.mark.asyncio +async def test_handle_interaction_event_no_handler_is_noop(): + c = _client() + # No cardAction handler registered — shouldn't raise + data = P2CardActionTrigger({"event": {}}) + await c._handle_interaction_event(data) + + +# ---- raw handler --------------------------------------------------------- + + +def _fake_message_event(message_id="om_raw"): + return SimpleNamespace( + header=SimpleNamespace(event_id="evt_raw"), + event=SimpleNamespace( + sender=SimpleNamespace( + sender_id=SimpleNamespace(open_id="ou_sender", user_id="u1"), + sender_type="user", + ), + message=SimpleNamespace( + message_id=message_id, + create_time="1000", + chat_id="oc_1", + chat_type="p2p", + message_type="text", + content=json.dumps({"text": "hello"}), + mentions=[], + ), + ), + ) + + +@pytest.mark.asyncio +async def test_handle_message_event_does_not_emit_raw_by_default(): + c = _client() + raw_events = [] + messages = [] + c.on("raw", lambda event: raw_events.append(event)) + c.on("message", lambda event: messages.append(event)) + + await c._handle_message_event(_fake_message_event()) + + assert len(messages) == 1 + assert raw_events == [] + + +@pytest.mark.asyncio +async def test_handle_message_event_emits_raw_when_enabled(): + c = _client(inbound=InboundConfig(emit_raw_events=True)) + raw_events = [] + c.on("raw", lambda event: raw_events.append(event)) + + await c._handle_message_event(_fake_message_event()) + + assert len(raw_events) == 1 + assert raw_events[0]["event"]["message"]["message_id"] == "om_raw" + + +# ---- reaction handler --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_reaction_event_off_mode_drops(): + c = _client() + c._config.inbound.reaction_notifications = "off" + got = [] + c.on("reaction", lambda event: got.append(event)) + data = P2ImMessageReactionCreatedV1({ + "event": { + "message_id": "om_1", + "reaction_type": {"emoji_type": "HEART"}, + "user_id": {"open_id": "ou_r"}, + }, + }) + await c._handle_reaction_event(data, action="create") + assert got == [] + + +# ---- bot add / leave handlers ------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_bot_event_join_dispatches(): + c = _client() + got = [] + c.on("botAdded", lambda event: got.append(event)) + + data = SimpleNamespace( + event=SimpleNamespace( + chat_id="oc_new", + operator_id=SimpleNamespace(open_id="ou_op"), + ), + ) + await c._handle_bot_event(data, joined=True) + assert len(got) == 1 + assert got[0].chat_id == "oc_new" + assert got[0].operator.open_id == "ou_op" + + +@pytest.mark.asyncio +async def test_handle_bot_event_leave_dispatches_when_handler_set(): + c = _client() + got = [] + c.on("botLeave", lambda event: got.append(event)) + + data = SimpleNamespace( + event=SimpleNamespace(chat_id="oc_gone", operator_id=None), + ) + await c._handle_bot_event(data, joined=False) + assert len(got) == 1 + + +@pytest.mark.asyncio +async def test_handle_bot_event_no_handler_noop(): + c = _client() + data = SimpleNamespace(event=SimpleNamespace(chat_id="oc_x", operator_id=None)) + await c._handle_bot_event(data, joined=True) # no raise + + +# ---- message_read handler ----------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_message_read_event_dispatches(): + c = _client() + got = [] + c.on("messageRead", lambda event: got.append(event)) + data = SimpleNamespace(event=SimpleNamespace( + reader=SimpleNamespace(reader_id=SimpleNamespace(open_id="ou_reader")), + message_id_list=["om_1", "om_2"], + )) + await c._handle_message_read_event(data) + assert len(got) == 1 + assert got[0].message_ids == ["om_1", "om_2"] + assert got[0].reader.open_id == "ou_reader" + + +# ---- require_user_auth error paths -------------------------------------- + + +@pytest.mark.asyncio +async def test_require_user_auth_raises_when_scope_blocked(): + from lark_channel.channel.errors import UATAuthError + + c = _client() + c._config.uat.blocked_scopes = ["im:admin"] + with pytest.raises(UATAuthError): + await c.require_user_auth("ou_user", ["im:admin"]) + + +@pytest.mark.asyncio +async def test_require_user_auth_raises_when_scope_not_allowed(): + from lark_channel.channel.errors import UATAuthError + + c = _client() + c._config.uat.allowed_scopes = ["im:message"] + with pytest.raises(UATAuthError): + await c.require_user_auth("ou_user", ["wiki:write"]) + + +@pytest.mark.asyncio +async def test_require_user_auth_returns_existing_uat_when_fresh(): + import time + from lark_channel.channel.types import UAT + + c = _client() + fresh = UAT( + access_token="t", + refresh_token="r", + expires_at=time.time() + 3600, + scopes=["im:message"], + ) + await c._token_store.set("ou_me", fresh) + got = await c.require_user_auth("ou_me", ["im:message"]) + assert got is fresh + + +# ---- dispatcher property builds lazily ---------------------------------- + + +def test_dispatcher_property_builds_lazily(): + c = _client() + assert c._dispatcher is None + d = c.dispatcher + assert d is not None + assert c._dispatcher is d + + +# ---- _emit_reject with exception in handler is logged, not raised ------- + + +def test_emit_reject_handler_exception_is_swallowed(caplog): + c = _client() + + def bad(_): + raise ValueError("handler bug") + + c.on("reject", bad) + c._emit_reject(RejectEvent( + message_id="om_x", chat_id="oc_1", sender_id="ou_1", reason="policy_no_mention", + )) + assert any("raised" in (r.message or "") for r in caplog.records) diff --git a/lark_channel/channel/tests/test_client_lifecycle.py b/lark_channel/channel/tests/test_client_lifecycle.py new file mode 100644 index 0000000..2d7aba5 --- /dev/null +++ b/lark_channel/channel/tests/test_client_lifecycle.py @@ -0,0 +1,494 @@ +"""Coverage for `_ChannelClient` lifecycle internals without touching the network. + +The `start()` method is hard to exercise (opens a WS connection); but the +pieces it wires up — bg loop, scheduling, sent-message tracking, safety +pipeline construction, dispatcher building — are all testable in isolation. +""" + +import asyncio +import threading +from unittest.mock import patch + +import pytest + +from lark_channel.channel import FeishuChannel as _ChannelClient +from lark_channel.channel.bot_identity import BotIdentity +from lark_channel.channel.config import TransportConfig +from lark_channel.channel.errors import FeishuChannelError, FeishuChannelErrorCode + + +def _client() -> _ChannelClient: + return _ChannelClient(app_id="cli_test", app_secret="sec") + + +def test_build_does_not_spin_up_bg_loop(): + c = _client() + assert c._bg_loop is None + assert c._safety is None + + +def test_start_passes_handshake_timeout_to_ws_client(): + c = _ChannelClient( + app_id="cli_test", + app_secret="sec", + transport=TransportConfig(handshake_timeout_seconds=4.0), + ) + calls = {} + + class _ReadyWS: + def __init__(self, *args, **kwargs): + calls["kwargs"] = kwargs + self._conn = object() + self.on_reconnecting = lambda: None + self.on_reconnected = lambda: None + + def start(self): + return None + + def stop(self): + return None + + with patch("lark_channel.channel.channel.WSClient", _ReadyWS), patch.object( + c, "_fetch_bot_identity_sync", lambda: None + ): + c.start() + c.stop() + + assert calls["kwargs"]["handshake_timeout"] == 4.0 + + +def test_connection_snapshot_initial_state(): + c = _client() + + snapshot = c.connection_snapshot() + + assert snapshot.state == "idle" + assert snapshot.ready is False + assert snapshot.reconnect_attempts == 0 + assert snapshot.last_connected_at is None + assert snapshot.last_disconnected_at is None + assert snapshot.last_error_at is None + assert snapshot.last_error is None + + +def test_connection_snapshot_tracks_reconnect_callbacks(): + c = _client() + + c._notify_reconnecting() + reconnecting = c.connection_snapshot() + c._notify_reconnected() + reconnected = c.connection_snapshot() + + assert reconnecting.state == "reconnecting" + assert reconnecting.reconnect_attempts == 1 + assert reconnected.state == "connected" + assert reconnected.ready is False + assert reconnected.last_connected_at is not None + + +def test_ensure_bg_loop_is_idempotent(): + c = _client() + c._ensure_bg_loop() + loop = c._bg_loop + assert loop is not None + assert c._safety is not None + c._ensure_bg_loop() # no-op + assert c._bg_loop is loop + + +def test_schedule_runs_coroutine_on_bg_loop(): + c = _client() + done = threading.Event() + ran_on = {} + + async def task(): + ran_on["loop"] = asyncio.get_running_loop() + done.set() + + c.schedule(task()) + assert done.wait(2.0), "coroutine never fired" + assert ran_on["loop"] is c._bg_loop + + +def test_track_sent_message_bounded_lru(): + c = _client() + c._sent_messages_max = 3 + c._track_sent_message("a") + c._track_sent_message("b") + c._track_sent_message("c") + c._track_sent_message("d") # evicts 'a' + assert "a" not in c._sent_messages + assert "d" in c._sent_messages + + +def test_track_sent_message_ignores_empty(): + c = _client() + c._track_sent_message("") + assert len(c._sent_messages) == 0 + + +def test_track_sent_message_refreshes_on_touch(): + c = _client() + c._sent_messages_max = 3 + c._track_sent_message("a") + c._track_sent_message("b") + c._track_sent_message("c") + # Touch 'a' again — should stay even after next insert evicts oldest + c._track_sent_message("a") + c._track_sent_message("d") + assert "a" in c._sent_messages + + +@pytest.mark.asyncio +async def test_start_background_returns_after_ws_ready_without_waiting_for_start_exit(): + c = _client() + started = threading.Event() + release = threading.Event() + + class _BlockingReadyWS: + def __init__(self, *args, **kwargs): + self._conn = None + self._stopped = False + + def start(self): + self._conn = object() + started.set() + release.wait(timeout=2.0) + + def stop(self): + self._stopped = True + release.set() + + async def _no_identity(_cfg): + return None + + with patch("lark_channel.channel.channel.WSClient", _BlockingReadyWS), patch( + "lark_channel.channel.channel.fetch_bot_identity", side_effect=_no_identity + ): + await asyncio.wait_for(c.start_background(timeout=1.0), timeout=1.0) + assert started.is_set() + assert c.is_ready is True + assert c._start_future is not None + assert c._start_future.done() is False + + await c.stop_background() + assert c.is_ready is False + + +@pytest.mark.asyncio +async def test_start_background_propagates_not_connected_startup_failure(): + c = _client() + + class _FailingWS: + def __init__(self, *args, **kwargs): + pass + + def start(self): + raise RuntimeError("handshake failed") + + async def _no_identity(_cfg): + return None + + with patch("lark_channel.channel.channel.WSClient", _FailingWS), patch( + "lark_channel.channel.channel.fetch_bot_identity", side_effect=_no_identity + ): + with pytest.raises(FeishuChannelError) as exc: + await c.start_background(timeout=1.0) + + assert exc.value.code is FeishuChannelErrorCode.NOT_CONNECTED + + +@pytest.mark.asyncio +async def test_stop_background_wakes_in_flight_start_background_waiter(): + c = _client() + start_entered = threading.Event() + release_start = threading.Event() + + class _BlockingNotReadyWS: + def __init__(self, *args, **kwargs): + self._conn = None + + def start(self): + start_entered.set() + release_start.wait(timeout=2.0) + + def stop(self): + release_start.set() + + async def _identity(_cfg): + return BotIdentity(open_id="ou_bot") + + with patch("lark_channel.channel.channel.WSClient", _BlockingNotReadyWS), patch( + "lark_channel.channel.channel.fetch_bot_identity", side_effect=_identity + ): + start_task = asyncio.create_task(c.start_background(timeout=10.0)) + while not start_entered.wait(0.01): + await asyncio.sleep(0.01) + + await c.stop_background() + await asyncio.wait_for(start_task, timeout=1.0) + + assert c.is_ready is False + assert c._started is False + + +@pytest.mark.asyncio +async def test_start_background_after_stop_waits_for_new_readiness(monkeypatch): + c = _client() + c.stop() + loop = asyncio.get_running_loop() + start_future = loop.create_future() + submitted = threading.Event() + + def fake_run_in_executor(executor, fn): + submitted.set() + return start_future + + monkeypatch.setattr(loop, "run_in_executor", fake_run_in_executor) + + start_task = asyncio.create_task(c.start_background(timeout=1.0)) + while not submitted.wait(0.01): + await asyncio.sleep(0.01) + await asyncio.sleep(0.05) + + assert start_task.done() is False + + c._mark_ready() + start_future.set_result(None) + await asyncio.wait_for(start_task, timeout=1.0) + + +def test_stop_during_blocking_start_does_not_surface_late_ws_start_exception(): + c = _client() + ready = threading.Event() + release = threading.Event() + errors = [] + + class _LateFailingWS: + def __init__(self, *args, **kwargs): + self._conn = None + + def start(self): + self._conn = object() + ready.set() + release.wait(timeout=2.0) + raise RuntimeError("loop stopped during shutdown") + + def stop(self): + return None + + async def _identity(_cfg): + return BotIdentity(open_id="ou_bot") + + with patch("lark_channel.channel.channel.WSClient", _LateFailingWS), patch( + "lark_channel.channel.channel.fetch_bot_identity", side_effect=_identity + ): + def run_start(): + try: + c.start() + except Exception as exc: + errors.append(exc) + + t = threading.Thread(target=run_start) + t.start() + assert ready.wait(1.0) + c.stop() + release.set() + t.join(timeout=2.0) + + assert not t.is_alive() + assert errors == [] + assert c._started is False + assert c.is_ready is False + + +def test_stop_during_pre_ws_start_prevents_late_ws_creation(): + c = _client() + fetch_entered = threading.Event() + release_fetch = threading.Event() + ws_started = threading.Event() + errors = [] + + class _ShouldNotStartWS: + def __init__(self, *args, **kwargs): + pass + + def start(self): + ws_started.set() + + async def _slow_identity(_cfg): + fetch_entered.set() + await asyncio.get_running_loop().run_in_executor(None, release_fetch.wait) + return BotIdentity(open_id="ou_bot") + + with patch("lark_channel.channel.channel.WSClient", _ShouldNotStartWS), patch( + "lark_channel.channel.channel.fetch_bot_identity", side_effect=_slow_identity + ): + def run_start(): + try: + c.start() + except Exception as exc: + errors.append(exc) + + t = threading.Thread(target=run_start) + t.start() + assert fetch_entered.wait(1.0) + stop_t = threading.Thread(target=c.stop) + stop_t.start() + release_fetch.set() + t.join(timeout=2.0) + stop_t.join(timeout=2.0) + + assert not t.is_alive() + assert not stop_t.is_alive() + assert errors == [] + assert ws_started.is_set() is False + assert c.ws_client is None + assert c._started is False + assert c.is_ready is False + + +def test_stale_pre_ws_start_cannot_be_uncancelled_by_restart(): + c = _client() + first_fetch_entered = threading.Event() + second_fetch_entered = threading.Event() + release_first_fetch = threading.Event() + release_second_fetch = threading.Event() + fetch_count = 0 + labels_by_thread = {} + started_labels = [] + errors = [] + lock = threading.Lock() + + class _LabelledWS: + def __init__(self, *args, **kwargs): + self._conn = object() + + def start(self): + with lock: + started_labels.append(labels_by_thread.get(threading.get_ident())) + + def stop(self): + return None + + def _slow_fetch(): + nonlocal fetch_count + with lock: + fetch_count += 1 + label = "first" if fetch_count == 1 else "second" + labels_by_thread[threading.get_ident()] = label + if label == "first": + first_fetch_entered.set() + release_first_fetch.wait(timeout=2.0) + else: + second_fetch_entered.set() + release_second_fetch.wait(timeout=2.0) + + with patch("lark_channel.channel.channel.WSClient", _LabelledWS), patch.object( + c, "_fetch_bot_identity_sync", side_effect=_slow_fetch + ): + def run_start(): + try: + c.start() + except Exception as exc: + errors.append(exc) + + first = threading.Thread(target=run_start) + first.start() + assert first_fetch_entered.wait(1.0) + + c.stop() + + second = threading.Thread(target=run_start) + second.start() + assert second_fetch_entered.wait(1.0) + + release_first_fetch.set() + first.join(timeout=2.0) + release_second_fetch.set() + second.join(timeout=2.0) + + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + assert "first" not in started_labels + assert "second" in started_labels + + +def test_bot_identity_accessor_before_resolve(): + c = _client() + assert c.bot_identity is None + + +def test_resolve_bot_identity_persists_to_safety_pipeline(monkeypatch): + """When identity resolves, it should propagate into the safety PolicyGate.""" + async def fake_fetch(config): + return BotIdentity(open_id="ou_bot_xyz", name="Test Bot") + + c = _client() + c._ensure_bg_loop() + monkeypatch.setattr("lark_channel.channel.channel.fetch_bot_identity", fake_fetch) + fut = asyncio.run_coroutine_threadsafe(c.resolve_bot_identity(), c._bg_loop) + identity = fut.result(timeout=2) + assert identity.open_id == "ou_bot_xyz" + assert c._bot_open_id == "ou_bot_xyz" + # Safety gate's bot open id should also be set + assert c._safety._policy._bot_open_id == "ou_bot_xyz" # type: ignore[attr-defined] + + +def test_build_dispatcher_registers_required_events(): + """Dispatcher should have processors for all 5 event types we handle.""" + c = _client() + c._ensure_bg_loop() + dispatcher = c._build_dispatcher() + keys = set(dispatcher._processorMap.keys()) + keys |= set(dispatcher._callback_processor_map.keys()) + expected = { + "p2.im.message.receive_v1", + "p2.card.action.trigger", + "p2.im.message.reaction.created_v1", + "p2.im.message.reaction.deleted_v1", + "p2.im.chat.member.bot.added_v1", + "p2.im.chat.member.bot.deleted_v1", + "p2.im.message.message_read_v1", + # drive comment-add has no typed SDK processor and the wire + # payload may arrive under either schema (p1 callback envelope vs + # p2 WS envelope). Register both so neither path logs + # ``processor not found``. + "p1.drive.notice.comment_add_v1", + "p2.drive.notice.comment_add_v1", + } + missing = expected - keys + assert not missing, f"dispatcher missing processors: {missing}" + + +def test_emit_reject_with_no_handler_only_logs(caplog): + import logging + + from lark_channel.channel.safety import RejectEvent + + c = _client() + with caplog.at_level(logging.DEBUG, logger="lark_channel"): + c._emit_reject(RejectEvent( + message_id="om_x", chat_id="oc_x", sender_id="ou_s", reason="policy_no_mention", + )) + # With no handler registered, _emit_reject must not raise. It may or may + # not log anything depending on level, but it must at least not surface + # through an uncaught exception. + # (Previously this assertion ended with ``... or True`` which made it + # vacuously pass; the intent was "tolerate missing log line while still + # asserting no exception", which the ``with caplog.at_level`` + absence + # of pytest raise already covers.) + + +def test_emit_reject_dispatches_to_registered_handler(): + from lark_channel.channel.safety import RejectEvent + + c = _client() + got = [] + c.on("reject", lambda e: got.append(e)) + c._emit_reject(RejectEvent( + message_id="om_x", chat_id="oc_x", sender_id="ou_s", reason="policy_dm_disabled", + )) + assert len(got) == 1 + assert got[0].reason == "policy_dm_disabled" diff --git a/lark_channel/channel/tests/test_comment_event.py b/lark_channel/channel/tests/test_comment_event.py new file mode 100644 index 0000000..da40ff2 --- /dev/null +++ b/lark_channel/channel/tests/test_comment_event.py @@ -0,0 +1,218 @@ +"""Tests for normalize_comment and dispatcher wiring. + +Two regressions are guarded here: + +1. The channel registers ``drive.notice.comment_add_v1`` under both ``p1`` and + ``p2`` schemas. The WS frontier wraps in a p2 envelope; legacy callbacks + use p1. + +2. After registration, the live comment payload still surfaced operator + open_id / mentioned_bot / timestamp as null — the original + ``normalize_comment`` was guessing at field names that don't exist in + the wire format. Real shape (observed end-to-end against a Feishu + tenant 2026-04-27): operator at ``notice_meta.from_user_id``, + mentioned-bot is the boolean ``is_mentioned``, timestamp is + ``create_time`` as a millisecond string. +""" + +import threading + +from lark_channel.channel import FeishuChannel +from lark_channel.channel.normalize.comment import CommentEvent, normalize_comment + + +# --------------------------------------------------------------------------- +# normalize_comment — real wire format +# --------------------------------------------------------------------------- + + +def test_full_payload_real_wire_format(): + """Mirror a realistic ``drive.notice.comment_add_v1`` WS payload.""" + payload = { + "event": { + "file_token": "doc_token_x", + "file_type": "docx", + "comment_id": "cmt_1", + "reply_id": "rpl_1", + "is_mentioned": True, + "create_time": "1700000000000", + "notice_meta": { + "from_user_id": { + "open_id": "ou_op", + "user_id": "u_op", + "union_id": "on_op", + }, + "to_user_id": {"open_id": "ou_bot"}, + "file_token": "doc_token_x", + "file_type": "docx", + "is_mentioned": True, + "timestamp": "1700000000000", + "notice_type": "comment_add", + }, + } + } + c = normalize_comment(payload) + assert isinstance(c, CommentEvent) + assert c.file_token == "doc_token_x" + assert c.file_type == "docx" + assert c.comment_id == "cmt_1" + assert c.reply_id == "rpl_1" + assert c.operator.open_id == "ou_op" + assert c.operator.user_id == "u_op" + assert c.operator.union_id == "on_op" + assert c.mentioned_bot is True + assert c.timestamp == 1700000000000 + + +def test_missing_file_token_returns_none(): + payload = {"event": {"comment_id": "x"}} + assert normalize_comment(payload) is None + + +def test_missing_operator_returns_none(): + """Half-populated payloads (no operator) are dropped instead of + delivered with operator.open_id == None.""" + payload = { + "event": { + "file_token": "t", + "file_type": "docx", + "comment_id": "c", + "create_time": "1", + # notice_meta missing → no operator → drop + } + } + assert normalize_comment(payload) is None + + +def test_envelope_timestamp_used_when_inner_event_omits_it(): + """The real WS payload puts ``create_time`` on the p2 envelope's + ``header``, not the inner event dict. Without this fallback, every + delivered ``CommentEvent`` would have ``timestamp=0``.""" + payload = { + "event": { + "file_token": "doc_token_x", + "file_type": "docx", + "comment_id": "cmt_no_inner_ts", + "is_mentioned": True, + "notice_meta": {"from_user_id": {"open_id": "ou_op"}}, + # NB: no create_time / action_time / timestamp anywhere inside + } + } + c = normalize_comment(payload, envelope_timestamp="1700000000000") + assert c is not None + assert c.timestamp == 1700000000000 + + +def test_legacy_top_level_user_id_fallback(): + """Older p1 callbacks ship the operator at top level as ``user_id`` + instead of ``notice_meta.from_user_id``. Don't break those.""" + payload = { + "event": { + "file_token": "t1", + "file_type": "sheet", + "comment_id": "cmt_abc", + "user_id": {"open_id": "ou_legacy", "union_id": "on_legacy"}, + "is_mention": True, + "action_time": "1650000000000", + } + } + c = normalize_comment(payload) + assert c is not None + assert c.operator.open_id == "ou_legacy" + assert c.operator.union_id == "on_legacy" + assert c.mentioned_bot is True + assert c.timestamp == 1650000000000 + + +def test_is_mentioned_false_propagates(): + """When the bot was NOT @-mentioned (e.g. an at-document comment), the + flag must surface as False. The result must not depend on a mentions array + that doesn't exist on the wire.""" + payload = { + "event": { + "file_token": "t", + "file_type": "docx", + "comment_id": "c", + "is_mentioned": False, + "create_time": "1", + "notice_meta": {"from_user_id": {"open_id": "ou_op"}}, + } + } + c = normalize_comment(payload) + assert c is not None and c.mentioned_bot is False + + +# --------------------------------------------------------------------------- +# Dispatcher wiring — both p1 and p2 must be registered +# --------------------------------------------------------------------------- + + +def _client() -> FeishuChannel: + return FeishuChannel(app_id="cli_test", app_secret="secret_test") + + +def test_dispatcher_registers_both_schemas_for_comment_add(): + c = _client() + c._ensure_bg_loop() + dispatcher = c._build_dispatcher() + keys = set(dispatcher._processorMap.keys()) + # WS frontier wraps even legacy events in a p2 envelope; the legacy + # HTTP callback uses p1. Without both, half the deployments log + # ``processor not found``. + assert "p1.drive.notice.comment_add_v1" in keys, ( + f"missing p1 processor; got {sorted(keys)}" + ) + assert "p2.drive.notice.comment_add_v1" in keys, ( + f"missing p2 processor; got {sorted(keys)}" + ) + + +def test_incoming_comment_event_invokes_comment_handler(): + """End-to-end: feed a CustomizedEvent (real wire format) into the + dispatcher processor and assert ``on("comment", ...)`` fires with a + fully-populated ``CommentEvent``.""" + from lark_channel.event.custom import CustomizedEvent + + c = _client() + c._ensure_bg_loop() + dispatcher = c._build_dispatcher() + + got: list[CommentEvent] = [] + done = threading.Event() + + def _on_comment(ev: CommentEvent) -> None: + got.append(ev) + done.set() + + c.on("comment", _on_comment) + + ctx = CustomizedEvent() + ctx.event = { + "file_token": "doc_token_case", + "file_type": "docx", + "comment_id": "cmt_42", + "reply_id": "rpl_42", + "is_mentioned": True, + "create_time": "1712000000000", + "notice_meta": { + "from_user_id": { + "open_id": "ou_op_user", + "user_id": "u_op", + }, + "is_mentioned": True, + "timestamp": "1712000000000", + }, + } + + # Use the p2 processor (modern WS path) — the regression chain. + processor = dispatcher._processorMap["p2.drive.notice.comment_add_v1"] + processor.do(ctx) + assert done.wait(timeout=2.0), "comment handler was not invoked within 2s" + assert len(got) == 1 + ev = got[0] + assert ev.file_token == "doc_token_case" + assert ev.comment_id == "cmt_42" + assert ev.operator.open_id == "ou_op_user" + assert ev.operator.user_id == "u_op" + assert ev.mentioned_bot is True + assert ev.timestamp == 1712000000000 diff --git a/lark_channel/channel/tests/test_config.py b/lark_channel/channel/tests/test_config.py new file mode 100644 index 0000000..1790897 --- /dev/null +++ b/lark_channel/channel/tests/test_config.py @@ -0,0 +1,138 @@ +"""Coverage for the config dataclasses — default values + nesting.""" + +from lark_channel.channel.config import ( + ChannelConfig, + DedupConfig, + FooterConfig, + GroupOverride, + InboundConfig, + MarkdownConverter, + MediaCapabilities, + NameCacheConfig, + OutboundConfig, + PerChatReplyMode, + PolicyConfig, + RetryConfig, + SafetyConfig, + StreamThrottleConfig, + TransportConfig, + UATConfig, +) + + +def test_media_capabilities_default_all_true(): + mc = MediaCapabilities() + assert mc.image and mc.audio and mc.video and mc.file and mc.sticker + + +def test_name_cache_defaults_match_spec(): + c = NameCacheConfig() + assert c.enabled and c.max_size == 2000 and c.ttl_seconds == 24 * 3600 + + +def test_inbound_config_defaults(): + ib = InboundConfig() + assert ib.expand_merge_forward is True + assert ib.fetch_interactive_card is True + assert ib.reaction_notifications == "own" + assert ib.merge_forward_max_depth == 3 + assert ib.merge_forward_max_items == 50 + + +def test_outbound_config_defaults(): + ob = OutboundConfig() + assert ob.reply_mode == "auto" + assert ob.text_chunk_limit == 3500 # node-aligned (DEFAULT_CHUNK_LIMIT) + assert ob.chunk_mode == "newline" + assert ob.markdown_converter.enabled is True + assert ob.markdown_converter.table_mode == "off" + assert ob.ssrf_allowlist is None + assert isinstance(ob.retry, RetryConfig) + assert ob.retry.max_attempts == 3 + + +def test_stream_throttle_defaults(): + t = StreamThrottleConfig() + assert t.min_chars == 20 and t.max_chars == 200 and t.idle_ms == 300 + + +def test_footer_config_all_off_by_default(): + f = FooterConfig() + assert not any([f.status, f.elapsed, f.tokens, f.model, f.cache, f.context]) + + +def test_dedup_config_defaults(): + d = DedupConfig() + assert d.enabled is True + assert d.ttl_seconds == 12 * 3600 + assert d.max_entries == 5000 + assert d.sweep_seconds == 5 * 60 + + +def test_uat_config_defaults(): + u = UATConfig() + assert u.refresh_before_expiry_seconds == 300 + assert u.device_poll_interval_seconds == 5 + assert u.allowed_scopes is None + assert u.blocked_scopes is None + + +def test_transport_config_default_ws(): + t = TransportConfig() + assert t.kind == "ws" + assert t.auto_reconnect is True + + +def test_policy_config_defaults(): + p = PolicyConfig() + assert p.dm_policy == "open" + assert p.group_policy == "open" + assert p.require_mention is True + assert p.respond_to_mention_all is False + assert p.allow_from is None + assert p.group_overrides == {} + + +def test_group_override_all_fields_optional(): + o = GroupOverride() + assert o.policy is None and o.enabled is None + + +def test_safety_config_nests_dedup_and_batch(): + s = SafetyConfig() + assert isinstance(s.dedup, DedupConfig) + # text_batch / chat_queue live in safety.types; we just verify the + # nesting is present without pinning their class names here. + assert s.text_batch is not None + assert s.chat_queue is not None + assert s.stale_message_window_ms == 30 * 60 * 1000 + + +def test_channel_config_top_level_areas(): + c = ChannelConfig() + assert c.app_id == "" and c.app_secret == "" + assert c.encrypt_key is None and c.verification_token is None + assert isinstance(c.transport, TransportConfig) + assert isinstance(c.policy, PolicyConfig) + assert isinstance(c.safety, SafetyConfig) + assert isinstance(c.inbound, InboundConfig) + assert isinstance(c.outbound, OutboundConfig) + assert isinstance(c.uat, UATConfig) + + +def test_per_chat_reply_mode_defaults(): + m = PerChatReplyMode() + assert m.default == "auto" + assert m.dm is None and m.group is None + + +def test_markdown_converter_table_mode_override(): + mc = MarkdownConverter(enabled=False, table_mode="bullets") + assert mc.enabled is False + assert mc.table_mode == "bullets" + + +def test_retry_config_defaults(): + r = RetryConfig() + assert r.max_attempts == 3 + assert r.base_delay_ms == 500 diff --git a/lark_channel/channel/tests/test_converter_node_aligned_output.py b/lark_channel/channel/tests/test_converter_node_aligned_output.py new file mode 100644 index 0000000..a98833b --- /dev/null +++ b/lark_channel/channel/tests/test_converter_node_aligned_output.py @@ -0,0 +1,408 @@ +"""Byte-level output alignment with node-sdk converters. + +Every assertion here corresponds to a node-sdk converter's observable output +format (tag names, indentation, emoji prefixes, attribute names). Divergence +caught here = a regression that node users would see differently from +Python users. + +Node source reference (commit ccc7e31): + https://github.com/larksuite/node-sdk/tree/ccc7e31/channel/normalize/converters +""" + +from pathlib import Path + +from lark_channel.channel.normalize.converters import ( + calendar as calendar_c, + folder as folder_c, + merge_forward as mf_c, + share as share_c, + system as system_c, + todo as todo_c, + video_chat as vc_c, + vote as vote_c, +) +from lark_channel.channel.types import ( + CalendarContent, + FolderContent, + GeneralCalendarContent, + ImageContent, + MergeForwardContent, + MergeForwardItem, + ShareCalendarEventContent, + ShareChatContent, + ShareUserContent, + SystemContent, + TextContent, + TodoContent, + VideoChatContent, + VoteContent, +) + + +# ---- system: template regex expansion --------------------------------------- + + +def test_system_expands_template_variables(): + content = SystemContent( + template="{from_user} invited {to_chatters}", + raw={ + "template": "{from_user} invited {to_chatters}", + "from_user": [{"name": "Alice"}], + "to_chatters": [{"name": "Bob"}, {"name": "Carol"}], + }, + ) + text, _ = system_c.convert(content) + assert text == "Alice invited Bob, Carol" + + +def test_system_missing_template_emits_placeholder(): + text, _ = system_c.convert(SystemContent(template="")) + assert text == "[system message]" + + +def test_system_unknown_key_becomes_empty(): + # Node's policy: unknown key → empty string (matches `val == null` branch + # in node's system.ts). Trailing whitespace is trimmed. + content = SystemContent(template="Hi {unknown}", raw={"template": "Hi {unknown}"}) + text, _ = system_c.convert(content) + assert text == "Hi" + + +def test_system_non_string_value_preserves_placeholder(): + # Node's policy: if value is neither string/array/null, leave the + # placeholder as-is (fallthrough `return match`). + content = SystemContent( + template="Count: {n}", + raw={"template": "Count: {n}", "n": 42}, # int, not str + ) + text, _ = system_c.convert(content) + assert text == "Count: {n}" + + +# ---- todo: rich multi-line output ------------------------------------------- + + +def test_todo_with_title_body_due_formats_multiline(): + content = TodoContent(title="Ship it", body="before EOD", due_time=1700000000000) + text, _ = todo_c.convert(content) + assert text.startswith("\n") + assert text.endswith("\n") + assert "Ship it" in text + assert "before EOD" in text + assert "Due: " in text + + +def test_todo_empty_emits_placeholder(): + text, _ = todo_c.convert(TodoContent()) + assert text == "\n[todo]\n" + + +# ---- video_chat: block with emoji + time -------------------------- + + +def test_video_chat_renders_meeting_block(): + content = VideoChatContent(topic="Sprint Planning", start_time=1700000000000) + text, _ = vc_c.convert(content) + assert text.startswith("\n") + assert text.endswith("\n") + assert "📹 Sprint Planning" in text + assert "🕙 " in text + + +def test_video_chat_empty_emits_placeholder(): + text, _ = vc_c.convert(VideoChatContent()) + assert text == "\n[video chat]\n" + + +# ---- calendar (3 variants): rich block + correct tag names ------------------ + + +def test_calendar_invite_tag_name(): + content = CalendarContent(summary="Sync", start_time=1700000000000) + text, _ = calendar_c.convert(content) + assert text.startswith("\n") + assert text.endswith("\n") + assert "📅 Sync" in text + + +def test_general_calendar_tag_name_is_calendar(): + content = GeneralCalendarContent(summary="Demo") + text, _ = calendar_c.convert_general(content) + assert text.startswith("\n") + assert text.endswith("\n") + assert "📅 Demo" in text + + +def test_share_calendar_event_tag_name_is_calendar_share(): + content = ShareCalendarEventContent(summary="Demo") + text, _ = calendar_c.convert_share_event(content) + assert text.startswith("\n") + assert text.endswith("\n") + + +def test_calendar_start_end_rendered_with_tilde(): + content = CalendarContent( + summary="Mtg", start_time=1700000000000, end_time=1700003600000 + ) + text, _ = calendar_c.convert(content) + assert "~" in text + + +# ---- vote: multi-line bullets ----------------------------------------------- + + +def test_vote_multiline_with_bullet_options(): + content = VoteContent(topic="Lunch?", options=["Pizza", "Sushi"]) + text, _ = vote_c.convert(content) + lines = text.split("\n") + assert lines[0] == "" + assert "Lunch?" in lines + assert "• Pizza" in lines + assert "• Sushi" in lines + assert lines[-1] == "" + + +def test_vote_empty_placeholder(): + text, _ = vote_c.convert(VoteContent()) + assert text == "\n[vote]\n" + + +# ---- share: id= attribute (not chat_id= / user_id=) ------------------------- + + +def test_share_chat_uses_id_attribute(): + text, _ = share_c.convert_chat(ShareChatContent(chat_id="oc_123")) + assert text == '' + + +def test_share_user_uses_id_attribute(): + text, _ = share_c.convert_user(ShareUserContent(user_id="ou_456")) + assert text == '' + + +# ---- folder: key= attribute + optional name= ------------------------------ + + +def test_folder_with_key_and_name(): + text, _ = folder_c.convert(FolderContent(file_key="fk_1", file_name="docs")) + assert text == '' + + +def test_folder_with_key_only(): + text, _ = folder_c.convert(FolderContent(file_key="fk_1")) + assert text == '' + + +def test_folder_no_key_falls_back_to_placeholder(): + text, _ = folder_c.convert(FolderContent(file_name="docs")) + assert text == "[folder]" + + +# ---- merge_forward: header not indented, body indented 4 spaces ------------- + + +def test_merge_forward_item_header_is_flush_left(): + child = MergeForwardItem( + message_id="om_1", + sender_name="Alice", + create_time=1700000000000, + content=TextContent(text="hello"), + ) + mf = MergeForwardContent(items=[child]) + text, _ = mf_c.convert(mf) + lines = text.split("\n") + # Header line: "[timestamp] Alice:" — no leading spaces + header_line = next(line for line in lines if "Alice:" in line) + assert not header_line.startswith(" "), f"header should be flush-left: {header_line!r}" + # Body line: " hello" — 4 spaces + body_line = next(line for line in lines if "hello" in line) + assert body_line == " hello", f"body should be indented 4 spaces: {body_line!r}" + + +def test_merge_forward_empty_or_loading_is_self_closing(): + assert mf_c.convert(MergeForwardContent(loading=True))[0] == "" + assert mf_c.convert(MergeForwardContent(items=[]))[0] == "" + + +def test_merge_forward_nested_indents_by_4_more(): + inner_child = MergeForwardItem( + message_id="om_inner", + sender_name="Bob", + create_time=1700000000000, + content=TextContent(text="inner text"), + ) + inner = MergeForwardContent(items=[inner_child]) + outer_child = MergeForwardItem( + message_id="om_outer", + sender_name="Alice", + create_time=1700000000000, + content=inner, + ) + outer = MergeForwardContent(items=[outer_child]) + text, _ = mf_c.convert(outer) + # The inner "inner text" line should be indented 8 spaces (4 from outer + # body indent + 4 from inner body indent). + inner_line = next(line for line in text.split("\n") if "inner text" in line) + assert inner_line == " inner text", f"nested body needs 8-space indent: {inner_line!r}" + + +def test_merge_forward_truncation_footer(): + child = MergeForwardItem( + message_id="om_1", + sender_name="Alice", + create_time=1700000000000, + content=TextContent(text="hi"), + ) + mf = MergeForwardContent(items=[child], truncated=True) + text, _ = mf_c.convert(mf) + assert "... (truncated)" in text + # Truncation marker should NOT be indented (node emits `\n... (truncated)`). + assert "\n... (truncated)" in text + + +def test_merge_forward_missing_create_time_falls_back_to_unknown(): + child = MergeForwardItem( + message_id="om_1", + sender_name="Alice", + create_time=None, + content=TextContent(text="hi"), + ) + mf = MergeForwardContent(items=[child]) + text, _ = mf_c.convert(mf) + # Matches node's `timestamp = createMs > 0 ? format(...) : 'unknown'`. + assert "[unknown] Alice:" in text + + +def test_merge_forward_item_that_raises_is_skipped(): + class _Boom: + def __getattribute__(self, name): + raise RuntimeError("simulated failure") + + ok_child = MergeForwardItem( + message_id="om_1", + sender_name="Alice", + create_time=1700000000000, + content=TextContent(text="good"), + ) + mf = MergeForwardContent(items=[_Boom(), ok_child]) + text, _ = mf_c.convert(mf) + # The good item survives; the broken one is silently dropped. + assert "Alice:" in text + assert "good" in text + + +def test_merge_forward_nested_resources_keep_depth_first_order(): + first = MergeForwardContent( + items=[ + MergeForwardItem( + message_id="om_first_image", + sender_name="Alice", + create_time=1700000000000, + content=ImageContent(image_key="img_first"), + ) + ] + ) + second = MergeForwardContent( + items=[ + MergeForwardItem( + message_id="om_second_image", + sender_name="Bob", + create_time=1700000000000, + content=ImageContent(image_key="img_second"), + ) + ] + ) + mf = MergeForwardContent( + items=[ + MergeForwardItem( + message_id="om_first_forward", + sender_name="Alice", + create_time=1700000000000, + content=first, + ), + MergeForwardItem( + message_id="om_second_forward", + sender_name="Bob", + create_time=1700000000000, + content=second, + ), + ] + ) + + _, resources = mf_c.convert(mf) + + assert [resource.file_key for resource in resources] == [ + "img_first", + "img_second", + ] + + +def test_merge_forward_converter_avoids_pep585_runtime_annotations_for_python38(): + source = Path(mf_c.__file__).read_text(encoding="utf-8") + + assert "dict[" not in source + assert "set[" not in source + + +# ---- Calendar edge cases ----------------------------------------------- + + +def test_calendar_only_summary_no_time(): + text, _ = calendar_c.convert(CalendarContent(summary="Just a note")) + assert "📅 Just a note" in text + assert "🕙" not in text # no time line when start/end absent + + +def test_calendar_only_start_no_end(): + text, _ = calendar_c.convert(CalendarContent(summary="S", start_time=1700000000000)) + assert "🕙 " in text + assert "~" not in text # no range separator when end missing + + +def test_calendar_empty_falls_back_to_placeholder(): + text, _ = calendar_c.convert(CalendarContent()) + assert text == "\n[calendar event]\n" + + +# ---- Vote edge cases --------------------------------------------------- + + +def test_vote_options_only_no_topic(): + text, _ = vote_c.convert(VoteContent(options=["A", "B"])) + lines = text.split("\n") + assert lines[0] == "" + assert "• A" in lines and "• B" in lines + assert lines[-1] == "" + + +# ---- Todo edge cases --------------------------------------------------- + + +def test_todo_only_due_time(): + text, _ = todo_c.convert(TodoContent(due_time=1700000000000)) + assert "Due: " in text + + +def test_todo_only_title(): + text, _ = todo_c.convert(TodoContent(title="finish audit")) + assert "finish audit" in text + assert "Due:" not in text + + +# ---- Share fallback for empty id -------------------------------------- + + +def test_share_chat_with_empty_chat_id(): + # Node emits id="" for missing chat_id (``?? ''`` coalesce). + text, _ = share_c.convert_chat(ShareChatContent(chat_id="")) + assert text == '' + + +# ---- System fallback -------------------------------------------------- + + +def test_system_empty_raw_uses_template_as_is(): + # Template with no raw → variables can't resolve but template remains. + content = SystemContent(template="Hello World") + text, _ = system_c.convert(content) + assert text == "Hello World" diff --git a/lark_channel/channel/tests/test_dedup.py b/lark_channel/channel/tests/test_dedup.py new file mode 100644 index 0000000..efa15a6 --- /dev/null +++ b/lark_channel/channel/tests/test_dedup.py @@ -0,0 +1,61 @@ +"""Tests for dedup storage + two-key strategy.""" + +from lark_channel.channel.normalize.dedup import Deduper, InMemoryDedupStore, make_event_key, make_message_key + + +def test_basic_mark_and_seen(): + s = InMemoryDedupStore() + assert s.seen("k") is False + s.mark("k", ttl_seconds=60) + assert s.seen("k") is True + + +def test_ttl_expires(): + s = InMemoryDedupStore() + s.mark("k", ttl_seconds=0) + # ttl=0 is effectively expired immediately + assert s.seen("k") is False + + +def test_lru_bounded(): + s = InMemoryDedupStore(max_entries=3) + s.mark("a", 60) + s.mark("b", 60) + s.mark("c", 60) + s.mark("d", 60) + # 'a' must have been evicted + assert s.seen("a") is False + assert s.seen("d") is True + + +def test_two_key_dedupes_by_event_id_first(): + s = InMemoryDedupStore() + d = Deduper(s, ttl_seconds=60) + assert d.check_and_mark("app", "e1", "m1") is True + assert d.check_and_mark("app", "e1", "m2") is False # event_id dup + + +def test_two_key_dedupes_by_message_id(): + s = InMemoryDedupStore() + d = Deduper(s, ttl_seconds=60) + assert d.check_and_mark("app", "e1", "m1") is True + assert d.check_and_mark("app", "e2", "m1") is False # message_id dup + + +def test_different_accounts_do_not_collide(): + s = InMemoryDedupStore() + d = Deduper(s, ttl_seconds=60) + assert d.check_and_mark("app1", "e1", "m1") is True + assert d.check_and_mark("app2", "e1", "m1") is True + + +def test_dedup_disabled_always_passes(): + s = InMemoryDedupStore() + d = Deduper(s, ttl_seconds=60, enabled=False) + assert d.check_and_mark("app", "e1", "m1") is True + assert d.check_and_mark("app", "e1", "m1") is True + + +def test_key_builders(): + assert make_event_key("a", "e") == "evt:a:e" + assert make_message_key("a", "m") == "msg:a:m" diff --git a/lark_channel/channel/tests/test_device_flow.py b/lark_channel/channel/tests/test_device_flow.py new file mode 100644 index 0000000..30ea39c --- /dev/null +++ b/lark_channel/channel/tests/test_device_flow.py @@ -0,0 +1,113 @@ +"""DeviceFlowClient tests with a mocked httpx transport.""" + +from typing import Any, Dict, List + +import httpx +import pytest + +from lark_channel.channel.auth.device_flow import DeviceFlowClient, uat_needs_refresh +from lark_channel.channel.errors import UATAuthError +from lark_channel.channel.types import UAT + + +def _mock_transport(responses: List[Dict[str, Any]]): + """Build an httpx MockTransport that returns the next response on each call.""" + idx = [0] + + def handler(request: httpx.Request) -> httpx.Response: + if idx[0] >= len(responses): + return httpx.Response(500, json={"msg": "no more canned responses"}) + r = responses[idx[0]] + idx[0] += 1 + return httpx.Response(200, json=r) + + return httpx.MockTransport(handler) + + +@pytest.mark.asyncio +async def test_start_returns_device_init(): + transport = _mock_transport( + [ + { + "verification_uri": "https://x/auth", + "verification_uri_complete": "https://x/auth?user_code=ABCD", + "user_code": "ABCD", + "device_code": "dc_1", + "expires_in": 600, + "interval": 5, + } + ] + ) + client = httpx.AsyncClient(transport=transport) + df = DeviceFlowClient("cli", "sec", http_client=client) + init = await df.start(["im:message"]) + assert init.device_code == "dc_1" + assert init.user_code == "ABCD" + assert init.interval == 5 + assert "user_code=ABCD" in init.verification_uri_complete + + +@pytest.mark.asyncio +async def test_poll_returns_uat_on_success(): + transport = _mock_transport( + [ + { + "access_token": "tok_x", + "refresh_token": "rtok", + "expires_in": 7200, + "refresh_token_expires_in": 2592000, + "scope": "im:message", + } + ] + ) + client = httpx.AsyncClient(transport=transport) + df = DeviceFlowClient("cli", "sec", http_client=client) + uat = await df.poll("dc_1", interval=1, timeout_seconds=10) + assert uat.access_token == "tok_x" + assert uat.refresh_token == "rtok" + assert "im:message" in uat.scopes + + +@pytest.mark.asyncio +async def test_poll_access_denied_raises(): + transport = _mock_transport([{"error": "access_denied", "msg": "user denied"}]) + client = httpx.AsyncClient(transport=transport) + df = DeviceFlowClient("cli", "sec", http_client=client) + with pytest.raises(UATAuthError): + await df.poll("dc_1", interval=1, timeout_seconds=3) + + +@pytest.mark.asyncio +async def test_poll_pending_then_ok(): + # First response: authorization_pending; second: success. + transport = _mock_transport( + [ + {"error": "authorization_pending"}, + {"access_token": "t", "expires_in": 300, "scope": "im:message"}, + ] + ) + client = httpx.AsyncClient(transport=transport) + df = DeviceFlowClient("cli", "sec", http_client=client) + uat = await df.poll("dc_1", interval=0, timeout_seconds=10) + assert uat.access_token == "t" + + +def test_uat_needs_refresh_threshold(): + import time + + soon = UAT(access_token="t", expires_at=time.time() + 60) + later = UAT(access_token="t", expires_at=time.time() + 1800) + assert uat_needs_refresh(soon, slack_seconds=300) is True + assert uat_needs_refresh(later, slack_seconds=300) is False + + +@pytest.mark.asyncio +async def test_refresh_uses_refresh_token(): + transport = _mock_transport( + [{"access_token": "fresh", "refresh_token": "r2", "expires_in": 600, "scope": "im:message"}] + ) + client = httpx.AsyncClient(transport=transport) + df = DeviceFlowClient("cli", "sec", http_client=client) + uat = await df.refresh("r1") + assert uat.access_token == "fresh" + assert uat.refresh_token == "r2" diff --git a/lark_channel/channel/tests/test_download_to_file.py b/lark_channel/channel/tests/test_download_to_file.py new file mode 100644 index 0000000..5b06e58 --- /dev/null +++ b/lark_channel/channel/tests/test_download_to_file.py @@ -0,0 +1,137 @@ +"""Tests for FeishuChannel.download_resource_to_file.""" + +from unittest.mock import patch + +import pytest + +from lark_channel.channel import FeishuChannel, FeishuChannelError, FeishuChannelErrorCode + + +@pytest.mark.asyncio +async def test_success_returns_existing_path(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + + fake_bytes = b"\x89PNG\r\n\x1a\nrest" + + async def fake_download(*args, **kwargs): + return fake_bytes, "image/png" + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + path = await ch.download_resource_to_file( + file_key="img_xyz", + resource_type="image", + message_id="om_test", + dest_dir=tmp_path, + ) + + assert path.exists() + assert path.parent == tmp_path + assert path.read_bytes() == fake_bytes + # Suffix from content-type "image/png" + assert path.suffix == ".png" + + +@pytest.mark.asyncio +async def test_dest_dir_is_auto_mkdir(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + target = tmp_path / "deep" / "nested" / "dir" + assert not target.exists() + + async def fake_download(*args, **kwargs): + return b"data", "application/pdf" + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + path = await ch.download_resource_to_file( + file_key="f1", resource_type="file", message_id="om_x", dest_dir=target + ) + assert path.parent == target + assert path.suffix == ".pdf" + + +@pytest.mark.asyncio +async def test_failure_raises_download_failed(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + + async def fake_download(*args, **kwargs): + return None, None # failure path + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + with pytest.raises(FeishuChannelError) as excinfo: + await ch.download_resource_to_file( + file_key="bad", resource_type="image", message_id="om_x", dest_dir=tmp_path + ) + assert excinfo.value.code == FeishuChannelErrorCode.DOWNLOAD_FAILED + + +@pytest.mark.asyncio +async def test_explicit_file_name_overrides_inferred(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + + async def fake_download(*args, **kwargs): + return b"data", "image/jpeg" + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + path = await ch.download_resource_to_file( + file_key="k", resource_type="image", message_id="m", + dest_dir=tmp_path, file_name="custom.bin", + ) + assert path.name == "custom.bin" + + +@pytest.mark.asyncio +async def test_explicit_file_name_cannot_escape_dest_dir(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + + async def fake_download(*args, **kwargs): + return b"data", "image/jpeg" + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + with pytest.raises(FeishuChannelError) as excinfo: + await ch.download_resource_to_file( + file_key="k", + resource_type="image", + message_id="m", + dest_dir=tmp_path, + file_name="../escape.bin", + ) + + assert excinfo.value.code == FeishuChannelErrorCode.DOWNLOAD_FAILED + assert not (tmp_path.parent / "escape.bin").exists() + + +@pytest.mark.asyncio +async def test_default_file_name_cannot_escape_dest_dir(tmp_path): + ch = FeishuChannel(app_id="cli_x", app_secret="x") + + async def fake_download(*args, **kwargs): + return b"data", "image/jpeg" + + with patch( + "lark_channel.channel._api_helpers.download_media_with_meta", + side_effect=fake_download, + ): + with pytest.raises(FeishuChannelError) as excinfo: + await ch.download_resource_to_file( + file_key="../escape", + resource_type="image", + message_id="m", + dest_dir=tmp_path, + ) + + assert excinfo.value.code == FeishuChannelErrorCode.DOWNLOAD_FAILED + assert not (tmp_path.parent / "escape.jpg").exists() diff --git a/lark_channel/channel/tests/test_driver.py b/lark_channel/channel/tests/test_driver.py new file mode 100644 index 0000000..a69bdef --- /dev/null +++ b/lark_channel/channel/tests/test_driver.py @@ -0,0 +1,130 @@ +"""Driver smoke tests: verify the adapter correctly constructs SDK requests. + +We don't hit the network — instead we patch the underlying Lark `Client` +service methods to record the built Request objects, then assert the +builders wired up fields as expected. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from lark_channel.channel.driver import LarkClientDriver + + +def _stub_client(): + c = MagicMock() + # im.v1.message.* + msg = c.im.v1.message + msg.acreate = AsyncMock(return_value=MagicMock(code=0, msg="", data=MagicMock(message_id="om_1"))) + msg.areply = AsyncMock(return_value=MagicMock(code=0, msg="", data=MagicMock(message_id="om_r"))) + msg.aupdate = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + msg.apatch = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + msg.adelete = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + msg.aforward = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + msg.aget = AsyncMock(return_value=MagicMock(code=0, msg="", data=MagicMock())) + # im.v1.message_reaction.* + rx = c.im.v1.message_reaction + rx.acreate = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + rx.adelete = AsyncMock(return_value=MagicMock(code=0, msg="", data=None)) + rx.alist = AsyncMock(return_value=MagicMock(code=0, msg="", data=MagicMock())) + return c + + +@pytest.mark.asyncio +async def test_create_message_builds_request(): + c = _stub_client() + d = LarkClientDriver(c) + await d.create_message( + receive_id_type="chat_id", + receive_id="oc_1", + msg_type="text", + content='{"text": "hi"}', + uuid="u1", + ) + call = c.im.v1.message.acreate.call_args + req = call.args[0] + assert req.receive_id_type == "chat_id" + assert req.body.receive_id == "oc_1" + assert req.body.msg_type == "text" + assert req.body.content == '{"text": "hi"}' + assert req.body.uuid == "u1" + + +@pytest.mark.asyncio +async def test_reply_message_sets_thread_flag(): + c = _stub_client() + d = LarkClientDriver(c) + await d.reply_message( + message_id="om_x", + msg_type="text", + content='{"text":"t"}', + reply_in_thread=True, + ) + call = c.im.v1.message.areply.call_args + req = call.args[0] + assert req.message_id == "om_x" + assert req.body.reply_in_thread is True + + +@pytest.mark.asyncio +async def test_delete_forward_patch_use_correct_path(): + c = _stub_client() + d = LarkClientDriver(c) + await d.delete_message(message_id="om_del") + assert c.im.v1.message.adelete.await_count == 1 + await d.patch_message(message_id="om_p", content="{}") + assert c.im.v1.message.apatch.await_count == 1 + await d.forward_message(message_id="om_f", chat_id="oc_new") + assert c.im.v1.message.aforward.await_count == 1 + fwd_call = c.im.v1.message.aforward.call_args + assert fwd_call.args[0].receive_id_type == "chat_id" + assert fwd_call.args[0].body.receive_id == "oc_new" + + +@pytest.mark.asyncio +async def test_update_message_sets_msg_type_and_content(): + c = _stub_client() + d = LarkClientDriver(c) + await d.update_message( + message_id="om_update", + msg_type="post", + content='{"zh_cn":{"title":"","content":[]}}', + ) + call = c.im.v1.message.aupdate.call_args + req = call.args[0] + assert req.message_id == "om_update" + assert req.body.msg_type == "post" + assert req.body.content == '{"zh_cn":{"title":"","content":[]}}' + + +@pytest.mark.asyncio +async def test_reaction_add_and_remove_call_correct_service(): + c = _stub_client() + d = LarkClientDriver(c) + await d.add_reaction(message_id="om_1", emoji_type="THUMBSUP") + assert c.im.v1.message_reaction.acreate.await_count == 1 + await d.remove_reaction(message_id="om_1", reaction_id="rxn_1") + assert c.im.v1.message_reaction.adelete.await_count == 1 + + +@pytest.mark.asyncio +async def test_reaction_list_builds_filter_request(): + c = _stub_client() + d = LarkClientDriver(c) + + await d.list_reactions( + message_id="om_1", + emoji_type="THUMBSUP", + page_token="page_1", + page_size=20, + ) + + req = c.im.v1.message_reaction.alist.call_args.args[0] + assert req.paths["message_id"] == "om_1" + assert req.page_size == 20 + assert dict(req.queries) == { + "reaction_type": "THUMBSUP", + "page_token": "page_1", + "page_size": "20", + } diff --git a/lark_channel/channel/tests/test_duration_parsers.py b/lark_channel/channel/tests/test_duration_parsers.py new file mode 100644 index 0000000..8cd05df --- /dev/null +++ b/lark_channel/channel/tests/test_duration_parsers.py @@ -0,0 +1,136 @@ +"""Binary-format duration parsers — synthetic buffers for deterministic tests.""" + +import struct + +from lark_channel.channel.outbound.media.duration_mp4 import parse_mp4_duration +from lark_channel.channel.outbound.media.duration_ogg import parse_opus_duration + + +# ---- OGG / Opus --------------------------------------------------------- + + +def _ogg_page(granule: int) -> bytes: + """Build a minimal OggS page header with the given granule position.""" + # Structure: "OggS"(4) + version(1) + type(1) + granule(8) + serial(4) + # + seq(4) + checksum(4) + n_segs(1) = 27 bytes total + header = b"OggS" + header += bytes([0]) # version + header += bytes([4]) # type = end of stream + header += struct.pack(" bytes: + size = 8 + len(payload) + return struct.pack(">I", size) + typ + payload + + +def _mvhd_v0(timescale: int, duration: int) -> bytes: + # version(1) + flags(3) + creation(4) + modification(4) + timescale(4) + # + duration(4) + rate(4) + volume(2) + reserved(10) + matrix(36) + # + pre_defined(24) + next_track_id(4) = 100 bytes + payload = bytes([0, 0, 0, 0]) # version + flags + payload += struct.pack(">I", 0) # creation + payload += struct.pack(">I", 0) # modification + payload += struct.pack(">I", timescale) + payload += struct.pack(">I", duration) + payload += b"\x00" * 76 # rest (padded) + return _box(b"mvhd", payload) + + +def _mvhd_v1(timescale: int, duration: int) -> bytes: + payload = bytes([1, 0, 0, 0]) # version 1 + flags + payload += struct.pack(">Q", 0) # creation (64-bit) + payload += struct.pack(">Q", 0) # modification (64-bit) + payload += struct.pack(">I", timescale) + payload += struct.pack(">Q", duration) # duration (64-bit) + payload += b"\x00" * 80 # rest + return _box(b"mvhd", payload) + + +def test_mp4_duration_v0_box(): + # timescale=1000 duration=5000 → 5000 ms + mvhd = _mvhd_v0(1000, 5000) + moov = _box(b"moov", mvhd) + # Prepend a skippable ftyp box to simulate a real file + ftyp = _box(b"ftyp", b"isom\x00\x00\x02\x00") + assert parse_mp4_duration(ftyp + moov) == 5000 + + +def test_mp4_duration_v1_box(): + mvhd = _mvhd_v1(48000, 24000) # timescale 48kHz, 24000 ticks = 500 ms + moov = _box(b"moov", mvhd) + assert parse_mp4_duration(moov) == 500 + + +def test_mp4_duration_missing_moov_returns_none(): + ftyp = _box(b"ftyp", b"isom\x00\x00\x02\x00") + assert parse_mp4_duration(ftyp) is None + + +def test_mp4_duration_missing_mvhd_returns_none(): + moov = _box(b"moov", _box(b"trak", b"\x00" * 32)) # no mvhd inside + assert parse_mp4_duration(moov) is None + + +def test_mp4_duration_zero_timescale_returns_none(): + mvhd = _mvhd_v0(0, 5000) + moov = _box(b"moov", mvhd) + assert parse_mp4_duration(moov) is None + + +def test_mp4_duration_extended_size_box(): + """Box with size=1 indicates a 64-bit extended size follows.""" + mvhd = _mvhd_v0(1000, 3000) + moov_payload = mvhd + # Construct moov with extended-size header + extended = b"\x00\x00\x00\x01" + b"moov" + struct.pack(">Q", 16 + len(moov_payload)) + moov_payload + assert parse_mp4_duration(extended) == 3000 + + +def test_mp4_duration_empty_returns_none(): + assert parse_mp4_duration(b"") is None + + +def test_mp4_duration_truncated_returns_none(): + assert parse_mp4_duration(b"\x00\x00\x00\x08moov") is None # header only, no payload diff --git a/lark_channel/channel/tests/test_edit_message_polymorphic.py b/lark_channel/channel/tests/test_edit_message_polymorphic.py new file mode 100644 index 0000000..2737be3 --- /dev/null +++ b/lark_channel/channel/tests/test_edit_message_polymorphic.py @@ -0,0 +1,110 @@ +"""Polymorphic edit_message materialization tests.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from lark_channel.channel import FeishuChannel +from lark_channel.channel.config import MarkdownConverter, OutboundConfig +from lark_channel.channel.outbound.sender import OutboundSender +from lark_channel.channel.types import ( + MediaSource, + OutboundCard, + OutboundImage, + OutboundPost, + OutboundText, +) + + +@pytest.mark.asyncio +async def test_materialize_for_edit_text_body(): + s = OutboundSender(driver=None) + body = await s.materialize_for_edit(OutboundText(text="plain")) + assert body["msg_type"] == "text" + assert json.loads(body["content"]) == {"text": "plain"} + + +@pytest.mark.asyncio +async def test_materialize_for_edit_markdown_native_body(): + cfg = OutboundConfig(markdown_converter=MarkdownConverter(tag_md_mode="native")) + s = OutboundSender(driver=None, config=cfg) + body = await s.materialize_for_edit(OutboundPost(markdown="# H1")) + assert body["msg_type"] == "post" + content = json.loads(body["content"]) + assert content["zh_cn"]["content"] == [[{"tag": "md", "text": "# H1"}]] + + +@pytest.mark.asyncio +async def test_materialize_for_edit_post_ast_opaque(): + ast = {"zh_cn": {"title": "", "content": [[{"tag": "text", "text": "hi"}]]}} + s = OutboundSender(driver=None) + body = await s.materialize_for_edit(OutboundPost(post=ast)) + assert body["msg_type"] == "post" + assert json.loads(body["content"]) == ast + + +@pytest.fixture +def channel(): + ch = FeishuChannel( + app_id="cli_x", + app_secret="secret", + outbound=OutboundConfig(markdown_converter=MarkdownConverter(tag_md_mode="native")), + ) + ch._driver.update_message = AsyncMock(return_value={"code": 0, "data": {"message_id": "om_1"}}) + return ch + + +@pytest.mark.asyncio +async def test_edit_message_bare_string_aligns_with_send_as_markdown(channel): + result = await channel.edit_message("om_1", "# H1") + assert result.success is True + channel._driver.update_message.assert_awaited_once() + kwargs = channel._driver.update_message.await_args.kwargs + assert kwargs["message_id"] == "om_1" + assert kwargs["msg_type"] == "post" + content = json.loads(kwargs["content"]) + assert content["zh_cn"]["content"] == [[{"tag": "md", "text": "# H1"}]] + + +@pytest.mark.asyncio +async def test_edit_message_text_dict_stays_text(channel): + await channel.edit_message("om_1", {"text": "# literal"}) + kwargs = channel._driver.update_message.await_args.kwargs + assert kwargs["msg_type"] == "text" + assert json.loads(kwargs["content"]) == {"text": "# literal"} + + +@pytest.mark.asyncio +async def test_edit_message_markdown_dict_is_post(channel): + await channel.edit_message("om_1", {"markdown": "**bold**"}) + kwargs = channel._driver.update_message.await_args.kwargs + assert kwargs["msg_type"] == "post" + content = json.loads(kwargs["content"]) + assert content["zh_cn"]["content"] == [[{"tag": "md", "text": "**bold**"}]] + + +@pytest.mark.asyncio +async def test_edit_message_post_dict_is_opaque(channel): + ast = {"zh_cn": {"title": "", "content": [[{"tag": "text", "text": "opaque"}]]}} + await channel.edit_message("om_1", {"post": ast}) + kwargs = channel._driver.update_message.await_args.kwargs + assert kwargs["msg_type"] == "post" + assert json.loads(kwargs["content"]) == ast + + +@pytest.mark.asyncio +async def test_edit_message_rejects_card_and_mentions_update_card(channel): + with pytest.raises(TypeError, match="update_card"): + await channel.edit_message("om_1", OutboundCard(card={"schema": "2.0"})) + channel._driver.update_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_edit_message_rejects_media(channel): + with pytest.raises(TypeError, match="text/post"): + await channel.edit_message( + "om_1", + OutboundImage(source=MediaSource(kind="key", key="img_x")), + ) + channel._driver.update_message.assert_not_called() diff --git a/lark_channel/channel/tests/test_errors.py b/lark_channel/channel/tests/test_errors.py new file mode 100644 index 0000000..18b7733 --- /dev/null +++ b/lark_channel/channel/tests/test_errors.py @@ -0,0 +1,44 @@ +"""Error classification tests.""" + +from lark_channel.channel.errors import FeishuChannelErrorCode, classify_error + + +def test_token_invalid_is_retryable(): + e = classify_error(99991663, "expired") + assert e.code == FeishuChannelErrorCode.PERMISSION_DENIED + assert e.retryable is True + + +def test_rate_limit(): + assert classify_error(11020).code == FeishuChannelErrorCode.RATE_LIMITED + + +def test_target_revoked(): + assert classify_error(230002).code == FeishuChannelErrorCode.TARGET_REVOKED + + +def test_length_exceed_maps_to_format_error(): + e = classify_error(230021) + assert e.code == FeishuChannelErrorCode.FORMAT_ERROR + assert e.retryable is False + + +def test_5xx_maps_to_unknown_retryable(): + assert classify_error(500).code == FeishuChannelErrorCode.UNKNOWN + assert classify_error(500).retryable is True + assert classify_error(50100).code == FeishuChannelErrorCode.UNKNOWN + + +def test_unknown_defaults(): + e = classify_error(123456) + assert e.code == FeishuChannelErrorCode.UNKNOWN + assert e.retryable is False + + +def test_zero_is_unknown_and_non_retryable(): + assert classify_error(0).retryable is False + + +def test_download_failed_enum_value_exists(): + from lark_channel.channel import FeishuChannelErrorCode + assert FeishuChannelErrorCode.DOWNLOAD_FAILED.value == "download_failed" diff --git a/lark_channel/channel/tests/test_errors_helpers.py b/lark_channel/channel/tests/test_errors_helpers.py new file mode 100644 index 0000000..3344728 --- /dev/null +++ b/lark_channel/channel/tests/test_errors_helpers.py @@ -0,0 +1,103 @@ +"""Coverage for classify_api_error / classify_http_status / is_retryable / +is_reply_target_gone / is_format_error / FeishuChannelError.""" + +from lark_channel.channel.errors import ( + FeishuChannelError, + FeishuChannelErrorCode, + classify_api_error, + classify_http_status, + is_format_error, + is_reply_target_gone, + is_retryable, +) + + +def test_feishu_channel_error_code_has_10_canonical_values(): + canonical = { + "format_error", "target_revoked", "rate_limited", "permission_denied", + "upload_failed", "download_failed", "ssrf_blocked", "send_timeout", + "not_connected", "unknown", + } + assert {m.value for m in FeishuChannelErrorCode} == canonical + + +def test_feishu_channel_error_construction(): + err = FeishuChannelError(FeishuChannelErrorCode.FORMAT_ERROR, "bad card", context={"to": "oc_x"}) + assert err.code == FeishuChannelErrorCode.FORMAT_ERROR + assert err.context == {"to": "oc_x"} + assert "format_error" in repr(err) or "bad card" in repr(err) + + +def test_feishu_channel_error_default_message_uses_code(): + err = FeishuChannelError(FeishuChannelErrorCode.NOT_CONNECTED) + assert err.args[0] == "not_connected" + + +def test_classify_api_error_target_revoked_family(): + # 230001 is NOT target_revoked — it is "invalid message content" (format error). + # Regression: putting 230001 in the target_revoked bucket triggered the + # reply-gone → fresh-create fallback, hiding schema bugs until prod. + for code in (230020, 230017, 230002, 230005): + assert classify_api_error(code) == FeishuChannelErrorCode.TARGET_REVOKED, code + + +def test_classify_api_error_230001_is_format_error_not_target_revoked(): + # See Feishu error-code docs: 230001 "invalid message content" = + # malformed body / schema violation. Must route through the plain-text + # fallback path (is_format_error), not the reply-gone path. + from lark_channel.channel.errors import classify_error, is_format_error + + assert classify_api_error(230001) == FeishuChannelErrorCode.FORMAT_ERROR + err = classify_error(230001, "invalid message content") + assert is_format_error(err.code) + + +def test_classify_api_error_permission_denied_family(): + for code in (99991400, 99991401, 99991672, 99991679, 99991680, 99991681, 230003, 230010): + assert classify_api_error(code) == FeishuChannelErrorCode.PERMISSION_DENIED, code + + +def test_classify_api_error_rate_limited_family(): + for code in (99991402, 11020, 11021): + assert classify_api_error(code) == FeishuChannelErrorCode.RATE_LIMITED, code + + +def test_classify_api_error_format_family(): + for code in (230099, 230021, 230022): + assert classify_api_error(code) == FeishuChannelErrorCode.FORMAT_ERROR, code + + +def test_classify_api_error_unknown_fallback(): + assert classify_api_error(123456) == FeishuChannelErrorCode.UNKNOWN + + +def test_classify_api_error_zero_is_unknown(): + assert classify_api_error(0) == FeishuChannelErrorCode.UNKNOWN + + +def test_classify_http_status_mapping(): + assert classify_http_status(429) == FeishuChannelErrorCode.RATE_LIMITED + assert classify_http_status(401) == FeishuChannelErrorCode.PERMISSION_DENIED + assert classify_http_status(403) == FeishuChannelErrorCode.PERMISSION_DENIED + assert classify_http_status(404) == FeishuChannelErrorCode.TARGET_REVOKED + assert classify_http_status(400) == FeishuChannelErrorCode.FORMAT_ERROR + assert classify_http_status(503) == FeishuChannelErrorCode.UNKNOWN + assert classify_http_status(200) == FeishuChannelErrorCode.UNKNOWN + + +def test_is_retryable_predicate(): + assert is_retryable(FeishuChannelErrorCode.RATE_LIMITED) is True + assert is_retryable(FeishuChannelErrorCode.UNKNOWN) is True + assert is_retryable(FeishuChannelErrorCode.FORMAT_ERROR) is False + assert is_retryable(FeishuChannelErrorCode.TARGET_REVOKED) is False + assert is_retryable(FeishuChannelErrorCode.PERMISSION_DENIED) is False + + +def test_is_reply_target_gone_predicate(): + assert is_reply_target_gone(FeishuChannelErrorCode.TARGET_REVOKED) is True + assert is_reply_target_gone(FeishuChannelErrorCode.FORMAT_ERROR) is False + + +def test_is_format_error_predicate(): + assert is_format_error(FeishuChannelErrorCode.FORMAT_ERROR) is True + assert is_format_error(FeishuChannelErrorCode.UNKNOWN) is False diff --git a/lark_channel/channel/tests/test_feishu_channel.py b/lark_channel/channel/tests/test_feishu_channel.py new file mode 100644 index 0000000..c1f7bb0 --- /dev/null +++ b/lark_channel/channel/tests/test_feishu_channel.py @@ -0,0 +1,169 @@ +"""Tests for the Node-aligned FeishuChannel facade.""" + +from unittest.mock import AsyncMock + +import pytest + +from lark_channel.channel import FeishuChannel, RejectEvent +from lark_channel.channel._coerce import ( + coerce_outbound as _coerce_outbound, + coerce_send_opts as _coerce_send_opts, + normalize_event_name as _normalize_event_name, +) +from lark_channel.channel.types import ( + OutboundCard, + OutboundFile, + OutboundImage, + OutboundPost, + OutboundText, + SendOpts, + SendResult, +) + + +# ---- input coercion -------------------------------------------------------- + + +def test_coerce_string_defaults_to_markdown(): + ob = _coerce_outbound("**hi**") + assert isinstance(ob, OutboundPost) and ob.markdown == "**hi**" + + +def test_coerce_dict_inputs_by_key(): + assert isinstance(_coerce_outbound({"markdown": "hi"}), OutboundPost) + assert isinstance(_coerce_outbound({"text": "hi"}), OutboundText) + assert isinstance(_coerce_outbound({"post": {}}), OutboundPost) + assert isinstance(_coerce_outbound({"card": {}}), OutboundCard) + + +def test_coerce_image_with_url(): + ob = _coerce_outbound({"image": {"source": "https://example.com/x.png"}}) + assert isinstance(ob, OutboundImage) + assert ob.source.kind == "url" + assert ob.source.url == "https://example.com/x.png" + + +def test_coerce_image_with_key(): + ob = _coerce_outbound({"image": {"source": "img_abcdef"}}) + assert isinstance(ob, OutboundImage) + assert ob.source.kind == "key" + assert ob.source.key == "img_abcdef" + + +def test_coerce_file_with_filename(): + ob = _coerce_outbound({"file": {"source": b"\x00\x01", "fileName": "x.pdf"}}) + assert isinstance(ob, OutboundFile) and ob.file_name == "x.pdf" + + +def test_coerce_send_opts_camel_and_snake(): + opts = _coerce_send_opts({"replyTo": "om_x", "reply_in_thread": True}) + assert opts.reply_to == "om_x" and opts.reply_in_thread is True + + +def test_coerce_send_opts_passthrough_sendopts(): + so = SendOpts(reply_to="om_y") + assert _coerce_send_opts(so) is so + + +# ---- event name normalization ---------------------------------------------- + + +def test_event_name_aliases_collapse_to_node_names(): + assert _normalize_event_name("interaction") == "cardAction" + assert _normalize_event_name("bot_added") == "botAdded" + assert _normalize_event_name("bot_leave") == "botLeave" + assert _normalize_event_name("message_read") == "messageRead" + + +# ---- FeishuChannel.on / off / reject wiring -------------------------------- + + +@pytest.fixture +def channel(): + c = FeishuChannel(app_id="cli_test", app_secret="s") + return c + + +def test_on_registers_and_unsubscribes(channel): + calls = [] + + def h(msg): + calls.append(msg) + + unsub = channel.on("message", h) + # Handlers are stored as a list so multiple subscribers can co-exist. + for fn in channel._handlers.get("message", []): + fn(None) + assert len(calls) == 1 + unsub() + # The list is cleared and the key pruned on final unsubscribe. + assert channel._handlers.get("message") is None + + +def test_on_appends_multiple_handlers(channel): + # New semantics: on() appends rather than replacing. Aligns with node-sdk + # and with the EventEmitter / pub-sub pattern Python users expect. + calls = [] + channel.on("message", lambda m: calls.append("a")) + channel.on("message", lambda m: calls.append("b")) + for fn in channel._handlers.get("message", []): + fn(None) + assert calls == ["a", "b"] + + +def test_on_dict_form_registers_many(channel): + got = {} + + channel.on({ + "message": lambda m: got.setdefault("m", 1), + "cardAction": lambda e: got.setdefault("c", 1), + }) + assert "message" in channel._handlers + assert "cardAction" in channel._handlers + + +def test_reject_handler_receives_reject_event(channel): + received = [] + channel.on("reject", lambda e: received.append(e)) + # Simulate safety pipeline emitting a reject + channel._emit_reject(RejectEvent( + message_id="om_x", chat_id="oc_1", sender_id="ou_s", + reason="policy_no_mention", + )) + assert received[0].reason == "policy_no_mention" + + +# ---- send routing ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_markdown_string_uses_post_sender(channel): + channel._sender = AsyncMock() # type: ignore[attr-defined] + channel._sender.send = AsyncMock(return_value=SendResult.ok(message_id="om_ok")) + await channel.send("oc_123", "**hi**") + call = channel._sender.send.call_args + # OutboundPost was constructed + sent_msg = call.args[0] + assert isinstance(sent_msg, OutboundPost) and sent_msg.markdown == "**hi**" + + +@pytest.mark.asyncio +async def test_send_infers_receive_id_type_from_prefix(channel): + channel._sender = AsyncMock() + channel._sender.send = AsyncMock(return_value=SendResult.ok(message_id="om_ok")) + await channel.send("ou_someone", {"text": "hi"}) + call = channel._sender.send.call_args + assert call.kwargs["receive_id_type"] == "open_id" + await channel.send("oc_group", {"text": "hi"}) + call = channel._sender.send.call_args + assert call.kwargs["receive_id_type"] == "chat_id" + + +# ---- update_policy + get_policy -------------------------------------------- + + +def test_update_policy_patches_live(channel): + # Force bg loop + safety pipeline up + channel._ensure_bg_loop() + channel.update_policy(require_mention=False) + assert channel.get_policy().require_mention is False diff --git a/lark_channel/channel/tests/test_flatten.py b/lark_channel/channel/tests/test_flatten.py new file mode 100644 index 0000000..9806b28 --- /dev/null +++ b/lark_channel/channel/tests/test_flatten.py @@ -0,0 +1,228 @@ +"""Tests for flat-string content + resources[] derivation (Node-aligned).""" + +from lark_channel.channel.normalize.flatten import flatten +from lark_channel.channel.types import ( + AudioContent, + FileContent, + FolderContent, + GeneralCalendarContent, + HongbaoContent, + ImageContent, + InteractiveContent, + LocationContent, + MediaContent, + MergeForwardContent, + MergeForwardItem, + PostContent, + ShareCalendarEventContent, + ShareChatContent, + ShareUserContent, + StickerContent, + TextContent, + UnknownContent, +) + + +def test_text_flat_passthrough(): + t, r = flatten(TextContent(text="hello")) + assert t == "hello" and r == [] + + +def test_image_markdown_placeholder_plus_resource(): + t, r = flatten(ImageContent(image_key="img_abc")) + assert t == "![image](img_abc)" + assert len(r) == 1 + assert r[0].type == "image" and r[0].file_key == "img_abc" + + +def test_file_xml_placeholder_resource_has_name(): + t, r = flatten(FileContent(file_key="f_x", file_name="report.pdf")) + assert "key=\"f_x\"" in t and "report.pdf" in t + assert r[0].type == "file" and r[0].file_name == "report.pdf" + + +def test_video_uses_media_content_with_cover(): + t, r = flatten(MediaContent(file_key="v_1", image_key="cov_1", duration_ms=3000)) + assert "