Merge remote-tracking branch 'origin/master' into feat/blocking-interactions

This commit is contained in:
fdc310
2026-10-31 03:17:49 +08:00
26 changed files with 1821 additions and 15 deletions
@@ -13,6 +13,7 @@ OMNI_ADAPTER_NAMES = frozenset(
'wecombot',
'wecomcs',
'officialaccount',
'line',
}
)
@@ -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 ''
@@ -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')
@@ -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
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -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
@@ -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)
@@ -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,
}
@@ -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
@@ -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:
@@ -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)))
@@ -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