fix(line): map LINE mentions to At elements so the at-bot rule works (#2478)

The LINE adapter passed text through as a single Plain component,
ignoring the mention payload (mentions[].index/length/isSelf) that the
Line Messaging API includes in the webhook. As a result:

- At(target=bot_account_id) never appeared in the message chain, so the
  'at-bot' group respond rule silently dropped every @bot mention.
- The bot only replied when the message happened to match the prefix
  rule (e.g. starting with 'ai').

Now LINEMessageConverter reads message.message.mention and builds the
chain per mention position:

- Bot mention (isSelf) -> At(target=bot_account_id) so AtBotRule matches
  the same way as other adapters (dingtalk/lark etc.).
- Other mentions -> At(target=<line user id>, display=<mention text>).
  At.__str__ already prepends '@', so the display text carries no
  double '@' and the rendered text (prefix/regexp rules, quotes,
  session context) is byte-identical to before.
- Missing/out-of-bounds mentions are skipped defensively.

target2yiri becomes an instance method (like wechatpad/aiocqhttp) so
the converters can hold bot_account_id; LINEAdapter passes it in from
its own config.
This commit is contained in:
leonoxo
2026-08-27 18:38:27 +08:00
committed by GitHub
parent b66db86bff
commit 855ae2bdba
2 changed files with 251 additions and 18 deletions
+51 -8
View File
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
ImageMessageContent, ImageMessageContent,
VideoMessageContent, VideoMessageContent,
AudioMessageContent, AudioMessageContent,
UserMentionee,
) )
# from linebot import WebhookParser # from linebot import WebhookParser
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return content_list return content_list
@staticmethod def __init__(self, bot_account_id: str = ''):
async def target2yiri(message, bot_client) -> platform_message.MessageChain: self.bot_account_id = bot_account_id
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
lb_msg_list = [] lb_msg_list = []
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
if isinstance(message.message, TextMessageContent): if isinstance(message.message, TextMessageContent):
lb_msg_list.append(platform_message.Plain(text=message.message.text)) lb_msg_list.extend(
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
)
elif isinstance(message.message, AudioMessageContent): elif isinstance(message.message, AudioMessageContent):
pass pass
elif isinstance(message.message, VideoMessageContent): elif isinstance(message.message, VideoMessageContent):
@@ -86,17 +91,55 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Image(base64=data_uri)) lb_msg_list.append(platform_message.Image(base64=data_uri))
return platform_message.MessageChain(lb_msg_list) return platform_message.MessageChain(lb_msg_list)
def _build_text_components(self, text: str, mention) -> list:
"""Build message components from text, inserting At components for mentions.
LINE provides mention positions (index/length) and is_self per mentionee in the
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
'at-bot' group respond rule work for LINE, consistent with other adapters.
"""
components: list = []
if not mention or not mention.mentionees:
if text:
components.append(platform_message.Plain(text=text))
return components
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m 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]))
if isinstance(mentionee, UserMentionee):
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
if not target:
target = text[start:end]
else:
target = text[start:end]
# At.__str__ already prepends '@', so strip one from the LINE text token.
display = text[start:end].lstrip('@')
components.append(platform_message.At(target=str(target), display=display))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
self.message_converter = LINEMessageConverter(bot_account_id)
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
event: platform_events.MessageEvent, event: platform_events.MessageEvent,
) -> MessageEvent: ) -> MessageEvent:
pass pass
@staticmethod async def target2yiri(self, event, bot_client) -> platform_events.Event:
async def target2yiri(event, bot_client) -> platform_events.Event: message_chain = await self.message_converter.target2yiri(event, bot_client)
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
if event.source.type == 'user': if event.source.type == 'user':
return platform_events.FriendMessage( return platform_events.FriendMessage(
@@ -169,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners={}, listeners={},
card_id_dict={}, card_id_dict={},
seq=1, seq=1,
event_converter=LINEEventConverter(), event_converter=LINEEventConverter(bot_account_id),
message_converter=LINEMessageConverter(), message_converter=LINEMessageConverter(bot_account_id),
line_webhook=line_webhook, line_webhook=line_webhook,
parser=parser, parser=parser,
configuration=configuration, configuration=configuration,
@@ -3,18 +3,25 @@ from __future__ import annotations
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from linebot.v3.webhooks import TextMessageContent from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401 from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line from langbot.pkg.platform.sources import line
import langbot_plugin.api.entities.builtin.platform.message as platform_message
BOT_ACCOUNT_ID = 'line-bot-account'
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'): def _make_event(
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
):
event = MagicMock() event = MagicMock()
event.timestamp = 1700000000000 event.timestamp = 1700000000000
event.message = MagicMock(spec=TextMessageContent) message = MagicMock(spec=TextMessageContent)
event.message.id = message_id message.id = message_id
event.message.text = text message.text = text
message.mention = mention
event.message = message
event.message.webhook_event_id = f'webhook-{message_id}' event.message.webhook_event_id = f'webhook-{message_id}'
event.message.timestamp = event.timestamp event.message.timestamp = event.timestamp
@@ -30,16 +37,21 @@ def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, messa
return event return event
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
return line.LINEEventConverter(bot_account_id=bot_account_id)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_message_launcher_id_stable_across_messages() -> None: async def test_user_message_launcher_id_stable_across_messages() -> None:
"""Two distinct messages from the same LINE user must resolve to the same """Two distinct messages from the same LINE user must resolve to the same
sender id, otherwise every message starts a brand new session (context loss). sender id, otherwise every message starts a brand new session (context loss).
""" """
converter = _make_converter()
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1') event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2') event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None) result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.id == 'U-stable-user' assert result1.sender.id == 'U-stable-user'
assert result1.sender.id == result2.sender.id assert result1.sender.id == result2.sender.id
@@ -48,11 +60,12 @@ async def test_user_message_launcher_id_stable_across_messages() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_message_uses_group_id_not_message_id() -> None: async def test_group_message_uses_group_id_not_message_id() -> None:
converter = _make_converter()
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1') event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2') event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None) result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.group.id == 'G-stable-group' assert result1.sender.group.id == 'G-stable-group'
assert result1.sender.group.id == result2.sender.group.id assert result1.sender.group.id == result2.sender.group.id
@@ -61,9 +74,186 @@ async def test_group_message_uses_group_id_not_message_id() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None: async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
converter = _make_converter()
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1') event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
result = await line.LINEEventConverter.target2yiri(event, bot_client=None) result = await converter.target2yiri(event, bot_client=None)
assert result.sender.group.id == 'R-stable-room' assert result.sender.group.id == 'R-stable-room'
assert result.sender.id == 'R-stable-room' assert result.sender.id == 'R-stable-room'
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
return [c for c in chain if isinstance(c, platform_message.At)]
@pytest.mark.asyncio
async def test_no_mention_keeps_plain_text() -> None:
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
assert _plain_texts(chain) == ['hello world']
assert _ats(chain) == []
@pytest.mark.asyncio
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
group respond rule matches (previously the mention was lost and the message
was silently dropped in groups with at-only rules).
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@BOT hey',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == BOT_ACCOUNT_ID
assert _plain_texts(chain) == [' hey']
@pytest.mark.asyncio
async def test_other_user_mention_keeps_display_text() -> None:
"""Mentions of other users keep their display text in the message string,
so prefix/regexp rules that match the raw '@Name ...' text still work.
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@Alice hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == 'U-other'
# str() of the At component falls back to display when set
assert str(chain) == '@Alice hello'
@pytest.mark.asyncio
async def test_bot_mention_triggers_atbot_rule() -> None:
"""End-to-end: a group message that @mentions the bot must be accepted by
the at-bot respond rule (this is the regression that silently dropped
'@bot' messages in LINE groups).
"""
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@RAIQt hi',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is True
@pytest.mark.asyncio
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is False
@pytest.mark.asyncio
async def test_at_all_mention_preserved_as_at_component() -> None:
mention = MagicMock()
mention.mentionees = [
AllMentionee(type='all', index=0, length=4),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@All hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert str(chain) == '@All hello'
@pytest.mark.asyncio
async def test_multiple_mentions_sorted_by_position() -> None:
mention = MagicMock()
# Intentionally out of order to exercise sorting
mention.mentionees = [
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@aaa mid @bbb tail',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert [a.target for a in ats] == ['U-a', 'U-b']
assert str(chain) == '@aaa mid @bbb tail'