diff --git a/.gitignore b/.gitignore index c245b2e49..27f4642ac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ bard.json !.pre-commit-config.yaml !components.yaml !/docker-compose.yaml +docker/data data/labels/instance_id.json .DS_Store /data diff --git a/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md b/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md index 1f85e05ea..83c75120c 100644 --- a/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md +++ b/dev-docs/agent-runner-pluginization/EVENT_DEBUG_FULL_QA_2026-09-05.md @@ -96,4 +96,4 @@ The full regression also found Windows portability problems in the skills toolin 2. **Bounded stream pressure can fail long responses.** Two recovery runs emitted thinking and then `Streaming action consumer is too slow; response buffer full`; later recovery and six-tool runs succeeded. The error originates in the SDK's existing 128-frame response queue and is preserved in the UI. It is not a missing-tool-result or 30-second HTTP timeout. Full end-to-end flow control under sustained overload remains open; queue limits were not removed to conceal the failure. 3. Mock results verify debug behavior, not real platform credentials, permissions, delivery, or every adapter/event Cartesian combination. Already launched external tool termination timing was not independently measured. A development-branch push does not mean these remaining production release gates passed. -For reproduction, use the retained test Agent, the Mock examples in [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md), and the original welcome Agent. Local diagnostic traces and fixture artifacts are excluded from Git; they contain model responses and local runtime state. This report contains outcomes rather than raw provider reasoning or secrets. +For reproduction, use the retained test Agent, the Mock examples in [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md), and the original welcome Agent. Local diagnostic traces and fixture artifacts are excluded from Git; they contain model responses and local runtime state. This report contains outcomes rather than raw provider reasoning or secrets. diff --git a/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md index daa303932..c9cca99ab 100644 --- a/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md +++ b/dev-docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -3,7 +3,7 @@ 本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。 - SDK ↔ Host 的协议数据结构(`RunnerContext`、`RunnerManifest`、`RunnerResult`、`RunnerAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。 -- 测试执行入口和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 - 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`RunnerDescriptor`)不属于 SDK 协议字段。 ## 1. 目标 diff --git a/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md index 9f632af9a..a4a140d15 100644 --- a/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md +++ b/dev-docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -1,6 +1,6 @@ # 官方 Runner 插件迁移计划 -本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。 +本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。 @@ -110,7 +110,7 @@ Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/ ## 7. Code-agent harness runner 当前形态 -外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `RunnerResult`。本地 smoke 验收入口与记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。 +外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `RunnerResult`。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 当前形态: diff --git a/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md b/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md index cc6973a8c..2e8af53d0 100644 --- a/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/dev-docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -2,7 +2,7 @@ 本文档是 LangBot Host 与插件 SDK / Runtime / Runner 之间协议合同的**唯一规范来源(single source of truth)**。 -- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 - 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。 - Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 diff --git a/dev-docs/agent-runner-pluginization/README.md b/dev-docs/agent-runner-pluginization/README.md index 8d6708211..48ba2c738 100644 --- a/dev-docs/agent-runner-pluginization/README.md +++ b/dev-docs/agent-runner-pluginization/README.md @@ -9,7 +9,7 @@ ## 文档维护原则(单一事实源) - **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。 -- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 - Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。 - 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。 @@ -54,7 +54,7 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完整 Stage 链;当 Pipeline 的 AI Stage 调用 runner 时,`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Agent 目标则直接从自己的持久配置构造 binding。两条路径可以复用同一套 Runner Host capabilities,但 Pipeline 本身不会被投影或持久化为 Agent。 -下一轮测试路径、状态定义和 smoke 记录见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md)。 +下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 ## 术语表 @@ -87,7 +87,7 @@ EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完 | [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 | | [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 | | [PLATFORM_ACTION_TOOLS.md](./PLATFORM_ACTION_TOOLS.md) | 当前事件工具、平台动作和普通工具的配置、投射及执行授权。 | -| [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md) | Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | +| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | | [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 | ## 工作拆分 diff --git a/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md index 6f43ec877..a5c285d4e 100644 --- a/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md +++ b/dev-docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -2,7 +2,7 @@ 本文档记录 Runner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `Runner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。 -> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 +> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 > > 实现状态说明:本文描述的是 Runtime Control Plane v2 的目标能力和分阶段落地建议。当前 Runner 插件化主线已经具备 event-first context、run-scoped authorization、EventLog / Transcript / State / sandbox 文件等 Host capability,并已落地持久 `AgentRun` / `AgentRunEvent` ledger、run control actions、最小 runtime heartbeat/claim lease 和 admin reconcile 原语。完整 Agent Platform 产品形态、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍未完成。当前实现状态以 [STATUS.md](./STATUS.md) 为准。 diff --git a/dev-docs/agent-runner-pluginization/STATUS.md b/dev-docs/agent-runner-pluginization/STATUS.md index 70acf22ae..a58eaaa50 100644 --- a/dev-docs/agent-runner-pluginization/STATUS.md +++ b/dev-docs/agent-runner-pluginization/STATUS.md @@ -1,6 +1,6 @@ # Runner Pluginization Status -本文档是 `dev-docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [RUNNER_QA_GUIDE.md](./RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。 +本文档是 `dev-docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。 状态快照日期:2026-09-05。代码基线为 LangBot `dev/4.11.x` / `a4d36aa2d` 和 SDK `dev/4.11.x` / `f1da058`;本文是本地检视快照,不代表远端最新状态或正式发布批准。 diff --git a/dev-docs/event-based-agents/adapters/line.md b/dev-docs/event-based-agents/adapters/line.md new file mode 100644 index 000000000..1b55e65e4 --- /dev/null +++ b/dev-docs/event-based-agents/adapters/line.md @@ -0,0 +1,72 @@ +# LINE EBA Adapter + +## Structure + +LINE is migrated into `src/langbot/pkg/platform/adapters/line/` with the standard EBA adapter layout: + +- `adapter.py` owns lifecycle, unified webhook handling, signature verification, listener dispatch, and outbound send/reply. +- `event_converter.py` maps LINE webhook events to `message.received`, `message.deleted`, group membership events, and `platform.specific`. +- `message_converter.py` maps common `MessageChain` components to LINE message objects and maps inbound LINE text/image/voice payloads back to EBA components. +- `api_impl.py` implements the optional common APIs against the LINE Messaging API. +- `platform_api.py` declares the safe LINE-specific pass-through actions. +- `manifest.yaml` declares `line-omni`. + +The legacy `src/langbot/pkg/platform/sources/line.py` adapter is kept unchanged. + +Request handling reuses the proven behaviour of the legacy adapter: `WebhookParser` verifies `X-Line-Signature`, the webhook acknowledges immediately, and each event is converted and dispatched in a background task. + +## Configuration + +| Field | Required | Notes | +|-------|----------|-------| +| `webhook_url` | No | Generated by LangBot. Paste it into the LINE Developers console and enable "Use webhook". | +| `channel_access_token` | Yes | Long-lived channel access token, used for all outbound calls. | +| `channel_secret` | Yes | Channel secret, used to verify `X-Line-Signature`. | + +`bot_account_id` is resolved through `get_bot_info` on startup and is used to drop the adapter's own messages. + +## Events + +| Event | Notes | +|-------|-------| +| `message.received` | Text, image, voice, and other message types. Group and room chats are mapped to group chat identity. LINE mentions become `At` components. | +| `message.deleted` | Emitted for LINE `unsend` events. | +| `group.member_joined` | Emitted for `memberJoined`; the first listed member is the event subject. | +| `group.member_left` | Emitted for `memberLeft`. | +| `bot.invited_to_group` | Emitted when the Official Account joins a group or room (`join`). | +| `bot.removed_from_group` | Emitted when the Official Account leaves (`leave`). | +| `platform.specific` | `line.follow`, `line.unfollow`, `line.postback`, `line.beacon`, and any other LINE event type, with `chat_id` in `data`. | + +Legacy pipeline bindings still receive `FriendMessage` / `GroupMessage`; the adapter converts the unified event for them. + +## Common APIs + +Required: + +- `send_message` +- `reply_message` + +Optional: + +- `get_user_info` +- `get_group_info` +- `leave_group` +- `call_platform_api` + +`reply_message` uses the event's reply token and falls back to `push_message` when the token is rejected or already used, so multi-message answers still arrive. + +## Platform APIs + +| Action | Notes | +|--------|-------| +| `get_bot_info` | Returns the Official Account profile. | +| `get_profile` | Calls `get_profile` for a user id. | +| `get_group_summary` | Calls `get_group_summary` for a group id. | + +## Known Limits + +- LINE has no group member listing API, so `get_group_member_list` raises `NotSupportedError`. +- Outbound images require a public URL: the Messaging API fetches `originalContentUrl`, so a base64-only `Image` component raises `NotSupportedError` instead of sending nothing. +- LINE has no message edit API; `edit_message` raises `NotSupportedError`. Streaming output is not supported. +- Requests are capped at five messages per call and 5000 characters per text message; longer chains are split into multiple requests. +- Offline coverage lives in `tests/unit_tests/platform/test_line_eba_adapter.py`. A real channel (webhook delivery, reply tokens, media download) still needs verification against a live LINE Official Account. diff --git a/dev-docs/review/minimax-protocol.md b/dev-docs/review/minimax-protocol.md new file mode 100644 index 000000000..32d60344b --- /dev/null +++ b/dev-docs/review/minimax-protocol.md @@ -0,0 +1,50 @@ +# MiniMax requester compatibility + +Implementation reference: `https://platform.minimax.io/docs/api-reference/text-openai-api.md` +(checked 2026-10-30). + +## Discovery and saved configurations + +New MiniMax configurations use `https://api.minimax.io/v1`. Saved endpoints and +manually selected model identifiers are not migrated or rewritten. + +Direct discovery recommends MiniMax-M3.1-Flash followed by MiniMax-M3, including +when the direct service has no model-list endpoint (404). Authentication, +rate-limit, and server errors still fail the scan. Documented capabilities and +the 1,000,000-token context are defaults for these direct recommendations; +advertised context lengths are retained. + +OpenRouter discovery never invents routes or replaces advertised metadata. +All advertised Flash variants precede M3. Only known legacy MiniMax text +families (M1/M2, MiniMax-01, abab) are removed from discovery; non-text entries, +other vendors, custom identifiers, and future model families remain available. +This filtering does not prevent saved/manual legacy models from being invoked. + +## Thinking and conversation history + +- Flash supports provider defaults, adaptive thinking (`enabled`), and + `low`, `medium`, `high`, `xhigh`, `max` effort; disabling is rejected locally. +- M3 supports provider defaults, adaptive thinking, and disabled thinking. +- The default policy sends no additional thinking, admission-tier, or + output-format parameters. Gateway requests keep the existing gateway protocol. +- `remove_think` changes display text, not the assistant history sent back to + MiniMax. Both `reasoning_content` and the original inline `` content are + retained, including when the next M3 request disables thinking. + +`minimax_raw_content` is internal provider metadata. Non-streaming responses +store their original content before display filtering. Streaming responses +buffer raw content and emit one complete snapshot at the finish event, or at +normal iterator exhaustion when the finish event is absent. This matches Local +Agent's opaque-field replacement semantics: only `reasoning_content` is a +concatenated delta there. Sending raw-content deltas would silently retain only +the last fragment. The internal field is removed during request conversion and +is not replayed as content when the user switches to another provider. + +## Regression coverage + +`tests/unit_tests/provider/test_minimax_protocol.py` covers real locked SDK +serialization against mocked HTTP/SSE, saved endpoints and identifiers, +thinking validation, image input, discovery failures and metadata, streaming +termination, JSON history round trips, tool follow-ups, and provider switching. +It makes no paid inference calls. The provider suite remains the shared-provider +regression check; Python packaging is independent of frontend/runtime deployment. diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index fe1aecba0..cf74c6711 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,7 +1,5 @@ # Docker Compose configuration for LangBot # For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs -version: "3" - services: langbot_plugin_runtime: @@ -16,6 +14,10 @@ services: - ./data/plugin-runtime:/app/data/plugin-runtime ports: - 5401:5401 + # ACP Agent Runner daemon hub. With `daemon-enabled = true` in the plugin + # config, a user-side runner daemon behind NAT connects outward to + # ws://:8766. Harmless when the hub is disabled. + - 8766:8766 restart: on-failure environment: - TZ=Asia/Shanghai diff --git a/src/langbot/pkg/platform/adapter_names.py b/src/langbot/pkg/platform/adapter_names.py index be5c1f56b..debd12e5e 100644 --- a/src/langbot/pkg/platform/adapter_names.py +++ b/src/langbot/pkg/platform/adapter_names.py @@ -13,6 +13,7 @@ OMNI_ADAPTER_NAMES = frozenset( 'wecombot', 'wecomcs', 'officialaccount', + 'line', } ) diff --git a/src/langbot/pkg/platform/adapters/line/adapter.py b/src/langbot/pkg/platform/adapters/line/adapter.py new file mode 100644 index 000000000..9f2a6d52c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/adapter.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import asyncio +import traceback +import typing + +import pydantic +import quart +from linebot.v3.exceptions import InvalidSignatureError +from linebot.v3.messaging import ( + ApiClient, + Configuration, + MessagingApi, + MessagingApiBlob, + PushMessageRequest, + ReplyMessageRequest, +) +from linebot.v3.webhook import WebhookParser +from linebot.v3.webhooks import Event + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + +from langbot.pkg.platform.adapters.line.api_impl import LineAPIMixin +from langbot.pkg.platform.adapters.line.errors import NotSupportedError +from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter +from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter +from langbot.pkg.platform.adapters.line.platform_api import PLATFORM_API_MAP +from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME, MAX_MESSAGES_PER_REQUEST + + +class LineAdapter(LineAPIMixin, abstract_platform_adapter.AbstractMessagePlatformAdapter): + """LINE Messaging API adapter in unified webhook mode.""" + + api_client: typing.Any = pydantic.Field(exclude=True) + bot: typing.Any = pydantic.Field(exclude=True) + blob_api: typing.Any = pydantic.Field(exclude=True) + parser: typing.Any = pydantic.Field(exclude=True) + + message_converter: LineMessageConverter = LineMessageConverter() + event_converter: LineEventConverter = LineEventConverter() + + config: dict + config_secret: str = '' + bot_uuid: str | None = None + + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + background_tasks: set = pydantic.Field(default_factory=set, exclude=True) + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + missing_keys = [key for key in ('channel_access_token', 'channel_secret') if not config.get(key)] + if missing_keys: + raise ValueError(f'LINE adapter missing config: {missing_keys}') + configuration = Configuration(access_token=config['channel_access_token']) + api_client = ApiClient(configuration) + bot = MessagingApi(api_client) + blob_api = MessagingApiBlob(api_client) + bot_account_id = config.get('bot_account_id', '') or '' + super().__init__( + config=config, + logger=logger, + bot_account_id=bot_account_id, + config_secret=config['channel_secret'], + api_client=api_client, + bot=bot, + blob_api=blob_api, + parser=WebhookParser(config['channel_secret']), + listeners={}, + background_tasks=set(), + message_converter=LineMessageConverter(), + event_converter=LineEventConverter(bot_account_id=bot_account_id, blob_api=blob_api), + ) + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.deleted', + 'group.member_joined', + 'group.member_left', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_user_info', + 'get_group_info', + 'leave_group', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + messages = await self.message_converter.yiri2target(message) + for batch in _batches(messages): + request = PushMessageRequest(to=str(target_id), messages=batch) + await asyncio.to_thread(self.bot.push_message, request) + return platform_events.MessageResult(raw={'sent': len(messages), 'target_id': str(target_id)}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + source = await LineEventConverter.yiri2target(message_source) + messages = await self.message_converter.yiri2target(message) + reply_token = str(getattr(source, 'reply_token', '') or '') + chat_id = _source_chat_id(source) or str(getattr(message_source, 'chat_id', '') or '') + for batch in _batches(messages): + if reply_token: + try: + await asyncio.to_thread( + self.bot.reply_message, ReplyMessageRequest(reply_token=reply_token, messages=batch) + ) + # A reply token is single-use; later batches of the same turn fall back to push. + reply_token = '' + continue + except Exception: + await self.logger.warning( + f'LINE reply token rejected, falling back to push: {traceback.format_exc()}' + ) + reply_token = '' + if not chat_id: + raise NotSupportedError('reply_message:line_missing_target') + await asyncio.to_thread(self.bot.push_message, PushMessageRequest(to=chat_id, messages=batch)) + return platform_events.MessageResult(raw={'sent': len(messages)}) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self, dict(params or {})) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request) -> typing.Any: + signature = request.headers.get('X-Line-Signature') + body = await request.get_data(as_text=True) + if not signature: + await self.logger.warning('LINE webhook rejected: missing X-Line-Signature header') + return quart.Response('Missing X-Line-Signature header', status=400) + try: + events = self.parser.parse(body, signature) + except InvalidSignatureError: + await self.logger.warning( + 'LINE webhook rejected: invalid signature, check channel_access_token/channel_secret' + ) + return quart.Response('Invalid signature', status=400) + for event in events or []: + task = asyncio.create_task(self._dispatch_native_event(event)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + # Acknowledge immediately; LINE retries any non-200 answer. + return quart.Response('OK', status=200) + + async def _dispatch_native_event(self, event: Event) -> None: + try: + if self.bot_account_id and _sender_id(event) == self.bot_account_id: + return + eba_event = await self.event_converter.target2yiri(event) + if eba_event is None: + return + if isinstance(eba_event, platform_events.MessageReceivedEvent): + legacy_event = eba_event.to_legacy_event() + listener = self.listeners.get(type(legacy_event)) + if listener: + await listener(legacy_event, self) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'LINE native event error: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent) -> None: + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + async def run_async(self): + if not self.bot_account_id: + try: + info = await asyncio.to_thread(self.bot.get_bot_info) + self.bot_account_id = info.user_id + self.event_converter.bot_account_id = info.user_id + except Exception: + await self.logger.warning( + 'LINE adapter could not resolve its account id; self-message filtering stays disabled' + ) + await self.logger.info(f'{ADAPTER_NAME} adapter running in unified webhook mode') + while True: + await asyncio.sleep(1) + + async def kill(self) -> bool: + for task in list(self.background_tasks): + task.cancel() + self.background_tasks.clear() + await asyncio.to_thread(self.api_client.close) + return True + + +def _batches(messages: list[typing.Any]) -> list[list[typing.Any]]: + """Split a message array into LINE's per-request budget.""" + return [ + messages[index : index + MAX_MESSAGES_PER_REQUEST] + for index in range(0, len(messages), MAX_MESSAGES_PER_REQUEST) + ] + + +def _sender_id(event: Event) -> str: + source = getattr(event, 'source', None) + return str(getattr(source, 'user_id', '') or '') + + +def _source_chat_id(event: Event) -> str: + """Chat id of the conversation a LINE event belongs to.""" + source = getattr(event, 'source', None) + for attribute in ('group_id', 'room_id', 'user_id'): + value = getattr(source, attribute, '') or '' + if value: + return str(value) + return '' diff --git a/src/langbot/pkg/platform/adapters/line/api_impl.py b/src/langbot/pkg/platform/adapters/line/api_impl.py new file mode 100644 index 000000000..263bb06f2 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/api_impl.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import typing + +from langbot.pkg.platform.adapters.line.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class LineAPIMixin: + """Optional universal APIs; LINE only exposes profile and group summaries.""" + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + import asyncio + + profile = await asyncio.to_thread(self.bot.get_profile, str(user_id)) + return platform_entities.User( + id=profile.user_id, + nickname=profile.display_name, + avatar_url=profile.picture_url or '', + ) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + import asyncio + + try: + summary = await asyncio.to_thread(self.bot.get_group_summary, str(group_id)) + except Exception as exc: + raise NotSupportedError('get_group_info:group_not_found') from exc + return platform_entities.UserGroup( + id=summary.group_id, + name=summary.group_name, + avatar_url=summary.picture_url or '', + ) + + async def leave_group(self, group_id: typing.Union[int, str]) -> None: + import asyncio + + await asyncio.to_thread(self.bot.leave_group, str(group_id)) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + # LINE does not expose a group member listing API. + raise NotSupportedError('get_group_member_list') + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + raise NotSupportedError('get_group_list') + + async def get_friend_list(self) -> list[platform_entities.User]: + raise NotSupportedError('get_friend_list') + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raise NotSupportedError('forward_message') + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') diff --git a/src/langbot/pkg/platform/adapters/line/errors.py b/src/langbot/pkg/platform/adapters/line/errors.py new file mode 100644 index 000000000..b56f459ae --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/errors.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +try: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +except ModuleNotFoundError: + + class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name diff --git a/src/langbot/pkg/platform/adapters/line/event_converter.py b/src/langbot/pkg/platform/adapters/line/event_converter.py new file mode 100644 index 000000000..605c58910 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/event_converter.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import typing + +from linebot.v3.webhooks import ( + BeaconEvent, + Event, + FollowEvent, + GroupSource, + JoinEvent, + LeaveEvent, + MemberJoinedEvent, + MemberLeftEvent, + MessageEvent, + PostbackEvent, + RoomSource, + UnfollowEvent, + UnsendEvent, + UserSource, +) + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + +from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter +from langbot.pkg.platform.adapters.line.types import ADAPTER_NAME + + +def _timestamp(event: Event) -> float: + try: + return float(event.timestamp) / 1000.0 + except (AttributeError, TypeError, ValueError): + return 0.0 + + +def _chat_identity(event: Event) -> tuple[platform_entities.ChatType, str, typing.Any, str]: + """Return chat type, chat id, group entity and sender id for a LINE event source.""" + source = event.source + if isinstance(source, GroupSource): + return ( + platform_entities.ChatType.GROUP, + source.group_id, + platform_entities.UserGroup(id=source.group_id, name=source.group_id), + source.user_id or source.group_id, + ) + if isinstance(source, RoomSource): + # LINE rooms are multi-user chats; treat them as group chats keyed by room id. + return ( + platform_entities.ChatType.GROUP, + source.room_id, + platform_entities.UserGroup(id=source.room_id, name=source.room_id), + source.user_id or source.room_id, + ) + user_id = source.user_id if isinstance(source, UserSource) else '' + return platform_entities.ChatType.PRIVATE, user_id, None, user_id + + +class LineEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Convert LINE webhook events into unified EBA events and legacy message events.""" + + def __init__(self, bot_account_id: str = '', blob_api: typing.Any = None): + self.bot_account_id = bot_account_id + self.blob_api = blob_api + + @staticmethod + async def yiri2target(event: platform_events.Event) -> typing.Any: + return getattr(event, 'source_platform_object', None) + + async def target2yiri(self, event: Event) -> typing.Optional[platform_events.EBAEvent]: + """Convert one LINE webhook event into a unified event; None when unsupported.""" + common = dict( + adapter_name=ADAPTER_NAME, + timestamp=_timestamp(event), + source_platform_object=event, + ) + chat_type, chat_id, group, sender_id = _chat_identity(event) + chat = dict( + chat_type=chat_type, + chat_id=chat_id, + group=group, + ) + if isinstance(event, MessageEvent): + return platform_events.MessageReceivedEvent( + **common, + **chat, + message_id=str(event.message.id), + message_chain=await LineMessageConverter.target2yiri( + event, blob_api=self.blob_api, bot_account_id=self.bot_account_id + ), + sender=platform_entities.User(id=sender_id, nickname=sender_id), + ) + if isinstance(event, UnsendEvent): + return platform_events.MessageDeletedEvent( + **common, + **chat, + message_id=str(event.unsend.message_id), + operator=platform_entities.User(id=sender_id) if sender_id else None, + ) + if isinstance(event, FollowEvent): + return platform_events.PlatformSpecificEvent( + **common, action='line.follow', data={**_event_payload(event), 'chat_id': chat_id} + ) + if isinstance(event, UnfollowEvent): + return platform_events.PlatformSpecificEvent( + **common, action='line.unfollow', data={**_event_payload(event), 'chat_id': chat_id} + ) + if isinstance(event, JoinEvent): + return platform_events.BotInvitedToGroupEvent( + **common, + group=platform_entities.UserGroup(id=chat_id, name=chat_id), + ) + if isinstance(event, LeaveEvent): + return platform_events.BotRemovedFromGroupEvent( + **common, + group=platform_entities.UserGroup(id=chat_id, name=chat_id), + ) + if isinstance(event, MemberJoinedEvent): + member = _first_member(event.joined.members, chat_id) + return platform_events.MemberJoinedEvent(**common, group=group, member=member) + if isinstance(event, MemberLeftEvent): + member = _first_member(event.left.members, chat_id) + return platform_events.MemberLeftEvent(**common, group=group, member=member) + if isinstance(event, PostbackEvent): + return platform_events.PlatformSpecificEvent( + **common, + action='line.postback', + data={ + 'data': event.postback.data, + 'chat_type': chat_type.value, + 'chat_id': chat_id, + 'sender_id': sender_id, + **({'params': event.postback.params} if event.postback.params else {}), + }, + ) + if isinstance(event, BeaconEvent): + return platform_events.PlatformSpecificEvent( + **common, + action='line.beacon', + data={'hwid': event.beacon.hwid, 'type': event.beacon.type, 'chat_id': chat_id}, + ) + # Remaining LINE event types stay visible to event processors without a lossy mapping. + action = getattr(event, 'type', '') or type(event).__name__ + return platform_events.PlatformSpecificEvent( + **common, action=f'line.{action}', data={**_event_payload(event), 'chat_id': chat_id} + ) + + async def target2legacy(self, event: Event) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + """Build the legacy message event used by pipeline-based bindings.""" + eba_event = await self.target2yiri(event) + if not isinstance(eba_event, platform_events.MessageReceivedEvent): + return None + return eba_event.to_legacy_event() + + +def _event_payload(event: Event) -> dict: + to_dict = getattr(event, 'to_dict', None) + if callable(to_dict): + return dict(to_dict()) + return {} + + +def _first_member(members: typing.Any, fallback_id: str) -> platform_entities.User: + """Member arrays carry only user ids; the first entry is the subject of the event.""" + for member in members or []: + user_id = getattr(member, 'user_id', '') or '' + if user_id: + return platform_entities.User(id=user_id, nickname=user_id) + return platform_entities.User(id=fallback_id, nickname=fallback_id) diff --git a/src/langbot/pkg/platform/adapters/line/line.png b/src/langbot/pkg/platform/adapters/line/line.png new file mode 100644 index 000000000..8c925c6c0 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/line/line.png differ diff --git a/src/langbot/pkg/platform/adapters/line/manifest.yaml b/src/langbot/pkg/platform/adapters/line/manifest.yaml new file mode 100644 index 000000000..79c47b42d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/manifest.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: line-omni + label: + en_US: LINE + zh_Hans: LINE + zh_Hant: LINE + description: + en_US: LINE adapter with event-driven orchestration, receiving messages through the unified webhook. + zh_Hans: LINE 适配器,支持事件驱动编排,通过统一 Webhook 接收消息。 + zh_Hant: LINE 適配器,支援事件驅動編排,透過統一 Webhook 接收訊息。 + icon: line.png + +spec: + categories: + - popular + - global + help_links: + zh: https://langbot.app/docs/zh/usage/platforms/line + en: https://langbot.app/docs/en/usage/platforms/line + ja: https://langbot.app/docs/ja/usage/platforms/line + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL into the LINE Developers console webhook settings and enable "Use webhook". + zh_Hans: 将此地址填入 LINE Developers 控制台的 Webhook 设置,并启用「Use webhook」。 + zh_Hant: 將此位址填入 LINE Developers 主控台的 Webhook 設定,並啟用「Use webhook」。 + type: webhook-url + required: false + default: "" + - name: channel_access_token + label: + en_US: Channel Access Token + zh_Hans: 频道访问令牌 + zh_Hant: 頻道存取權杖 + description: + en_US: Long-lived channel access token used to send messages. + zh_Hans: 用于发送消息的长期频道访问令牌。 + zh_Hant: 用於傳送訊息的長期頻道存取權杖。 + type: string + required: true + default: "" + - name: channel_secret + label: + en_US: Channel Secret + zh_Hans: 频道密钥 + zh_Hant: 頻道密鑰 + description: + en_US: Channel secret used to verify the X-Line-Signature of incoming webhooks. + zh_Hans: 用于校验入站 Webhook 的 X-Line-Signature 的频道密钥。 + zh_Hant: 用於校驗入站 Webhook 的 X-Line-Signature 的頻道密鑰。 + type: string + required: true + default: "" + + supported_events: + - message.received + - message.deleted + - group.member_joined + - group.member_left + - bot.invited_to_group + - bot.removed_from_group + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_user_info + - get_group_info + - leave_group + - call_platform_api + + platform_specific_apis: + - action: get_bot_info + description: { en_US: "Return the LINE Official Account profile", zh_Hans: "返回 LINE 官方账号资料" } + - action: get_profile + description: { en_US: "Call the LINE get profile API for a user", zh_Hans: "调用 LINE 用户资料接口" } + - action: get_group_summary + description: { en_US: "Call the LINE group summary API", zh_Hans: "调用 LINE 群组摘要接口" } + +execution: + python: + path: ./adapter.py + attr: LineAdapter diff --git a/src/langbot/pkg/platform/adapters/line/message_converter.py b/src/langbot/pkg/platform/adapters/line/message_converter.py new file mode 100644 index 000000000..aaeecc748 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/message_converter.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import base64 +import datetime +import typing + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from linebot.v3.messaging import ImageMessage, TextMessage +from linebot.v3.webhooks import ( + AudioMessageContent, + FileMessageContent, + ImageMessageContent, + LocationMessageContent, + MessageEvent, + StickerMessageContent, + TextMessageContent, + UserMentionee, + VideoMessageContent, +) + +from langbot.pkg.platform.adapters.line.errors import NotSupportedError +from langbot.pkg.platform.adapters.line.types import MAX_LINE_MEDIA_BYTES, MAX_TEXT_LENGTH + +# LINE stores inbound images as JPEG and the Blob API does not return the media type. +IMAGE_MIME = 'image/jpeg' + + +def _validate_media(content: bytes) -> bytes: + """Reject inbound media above the LINE download cap.""" + if len(content) > MAX_LINE_MEDIA_BYTES: + raise ValueError(f'LINE media exceeds the {MAX_LINE_MEDIA_BYTES}-byte limit') + return content + + +def _split_text(text: str) -> list[str]: + """Split a text body into LINE-sized chunks so long answers still send.""" + return [text[index : index + MAX_TEXT_LENGTH] for index in range(0, len(text), MAX_TEXT_LENGTH)] + + +def _message_datetime(event: MessageEvent) -> datetime.datetime: + try: + return datetime.datetime.fromtimestamp(float(event.timestamp) / 1000.0) + except (AttributeError, TypeError, ValueError): + return datetime.datetime.now() + + +def _text_components(text: str, mention: typing.Any, bot_account_id: str) -> list[platform_message.MessageComponent]: + """Map LINE mention positions onto At components, as the legacy adapter does.""" + if not mention or not mention.mentionees: + return [platform_message.Plain(text=text)] if text else [] + components: list[platform_message.MessageComponent] = [] + segments = sorted( + (mentionee.index, mentionee.index + mentionee.length, mentionee) for mentionee in mention.mentionees + ) + cursor = 0 + for start, end, mentionee in segments: + if start < cursor: + start, end = cursor, min(end, len(text)) + if start < cursor or end <= start or end > len(text): + continue + if start > cursor: + components.append(platform_message.Plain(text=text[cursor:start])) + token = text[start:end] + if isinstance(mentionee, UserMentionee): + target = bot_account_id if mentionee.is_self else (mentionee.user_id or token) + else: + target = token + components.append(platform_message.At(target=str(target), display=token.lstrip('@'))) + cursor = end + if cursor < len(text): + components.append(platform_message.Plain(text=text[cursor:])) + return components + + +async def _download_content(blob_api: typing.Any, message_id: str) -> bytes: + """Fetch inbound media through the LINE Blob API without blocking the event loop.""" + import asyncio + + if blob_api is None: + raise NotSupportedError('line:media_download') + return await asyncio.to_thread(blob_api.get_message_content, message_id) + + +class LineMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Convert between LangBot message chains and LINE Messaging API messages.""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> list[typing.Any]: + """Build the LINE message array for one push/reply request.""" + messages: list[typing.Any] = [] + for component in message_chain: + if isinstance(component, platform_message.Source): + continue + if isinstance(component, platform_message.Plain): + messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text or '')) + elif isinstance(component, platform_message.At): + messages.append(TextMessage(text=f'@{component.display or component.target}')) + elif isinstance(component, platform_message.AtAll): + messages.append(TextMessage(text='@All')) + elif isinstance(component, platform_message.Image): + url = component.url or '' + if not url: + # LINE fetches outgoing images from a public URL; inline base64 is not supported. + raise NotSupportedError('send_message:line_image_requires_public_url') + messages.append(ImageMessage(original_content_url=url, preview_image_url=url)) + elif isinstance(component, platform_message.Quote): + if component.origin: + messages.extend(await LineMessageConverter.yiri2target(component.origin)) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + messages.extend(await LineMessageConverter.yiri2target(node.message_chain)) + elif isinstance(component, platform_message.Unknown): + if component.text: + messages.extend(TextMessage(text=chunk) for chunk in _split_text(component.text)) + else: + raise NotSupportedError(f'send_message:line_unsupported_component:{type(component).__name__}') + return messages + + @staticmethod + async def target2yiri( + event: MessageEvent, + blob_api: typing.Any = None, + bot_account_id: str = '', + ) -> platform_message.MessageChain: + """Build a LangBot message chain from a LINE message event.""" + message = event.message + components: list[platform_message.MessageComponent] = [ + platform_message.Source( + id=str(getattr(message, 'id', '') or event.webhook_event_id), time=_message_datetime(event) + ) + ] + if isinstance(message, TextMessageContent): + components.extend(_text_components(message.text, getattr(message, 'mention', None), bot_account_id)) + elif isinstance(message, ImageMessageContent): + encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii') + components.append(platform_message.Image(base64=f'data:{IMAGE_MIME};base64,{encoded}')) + elif isinstance(message, AudioMessageContent): + encoded = base64.b64encode(_validate_media(await _download_content(blob_api, message.id))).decode('ascii') + components.append(platform_message.Voice(base64=encoded, length=int(getattr(message, 'duration', 0) or 0))) + elif isinstance(message, VideoMessageContent): + components.append(platform_message.Unknown(text='[video]')) + elif isinstance(message, StickerMessageContent): + components.append(platform_message.Unknown(text='[sticker]')) + elif isinstance(message, FileMessageContent): + components.append(platform_message.Unknown(text=f'[file] {getattr(message, "file_name", "")}'.strip())) + elif isinstance(message, LocationMessageContent): + components.append( + platform_message.Unknown( + text=f'[location] {getattr(message, "title", "")} {getattr(message, "address", "")}'.strip() + ) + ) + else: + components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]')) + if len(components) == 1: + components.append(platform_message.Unknown(text=f'[unsupported line message: {type(message).__name__}]')) + return platform_message.MessageChain(components) diff --git a/src/langbot/pkg/platform/adapters/line/platform_api.py b/src/langbot/pkg/platform/adapters/line/platform_api.py new file mode 100644 index 000000000..a331b5758 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/platform_api.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import typing + + +async def get_bot_info(adapter, params: dict) -> dict: + import asyncio + + info = await asyncio.to_thread(adapter.bot.get_bot_info) + return { + 'user_id': info.user_id, + 'basic_id': info.basic_id, + 'display_name': info.display_name, + 'chat_mode': info.chat_mode, + } + + +async def get_profile(adapter, params: dict) -> dict: + user_id = str(params.get('user_id', '')) + if not user_id: + raise ValueError('get_profile requires user_id') + import asyncio + + profile = await asyncio.to_thread(adapter.bot.get_profile, user_id) + return { + 'user_id': profile.user_id, + 'display_name': profile.display_name, + 'picture_url': profile.picture_url or '', + 'status_message': profile.status_message or '', + } + + +async def get_group_summary(adapter, params: dict) -> dict: + group_id = str(params.get('group_id', '')) + if not group_id: + raise ValueError('get_group_summary requires group_id') + import asyncio + + summary = await asyncio.to_thread(adapter.bot.get_group_summary, group_id) + return { + 'group_id': summary.group_id, + 'group_name': summary.group_name, + 'picture_url': summary.picture_url or '', + } + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'get_bot_info': get_bot_info, + 'get_profile': get_profile, + 'get_group_summary': get_group_summary, +} diff --git a/src/langbot/pkg/platform/adapters/line/types.py b/src/langbot/pkg/platform/adapters/line/types.py new file mode 100644 index 000000000..7a62600b5 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/line/types.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +ADAPTER_NAME = 'line-omni' + +# LINE Messaging API limits (push/reply share the same message array budget). +MAX_MESSAGES_PER_REQUEST = 5 + +# A single text message body is limited to 5000 characters. +MAX_TEXT_LENGTH = 5000 + +# LINE serves inbound media through the Blob API; keep the same cap as the legacy adapter. +MAX_LINE_MEDIA_BYTES = 10 * 1024 * 1024 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index c8bb662cd..16eb776c4 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.secret_managers.main import get_secret, get_secret_bool from .. import errors, reasoning, requester +from . import minimax from ....utils import httpclient, image import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -213,6 +214,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 'bailian-chat-completions': 'qwen', 'doubao-chat-completions': 'doubao', 'mimo-chat-completions': 'mimo', + 'minimax-chat-completions': 'minimax', } default_config: dict[str, typing.Any] = { @@ -569,6 +571,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None: normalized_name = model_name.lower().rsplit('/', 1)[-1] + if family == 'minimax': + return minimax.reasoning_levels(normalized_name) + if family == 'deepseek' and normalized_name.startswith('deepseek-'): if normalized_name.startswith('deepseek-v4-'): return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max'] @@ -728,6 +733,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester): raise errors.RequesterError(str(exc), code='invalid_policy') from exc family = self._reasoning_family(model.model_entity.name, model) + if family == 'minimax': + return minimax.reasoning_parameters(level) budget = config.get('budget_tokens') if budget is not None: if self._uses_openrouter_protocol(): @@ -837,6 +844,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): for m in messages: msg_dict = m.dict(exclude_none=True) provider_fields = msg_dict.get('provider_specific_fields') + replay_fields = minimax.take_replay_fields(provider_fields) if isinstance(provider_fields, dict) else {} if isinstance(provider_fields, dict): # Stream completion metadata belongs to the response, never to # replayed chat messages. Keep provider signatures for LiteLLM. @@ -878,6 +886,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): else: msg_dict.pop('provider_specific_fields', None) + if msg_dict.get('role') == 'assistant' and reasoning_family == 'minimax': + msg_dict.update(replay_fields) + if isinstance(content, list): converted_parts = [] for part in content: @@ -1476,6 +1487,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester): content = message_data.get('content', '') reasoning_content = message_data.get('reasoning_content', None) thinking_blocks = message_data.get('thinking_blocks') + if self._reasoning_family(model.model_entity.name, model) == 'minimax': + message_data['provider_specific_fields'] = minimax.capture_content(message_data) if reasoning_content or thinking_blocks: provider_fields = dict(message_data.get('provider_specific_fields') or {}) if reasoning_content: @@ -1520,6 +1533,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester): reasoning_started = False reasoning_closed = False thinking_blocks_state: list[dict[str, typing.Any]] = [] + keep_minimax_content = self._reasoning_family(model.model_entity.name, model) == 'minimax' + raw_content_parts: list[str] = [] response = None async with self._client_args(args) as args: @@ -1562,6 +1577,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester): delta_content = delta.get('content') or '' reasoning_content = delta.get('reasoning_content') or '' provider_fields = dict(delta.get('provider_specific_fields') or {}) + if keep_minimax_content: + if isinstance(delta.get('content'), str): + raw_content_parts.append(delta['content']) + if finish_reason and raw_content_parts: + provider_fields[minimax.RAW_CONTENT_FIELD] = ''.join(raw_content_parts) + raw_content_parts.clear() if finish_reason: # Preserve an explicit provider stop even when the final delta is empty. provider_fields['finish_reason'] = finish_reason @@ -1637,6 +1658,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): yield provider_message.MessageChunk(**chunk_data) chunk_idx += 1 + if raw_content_parts: + yield provider_message.MessageChunk( + role=role, + is_final=True, + provider_specific_fields={minimax.RAW_CONTENT_FIELD: ''.join(raw_content_parts)}, + ) + if reasoning_started and not reasoning_closed: yield provider_message.MessageChunk( role=role, @@ -1882,8 +1910,11 @@ class LiteLLMRequester(requester.ProviderAPIRequester): # rather than /models. Providers whose configured base_url # already ends in /v1 keep their original (working) URL. response = await client.get(f'{base_url}/v1/models', headers=headers) - response.raise_for_status() - payload = await httpclient.parse_json_response(response) + if self._requester_name() == 'minimax-chat-completions' and response.status_code == 404: + payload = {} + else: + response.raise_for_status() + payload = await httpclient.parse_json_response(response) models = [] for item in payload.get('data', []): @@ -1895,6 +1926,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester): models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower())) + direct_minimax = self._requester_name() == 'minimax-chat-completions' + if direct_minimax or self._uses_openrouter_protocol(): + models = minimax.arrange_catalog(models, direct=direct_minimax) + return {'models': models} except httpx.HTTPStatusError as e: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimax.py b/src/langbot/pkg/provider/modelmgr/requesters/minimax.py new file mode 100644 index 000000000..9d056806e --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimax.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import re +from typing import Any + + +RECOMMENDED_MODELS = ('MiniMax-M3.1-Flash', 'MiniMax-M3') +RAW_CONTENT_FIELD = 'minimax_raw_content' +_LEGACY_TEXT_MODEL = re.compile(r'^(minimax-(m[12](?:[.\-]|$)|01(?:[.\-]|$))|abab)') + + +def reasoning_levels(model_name: str) -> list[str] | None: + name = model_name.lower() + if name == RECOMMENDED_MODELS[0].lower(): + return ['provider_default', 'enabled', 'low', 'medium', 'high', 'xhigh', 'max'] + if name == RECOMMENDED_MODELS[1].lower(): + return ['provider_default', 'enabled', 'disabled'] + return None + + +def reasoning_parameters(level: str) -> dict[str, Any]: + if level == 'provider_default': + return {} + thinking_type = {'enabled': 'adaptive', 'disabled': 'disabled'}.get(level) + if thinking_type is not None: + return {'extra_body': {'thinking': {'type': thinking_type}}} + return {'reasoning_effort': level} + + +def capture_content(payload: dict[str, Any]) -> dict[str, Any]: + fields = dict(payload.get('provider_specific_fields') or {}) + if isinstance(payload.get('content'), str): + fields[RAW_CONTENT_FIELD] = payload['content'] + return fields + + +def take_replay_fields(fields: dict[str, Any]) -> dict[str, Any]: + replay = {} + raw_content = fields.pop(RAW_CONTENT_FIELD, None) + if isinstance(raw_content, str): + replay['content'] = raw_content + if isinstance(fields.get('reasoning_content'), str): + replay['reasoning_content'] = fields['reasoning_content'] + return replay + + +def arrange_catalog(models: list[dict[str, Any]], *, direct: bool) -> list[dict[str, Any]]: + priorities = {name.lower(): position for position, name in enumerate(RECOMMENDED_MODELS)} + + def model_name(model: dict[str, Any]) -> str: + name = model['id'].lower() + if not direct: + if not name.startswith('minimax/'): + return '' + name = name.removeprefix('minimax/') + return name.partition(':')[0] + + catalog = [ + dict(model) for model in models if model['type'] != 'llm' or not _LEGACY_TEXT_MODEL.match(model_name(model)) + ] + if direct: + present = {model['id'].lower() for model in catalog} + catalog.extend( + {'id': name, 'name': name, 'type': 'llm'} for name in RECOMMENDED_MODELS if name.lower() not in present + ) + for model in catalog: + if model_name(model) in priorities: + model.setdefault('context_length', 1_000_000) + model['abilities'] = list( + dict.fromkeys([*model.get('abilities', []), 'func_call', 'vision', 'reasoning']) + ) + return sorted(catalog, key=lambda model: priorities.get(model_name(model), len(priorities))) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml index b0c246c98..0447fb6d3 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml +++ b/src/langbot/pkg/provider/modelmgr/requesters/minimaxchatcmpl.yaml @@ -15,7 +15,7 @@ spec: zh_Hans: 基础 URL type: string required: true - default: https://api.minimax.chat/v1 + default: https://api.minimax.io/v1 - name: timeout label: en_US: Timeout diff --git a/tests/unit_tests/platform/test_line_eba_adapter.py b/tests/unit_tests/platform/test_line_eba_adapter.py new file mode 100644 index 000000000..306a09176 --- /dev/null +++ b/tests/unit_tests/platform/test_line_eba_adapter.py @@ -0,0 +1,464 @@ +"""Unit coverage for the LINE Omni adapter (unified webhook mode).""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import pathlib + +import pytest +import yaml +from linebot.v3.messaging import PushMessageRequest, ReplyMessageRequest +from linebot.v3.webhook import WebhookParser + +from langbot.pkg.platform.adapters.line.adapter import LineAdapter +from langbot.pkg.platform.adapters.line.errors import NotSupportedError +from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter +from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + +SECRET = 'test-channel-secret' +TOKEN = 'test-channel-access-token' +PLATFORM_ROOT = pathlib.Path(__file__).parents[3] / 'src/langbot/pkg/platform' + + +class DummyLogger(AbstractEventLogger): + def __init__(self): + self.lines: list[tuple[str, str]] = [] + + async def info(self, text, images=None, message_session_id=None, no_throw=True): + self.lines.append(('info', str(text))) + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + self.lines.append(('warning', str(text))) + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + self.lines.append(('error', str(text))) + + +class DummyMessagingApi: + def __init__(self, fail_reply: bool = False): + self.push_requests: list[PushMessageRequest] = [] + self.reply_requests: list[ReplyMessageRequest] = [] + self.fail_reply = fail_reply + self.leaved_groups: list[str] = [] + + def push_message(self, request): + self.push_requests.append(request) + return {'sent': len(request.messages)} + + def reply_message(self, request): + if self.fail_reply: + raise RuntimeError('reply token expired') + self.reply_requests.append(request) + return {'sent': len(request.messages)} + + def get_bot_info(self): + return type( + 'BotInfo', (), {'user_id': 'Ubot', 'basic_id': '@bot', 'display_name': 'Bot', 'chat_mode': 'chat'} + )() + + def get_profile(self, user_id): + return type( + 'Profile', + (), + {'user_id': user_id, 'display_name': 'Alice', 'picture_url': 'https://img/1.png', 'status_message': None}, + )() + + def get_group_summary(self, group_id): + return type('Summary', (), {'group_id': group_id, 'group_name': 'Team', 'picture_url': None})() + + def leave_group(self, group_id): + self.leaved_groups.append(group_id) + + +class DummyBlobApi: + def __init__(self, content: bytes = b'image-bytes'): + self.content = content + self.requested: list[str] = [] + + def get_message_content(self, message_id): + self.requested.append(message_id) + return self.content + + +class DummyApiClient: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +class DummyHeaders(dict): + def get(self, key, default=None): + return dict.get(self, key, default) + + +class DummyRequest: + def __init__(self, body: str, signature: str | None): + self.headers = DummyHeaders({} if signature is None else {'X-Line-Signature': signature}) + self._body = body + + async def get_data(self, as_text: bool = False): + return self._body if as_text else self._body.encode() + + +def sign(body: str, secret: str = SECRET) -> str: + return base64.b64encode(hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()).decode() + + +def message_event( + *, + text: str = 'hello', + source: dict | None = None, + reply_token: str = 'reply-token-1', + message: dict | None = None, +) -> dict: + return { + 'type': 'message', + 'mode': 'active', + 'timestamp': 1730000000000, + 'webhookEventId': '01HWEBHOOKID', + 'deliveryContext': {'isRedelivery': False}, + 'source': source or {'type': 'user', 'userId': 'U-user-1'}, + 'replyToken': reply_token, + 'message': message or {'id': 'msg-1', 'type': 'text', 'text': text, 'quoteToken': 'quote-1'}, + } + + +def webhook_body(*events: dict) -> str: + return json.dumps({'destination': 'Ubot', 'events': list(events)}) + + +def build_adapter(logger: DummyLogger | None = None) -> LineAdapter: + return LineAdapter( + config={'channel_access_token': TOKEN, 'channel_secret': SECRET}, + logger=logger or DummyLogger(), + ) + + +class EventSink: + def __init__(self): + self.events: list[platform_events.Event] = [] + + async def __call__(self, event, adapter): + self.events.append(event) + + +async def dispatch(adapter: LineAdapter, body: str, signature: str | None = None): + response = await adapter.handle_unified_webhook( + bot_uuid='bot-uuid', path='', request=DummyRequest(body, sign(body) if signature is None else signature) + ) + pending = list(adapter.background_tasks) + if pending: + await asyncio_gather(pending) + return response + + +async def asyncio_gather(tasks): + import asyncio + + await asyncio.gather(*tasks, return_exceptions=True) + + +def parse_events(body: str) -> list: + return WebhookParser(SECRET).parse(body, sign(body)) + + +def test_manifest_is_discoverable_and_keeps_legacy_config(): + manifest = yaml.safe_load((PLATFORM_ROOT / 'adapters/line/manifest.yaml').read_text(encoding='utf-8')) + legacy = yaml.safe_load((PLATFORM_ROOT / 'sources/line.yaml').read_text(encoding='utf-8')) + assert manifest['metadata']['name'] == 'line-omni' + assert manifest['execution']['python'] == {'path': './adapter.py', 'attr': 'LineAdapter'} + assert {field['name'] for field in legacy['spec']['config']} <= { + field['name'] for field in manifest['spec']['config'] + } + assert manifest['spec']['help_links'] == legacy['spec']['help_links'] + assert 'message.received' in manifest['spec']['supported_events'] + assert (PLATFORM_ROOT / 'adapters/line' / manifest['metadata']['icon']).is_file() + + +def test_adapter_requires_credentials(): + with pytest.raises(ValueError, match='channel_access_token'): + LineAdapter(config={'channel_secret': SECRET}, logger=DummyLogger()) + + +@pytest.mark.asyncio +async def test_webhook_rejects_missing_and_invalid_signature(): + adapter = build_adapter() + body = webhook_body(message_event()) + assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, None))).status_code == 400 + assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, 'bogus'))).status_code == 400 + + +@pytest.mark.asyncio +async def test_webhook_dispatches_message_received_event(): + adapter = build_adapter() + sink = EventSink() + adapter.register_listener(platform_events.MessageReceivedEvent, sink) + + response = await dispatch(adapter, webhook_body(message_event(text='hi there'))) + + assert response.status_code == 200 + assert len(sink.events) == 1 + event = sink.events[0] + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.adapter_name == 'line-omni' + assert event.chat_type == platform_entities.ChatType.PRIVATE + assert event.chat_id == 'U-user-1' + assert event.message_id == 'msg-1' + assert [type(component) for component in event.message_chain] == [platform_message.Source, platform_message.Plain] + assert event.message_chain[1].text == 'hi there' + + +@pytest.mark.asyncio +async def test_webhook_serves_legacy_listeners_and_ignores_self_messages(): + adapter = build_adapter() + adapter.bot_account_id = 'Ubot' + legacy_sink = EventSink() + adapter.register_listener(platform_events.FriendMessage, legacy_sink) + + await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'Ubot'}))) + assert legacy_sink.events == [] + + await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'U-user-9'}))) + assert len(legacy_sink.events) == 1 + assert isinstance(legacy_sink.events[0], platform_events.FriendMessage) + assert legacy_sink.events[0].sender.id == 'U-user-9' + + +@pytest.mark.asyncio +async def test_group_message_uses_group_chat_identity(): + events = parse_events( + webhook_body(message_event(source={'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-2'})) + ) + event = await LineEventConverter().target2yiri(events[0]) + + assert event.chat_type == platform_entities.ChatType.GROUP + assert event.chat_id == 'C-group' + assert event.group.id == 'C-group' + assert event.sender.id == 'U-user-2' + + +@pytest.mark.asyncio +async def test_unsend_becomes_message_deleted(): + body = webhook_body( + { + 'type': 'unsend', + 'mode': 'active', + 'timestamp': 1730000000000, + 'webhookEventId': '01HUNSEND', + 'deliveryContext': {'isRedelivery': False}, + 'source': {'type': 'user', 'userId': 'U-user-1'}, + 'unsend': {'messageId': 'msg-9'}, + } + ) + event = await LineEventConverter().target2yiri(parse_events(body)[0]) + + assert isinstance(event, platform_events.MessageDeletedEvent) + assert event.message_id == 'msg-9' + assert event.chat_id == 'U-user-1' + + +@pytest.mark.asyncio +async def test_follow_and_postback_stay_platform_specific(): + follow = webhook_body( + { + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1730000000000, + 'webhookEventId': '01HFOLLOW', + 'deliveryContext': {'isRedelivery': False}, + 'source': {'type': 'user', 'userId': 'U-user-3'}, + 'replyToken': 'token', + 'follow': {'isUnblocked': True}, + } + ) + follow_event = await LineEventConverter().target2yiri(parse_events(follow)[0]) + assert follow_event.type == 'platform.specific' + assert follow_event.action == 'line.follow' + + postback = webhook_body( + { + 'type': 'postback', + 'mode': 'active', + 'timestamp': 1730000000000, + 'webhookEventId': '01HPOSTBACK', + 'deliveryContext': {'isRedelivery': False}, + 'source': {'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-4'}, + 'replyToken': 'token', + 'postback': {'data': 'action=buy'}, + } + ) + postback_event = await LineEventConverter().target2yiri(parse_events(postback)[0]) + assert postback_event.action == 'line.postback' + assert postback_event.data['data'] == 'action=buy' + assert postback_event.data['chat_id'] == 'C-group' + + +@pytest.mark.asyncio +async def test_member_joined_maps_group_and_member(): + body = webhook_body( + { + 'type': 'memberJoined', + 'mode': 'active', + 'timestamp': 1730000000000, + 'webhookEventId': '01HJOINED', + 'deliveryContext': {'isRedelivery': False}, + 'source': {'type': 'group', 'groupId': 'C-group'}, + 'replyToken': 'token', + 'joined': {'members': [{'type': 'user', 'userId': 'U-user-5'}]}, + } + ) + event = await LineEventConverter().target2yiri(parse_events(body)[0]) + + assert isinstance(event, platform_events.MemberJoinedEvent) + assert event.group.id == 'C-group' + assert event.member.id == 'U-user-5' + + +@pytest.mark.asyncio +async def test_text_mention_becomes_at_component_for_the_bot(): + body = webhook_body( + message_event( + text='@bot hello', + message={ + 'id': 'msg-2', + 'type': 'text', + 'quoteToken': 'quote-2', + 'text': '@bot hello', + 'mention': { + 'mentionees': [ + {'index': 0, 'length': 4, 'userId': 'Ubot', 'isSelf': True, 'type': 'user'}, + ] + }, + }, + ) + ) + event = await LineEventConverter(bot_account_id='Ubot').target2yiri(parse_events(body)[0]) + + at_components = [component for component in event.message_chain if isinstance(component, platform_message.At)] + assert len(at_components) == 1 + assert at_components[0].target == 'Ubot' + assert at_components[0].display == 'bot' + + +@pytest.mark.asyncio +async def test_image_message_is_downloaded_through_the_blob_api(): + blob = DummyBlobApi(b'jpeg-bytes') + body = webhook_body( + message_event( + message={ + 'id': 'msg-3', + 'type': 'image', + 'quoteToken': 'quote-3', + 'contentProvider': {'type': 'line'}, + } + ) + ) + event = await LineEventConverter(blob_api=blob).target2yiri(parse_events(body)[0]) + + image = event.message_chain[1] + assert isinstance(image, platform_message.Image) + assert image.base64 == f'data:image/jpeg;base64,{base64.b64encode(b"jpeg-bytes").decode()}' + assert blob.requested == ['msg-3'] + + +@pytest.mark.asyncio +async def test_outgoing_chain_builds_line_messages(): + chain = platform_message.MessageChain( + [ + platform_message.Source(id='msg-1', time=__import__('datetime').datetime.now()), + platform_message.Plain(text='hello'), + platform_message.At(target='U-user-1', display='Alice'), + ] + ) + messages = await LineMessageConverter.yiri2target(chain) + + assert [message.type for message in messages] == ['text', 'text'] + assert messages[0].text == 'hello' + assert messages[1].text == '@Alice' + + +@pytest.mark.asyncio +async def test_outgoing_image_requires_a_public_url(): + chain = platform_message.MessageChain([platform_message.Image(base64='ZGly')]) + with pytest.raises(NotSupportedError): + await LineMessageConverter.yiri2target(chain) + + +@pytest.mark.asyncio +async def test_send_message_batches_five_messages_per_request(): + adapter = build_adapter() + adapter.bot = DummyMessagingApi() + chain = platform_message.MessageChain([platform_message.Plain(text='x'), platform_message.Plain(text='y')] * 3) + + result = await adapter.send_message('person', 'U-user-1', chain) + + assert [len(request.messages) for request in adapter.bot.push_requests] == [5, 1] + assert all(request.to == 'U-user-1' for request in adapter.bot.push_requests) + assert result.raw == {'sent': 6, 'target_id': 'U-user-1'} + + +@pytest.mark.asyncio +async def test_reply_uses_reply_token_then_falls_back_to_push(): + adapter = build_adapter() + event = await LineEventConverter().target2yiri(parse_events(webhook_body(message_event()))[0]) + chain = platform_message.MessageChain([platform_message.Plain(text='pong')]) + + adapter.bot = DummyMessagingApi() + await adapter.reply_message(event, chain) + assert adapter.bot.reply_requests[0].reply_token == 'reply-token-1' + assert adapter.bot.push_requests == [] + + adapter.bot = DummyMessagingApi(fail_reply=True) + await adapter.reply_message(event, chain) + assert adapter.bot.push_requests[0].to == 'U-user-1' + + +@pytest.mark.asyncio +async def test_profile_and_group_apis_use_the_messaging_api(): + adapter = build_adapter() + adapter.bot = DummyMessagingApi() + + user = await adapter.get_user_info('U-user-1') + group = await adapter.get_group_info('C-group') + await adapter.leave_group('C-group') + + assert user.id == 'U-user-1' + assert user.nickname == 'Alice' + assert group.name == 'Team' + assert adapter.bot.leaved_groups == ['C-group'] + with pytest.raises(NotSupportedError): + await adapter.get_group_member_list('C-group') + + +@pytest.mark.asyncio +async def test_call_platform_api_dispatches_and_rejects_unknown_actions(): + adapter = build_adapter() + adapter.bot = DummyMessagingApi() + + info = await adapter.call_platform_api('get_bot_info', {}) + assert info['user_id'] == 'Ubot' + with pytest.raises(NotSupportedError): + await adapter.call_platform_api('not_a_line_action', {}) + + +@pytest.mark.asyncio +async def test_kill_closes_the_api_client(): + adapter = build_adapter() + api_client = DummyApiClient() + adapter.api_client = api_client + + assert await adapter.kill() is True + assert api_client.closed is True + assert adapter.background_tasks == set() diff --git a/tests/unit_tests/provider/test_minimax_protocol.py b/tests/unit_tests/provider/test_minimax_protocol.py new file mode 100644 index 000000000..89b21781d --- /dev/null +++ b/tests/unit_tests/provider/test_minimax_protocol.py @@ -0,0 +1,273 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +import yaml +from langbot_plugin.api.entities.builtin.provider.message import Message + +from langbot.pkg.provider.modelmgr import errors +from langbot.pkg.provider.modelmgr.requesters import litellmchat, minimax + + +@pytest.fixture +def provider(): + manifest = yaml.safe_load(Path(litellmchat.__file__).with_name('minimaxchatcmpl.yaml').read_text()) + settings = {option['name']: option['default'] for option in manifest['spec']['config']} + settings.update(custom_llm_provider='openai', requester_name=manifest['metadata']['name']) + tool = {'type': 'function', 'function': {'name': 'lookup', 'parameters': {'type': 'object'}}} + app = SimpleNamespace(tool_mgr=SimpleNamespace(generate_tools_for_openai=AsyncMock(return_value=[tool]))) + return litellmchat.LiteLLMRequester(app, settings) + + +def runtime(provider, name='MiniMax-M3.1-Flash', level='provider_default'): + return SimpleNamespace( + provider=SimpleNamespace( + provider_entity=SimpleNamespace(requester=provider.requester_cfg['requester_name']), + token_mgr=SimpleNamespace(get_token=lambda: 'synthetic-minimax-test'), + ), + model_entity=SimpleNamespace( + name=name, abilities=['reasoning'], reasoning_config={'level': level}, extra_args={} + ), + ) + + +def completion(message=None): + return { + 'id': 'protocol-test', + 'created': 1, + 'model': 'MiniMax-M3.1-Flash', + 'object': 'chat.completion', + 'choices': [ + {'index': 0, 'message': message or {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'} + ], + } + + +@pytest.fixture +def wire(monkeypatch): + captured = [] + queued = [] + + async def send(client, request, **kwargs): + captured.append((request, json.loads(request.content) if request.content else None)) + response = queued.pop(0) if queued else httpx.Response(200, json=completion()) + response.request = request + return response + + monkeypatch.setattr(httpx.AsyncClient, 'send', send) + return captured, queued + + +@pytest.mark.asyncio +@pytest.mark.parametrize('name', [*minimax.RECOMMENDED_MODELS, 'MiniMax-M2.7', 'private-deployment']) +@pytest.mark.parametrize( + 'base_url', [None, 'https://api.minimax.chat/v1', 'https://api.minimaxi.com/v1', 'https://proxy.test/v1'] +) +async def test_saved_endpoint_and_model_are_not_migrated(provider, wire, name, base_url): + if base_url: + provider.requester_cfg['base_url'] = base_url + await provider.invoke_llm(None, runtime(provider, name), [Message(role='user', content='hello')]) + request, body = wire[0][-1] + assert str(request.url) == f'{base_url or "https://api.minimax.io/v1"}/chat/completions' + assert body['model'] == name + assert body['messages'] == [{'role': 'user', 'content': 'hello'}] + assert not {'thinking', 'reasoning_effort', 'reasoning_split', 'service_tier'}.intersection(body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('level', ['enabled', 'low', 'medium', 'high', 'xhigh', 'max']) +async def test_flash_effort_is_serialized_without_changing_input(provider, wire, level): + content = [ + {'type': 'text', 'text': 'inspect'}, + {'type': 'image_url', 'image_url': {'url': 'https://image.test/a.png'}}, + ] + await provider.invoke_llm(None, runtime(provider, level=level), [Message(role='user', content=content)]) + body = wire[0][-1][1] + assert body['messages'][0]['content'] == content + if level == 'enabled': + assert body['thinking'] == {'type': 'adaptive'} + assert 'reasoning_effort' not in body + else: + assert body['reasoning_effort'] == level + assert 'thinking' not in body + + +@pytest.mark.asyncio +@pytest.mark.parametrize('level,expected', [('enabled', 'adaptive'), ('disabled', 'disabled')]) +async def test_m3_thinking_switch(provider, wire, level, expected): + await provider.invoke_llm(None, runtime(provider, 'MiniMax-M3', level), []) + assert wire[0][-1][1]['thinking'] == {'type': expected} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'name,level', [('MiniMax-M3.1-Flash', 'disabled'), ('MiniMax-M3', 'max'), ('MiniMax-M2.7', 'disabled')] +) +async def test_unsupported_thinking_rejected_before_http(provider, wire, name, level): + with pytest.raises(errors.RequesterError): + await provider.invoke_llm(None, runtime(provider, name, level), []) + assert wire[0] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status', [200, 404]) +async def test_direct_discovery_fallback_preserves_custom_and_nontext_models(provider, wire, status): + names = ['MiniMax-M2.7', 'MiniMax-01', 'abab6.5-chat', 'speech-02-hd', 'embo-01', 'private-deployment'] + wire[1].append(httpx.Response(status, json={'data': [{'id': name} for name in names]})) + models = (await provider.scan_models('synthetic-minimax-test'))['models'] + assert [model['id'] for model in models[:2]] == list(minimax.RECOMMENDED_MODELS) + assert all(model['context_length'] == 1_000_000 for model in models[:2]) + assert all(set(model['abilities']) == {'func_call', 'vision', 'reasoning'} for model in models[:2]) + assert {model['id'] for model in models[2:]} == (set(names[3:]) if status == 200 else set()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('status', [401, 403, 429, 500]) +async def test_catalog_errors_are_not_hidden_by_recommendations(provider, wire, status): + wire[1].append(httpx.Response(status, json={'error': 'fixture'})) + with pytest.raises(errors.RequesterError, match=str(status)): + await provider.scan_models() + + +@pytest.mark.asyncio +async def test_gateway_keeps_all_advertised_current_variants_and_metadata(provider, wire): + provider.requester_cfg.update(requester_name='openrouter-chat-completions', base_url='https://openrouter.ai/api/v1') + names = [ + 'other/minimax-m2', + 'minimax/minimax-m3', + 'minimax/minimax-m3.1-flash', + 'minimax/minimax-m3.1-flash:free', + 'minimax/minimax-m4', + 'minimax/minimax-m2.7', + 'minimax/speech-02-hd', + ] + wire[1].append(httpx.Response(200, json={'data': [{'id': name, 'context_length': 12345} for name in names]})) + models = (await provider.scan_models())['models'] + assert [model['id'] for model in models[:3]] == [names[2], names[3], names[1]] + assert {model['id'] for model in models} == set(names) - {names[5]} + assert all(model.get('context_length') == 12345 for model in models if model['type'] == 'llm') + wire[1].append(httpx.Response(200, json={'data': []})) + assert await provider.scan_models() == {'models': []} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stream', [False, True]) +@pytest.mark.parametrize('hide_thinking', [False, True]) +@pytest.mark.parametrize('name,level', [('MiniMax-M3.1-Flash', 'provider_default'), ('MiniMax-M3', 'disabled')]) +async def test_tool_followup_replays_original_assistant_not_rendered_text( + provider, wire, stream, hide_thinking, name, level +): + tool_call = {'id': 'lookup-1', 'type': 'function', 'function': {'name': 'lookup', 'arguments': '{}'}} + original = { + 'role': 'assistant', + 'content': 'privateanswer', + 'reasoning_content': 'plan', + 'tool_calls': [tool_call], + } + model = runtime(provider, name, level) + if stream: + deltas = [ + {'role': 'assistant', 'reasoning_content': 'pl'}, + {'reasoning_content': 'an'}, + {'content': 'private'}, + {'content': 'answer'}, + {'tool_calls': [{'index': 0, **tool_call}]}, + ] + events = [] + for index, delta in enumerate([*deltas, {}]): + event = completion() + event.update(object='chat.completion.chunk') + event['choices'] = [ + {'index': 0, 'delta': delta, 'finish_reason': 'tool_calls' if index == len(deltas) else None} + ] + events.append('data: ' + json.dumps(event) + '\n\n') + wire[1].append( + httpx.Response( + 200, content=''.join(events) + 'data: [DONE]\n\n', headers={'content-type': 'text/event-stream'} + ) + ) + chunks = [chunk async for chunk in provider.invoke_llm_stream(None, model, [], remove_think=hide_thinking)] + fields = {} + calls = [] + for chunk in chunks: + for key, value in (chunk.provider_specific_fields or {}).items(): + fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value + if chunk.tool_calls: + calls = chunk.tool_calls + response = Message( + role='assistant', + content=''.join(chunk.content or '' for chunk in chunks), + provider_specific_fields=fields, + tool_calls=calls, + ) + else: + wire[1].append(httpx.Response(200, json=completion(original))) + response, _ = await provider.invoke_llm(None, model, [], remove_think=hide_thinking) + if hide_thinking: + assert response.content == 'answer' + assert response.provider_specific_fields[minimax.RAW_CONTENT_FIELD] == original['content'] + saved = Message.model_validate_json(response.model_dump_json()) + await provider.invoke_llm( + None, model, [saved, Message(role='tool', tool_call_id='lookup-1', content='result')], funcs=[object()] + ) + followup = wire[0][-1][1] + assert {key: followup['messages'][0][key] for key in original} == original + assert minimax.RAW_CONTENT_FIELD not in json.dumps(followup) + assert followup['messages'][1]['tool_call_id'] == 'lookup-1' + assert followup['tools'][0]['function']['name'] == 'lookup' + + +def test_private_replay_field_does_not_leak_when_switching_provider(provider): + message = Message( + role='assistant', content='visible', provider_specific_fields={minimax.RAW_CONTENT_FIELD: 'private'} + ) + assert provider._convert_messages([message], reasoning_family='openai') == [ + {'role': 'assistant', 'content': 'visible'} + ] + assert message.provider_specific_fields == {minimax.RAW_CONTENT_FIELD: 'private'} + + +def test_direct_catalog_does_not_overwrite_advertised_context_or_drop_nontext(): + advertised = [ + {'id': 'MiniMax-M3', 'name': 'MiniMax-M3', 'type': 'llm', 'context_length': 54321}, + {'id': 'minimax-m2-embedding', 'name': 'embedding', 'type': 'text-embedding'}, + ] + models = minimax.arrange_catalog(advertised, direct=True) + assert len(models) == 3 + assert models[1]['context_length'] == 54321 + assert models[2] == advertised[1] + assert 'abilities' not in advertised[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('requester_name', ['openai-chat-completions', 'openrouter-chat-completions']) +async def test_other_providers_do_not_get_minimax_404_fallback(provider, wire, requester_name): + provider.requester_cfg['requester_name'] = requester_name + wire[1].append(httpx.Response(404, json={})) + with pytest.raises(errors.RequesterError, match='404'): + await provider.scan_models() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('content', ['hidden', 'hiddenanswer']) +async def test_stream_without_finish_reason_flushes_one_complete_history_snapshot(provider, wire, content): + events = [] + for fragment in [content[:5], content[5:]]: + event = completion() + event['object'] = 'chat.completion.chunk' + event['choices'] = [{'index': 0, 'delta': {'content': fragment}, 'finish_reason': None}] + events.append('data: ' + json.dumps(event) + '\n\n') + wire[1].append( + httpx.Response(200, content=''.join(events) + 'data: [DONE]\n\n', headers={'content-type': 'text/event-stream'}) + ) + chunks = [chunk async for chunk in provider.invoke_llm_stream(None, runtime(provider), [], remove_think=True)] + snapshots = [ + chunk.provider_specific_fields[minimax.RAW_CONTENT_FIELD] + for chunk in chunks + if minimax.RAW_CONTENT_FIELD in (chunk.provider_specific_fields or {}) + ] + assert snapshots == [content]