-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: message aggregator #1985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: message aggregator #1985
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,280 @@ | ||||||||||||||||||||||||||||||||||||||
| """Message Aggregator Module | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| This module provides message aggregation/debounce functionality. | ||||||||||||||||||||||||||||||||||||||
| When users send multiple messages consecutively, the aggregator will wait | ||||||||||||||||||||||||||||||||||||||
| for a configurable delay period and merge them into a single message | ||||||||||||||||||||||||||||||||||||||
| before processing. | ||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||
| import typing | ||||||||||||||||||||||||||||||||||||||
| from dataclasses import dataclass, field | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import langbot_plugin.api.entities.builtin.platform.message as platform_message | ||||||||||||||||||||||||||||||||||||||
| import langbot_plugin.api.entities.builtin.platform.events as platform_events | ||||||||||||||||||||||||||||||||||||||
| import langbot_plugin.api.entities.builtin.provider.session as provider_session | ||||||||||||||||||||||||||||||||||||||
| import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if typing.TYPE_CHECKING: | ||||||||||||||||||||||||||||||||||||||
| from ..core import app | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||
| class PendingMessage: | ||||||||||||||||||||||||||||||||||||||
| """A pending message waiting to be aggregated""" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| bot_uuid: str | ||||||||||||||||||||||||||||||||||||||
| launcher_type: provider_session.LauncherTypes | ||||||||||||||||||||||||||||||||||||||
| launcher_id: typing.Union[int, str] | ||||||||||||||||||||||||||||||||||||||
| sender_id: typing.Union[int, str] | ||||||||||||||||||||||||||||||||||||||
| message_event: platform_events.MessageEvent | ||||||||||||||||||||||||||||||||||||||
| message_chain: platform_message.MessageChain | ||||||||||||||||||||||||||||||||||||||
| adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | ||||||||||||||||||||||||||||||||||||||
| pipeline_uuid: typing.Optional[str] | ||||||||||||||||||||||||||||||||||||||
| timestamp: float = field(default_factory=time.time) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||
| class SessionBuffer: | ||||||||||||||||||||||||||||||||||||||
| """Buffer for a single session's pending messages""" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| session_id: str | ||||||||||||||||||||||||||||||||||||||
| messages: list[PendingMessage] = field(default_factory=list) | ||||||||||||||||||||||||||||||||||||||
| timer_task: typing.Optional[asyncio.Task] = None | ||||||||||||||||||||||||||||||||||||||
| last_message_time: float = field(default_factory=time.time) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| class MessageAggregator: | ||||||||||||||||||||||||||||||||||||||
| """Message aggregator that buffers and merges consecutive messages | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| This class implements a debounce mechanism for incoming messages. | ||||||||||||||||||||||||||||||||||||||
| When a message arrives, it starts a timer. If more messages arrive | ||||||||||||||||||||||||||||||||||||||
| before the timer expires, they are buffered. When the timer expires, | ||||||||||||||||||||||||||||||||||||||
| all buffered messages are merged and sent to the query pool. | ||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ap: app.Application | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| buffers: dict[str, SessionBuffer] | ||||||||||||||||||||||||||||||||||||||
| """Session ID -> SessionBuffer mapping""" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| lock: asyncio.Lock | ||||||||||||||||||||||||||||||||||||||
| """Lock for thread-safe buffer operations""" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def __init__(self, ap: app.Application): | ||||||||||||||||||||||||||||||||||||||
| self.ap = ap | ||||||||||||||||||||||||||||||||||||||
| self.buffers = {} | ||||||||||||||||||||||||||||||||||||||
| self.lock = asyncio.Lock() | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def _get_session_id( | ||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||
| bot_uuid: str, | ||||||||||||||||||||||||||||||||||||||
| launcher_type: provider_session.LauncherTypes, | ||||||||||||||||||||||||||||||||||||||
| launcher_id: typing.Union[int, str], | ||||||||||||||||||||||||||||||||||||||
| ) -> str: | ||||||||||||||||||||||||||||||||||||||
| """Generate a unique session ID""" | ||||||||||||||||||||||||||||||||||||||
| return f'{bot_uuid}:{launcher_type.value}:{launcher_id}' | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]: | ||||||||||||||||||||||||||||||||||||||
| """Get aggregation configuration for a pipeline | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||
| tuple: (enabled, delay_seconds) | ||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||
| default_enabled = True | ||||||||||||||||||||||||||||||||||||||
| default_delay = 1.5 | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if pipeline_uuid is None: | ||||||||||||||||||||||||||||||||||||||
| return default_enabled, default_delay | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # Get pipeline from pipeline manager | ||||||||||||||||||||||||||||||||||||||
| pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) | ||||||||||||||||||||||||||||||||||||||
| if pipeline is None: | ||||||||||||||||||||||||||||||||||||||
| return default_enabled, default_delay | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| config = pipeline.pipeline_entity.config or {} | ||||||||||||||||||||||||||||||||||||||
| trigger_config = config.get('trigger', {}) | ||||||||||||||||||||||||||||||||||||||
| aggregation_config = trigger_config.get('message-aggregation', {}) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| enabled = aggregation_config.get('enabled', default_enabled) | ||||||||||||||||||||||||||||||||||||||
| delay = aggregation_config.get('delay', default_delay) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # Clamp delay to valid range | ||||||||||||||||||||||||||||||||||||||
| delay = max(1.0, min(10.0, float(delay))) | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| delay = aggregation_config.get('delay', default_delay) | |
| # Clamp delay to valid range | |
| delay = max(1.0, min(10.0, float(delay))) | |
| delay_raw = aggregation_config.get('delay', default_delay) | |
| try: | |
| delay = float(delay_raw) | |
| except (TypeError, ValueError): | |
| delay = default_delay | |
| # Clamp delay to valid range | |
| delay = max(1.0, min(10.0, delay)) |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new aggregation/debounce behavior is non-trivial (buffering, keying, merge semantics). Add unit tests to cover: (1) messages within the delay window are merged, (2) different senders in the same group are not merged, and (3) reply-critical message_event/message_id stays intact when merging.
Outdated
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_merge_messages() mutates message_event.message_chain to the merged chain. Several adapters rely on message_source.message_chain.message_id when replying/quoting; replacing the chain risks losing the original message_id and other event metadata. Since QueryPool.add_query() already takes message_chain separately, keep message_event unmodified (or choose a specific event to keep for reply reference) and only pass the merged chain via the message_chain field.
| # Create merged message with updated chain | |
| # We need to update the message_event's message_chain as well | |
| merged_event = base_msg.message_event | |
| merged_event.message_chain = merged_chain | |
| return PendingMessage( | |
| bot_uuid=base_msg.bot_uuid, | |
| launcher_type=base_msg.launcher_type, | |
| launcher_id=base_msg.launcher_id, | |
| sender_id=base_msg.sender_id, | |
| message_event=merged_event, | |
| # Create merged message using the original event (do not mutate its chain) | |
| return PendingMessage( | |
| bot_uuid=base_msg.bot_uuid, | |
| launcher_type=base_msg.launcher_type, | |
| launcher_id=base_msg.launcher_id, | |
| sender_id=base_msg.sender_id, | |
| message_event=base_msg.message_event, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_get_session_id()currently keys buffers only bybot_uuid,launcher_type, andlauncher_id. For group messages this means messages from different senders in the same group can be merged together, and the merged query will also keepsender_idfrom the first buffered message (misattributing content). Includesender_idin the buffer/session key (at least for group chats) so aggregation matches the “same user” behavior described in the metadata and avoids cross-user merges.