from __future__ import annotations import asyncio from typing import Any, ClassVar from nio import ( AsyncClient, DownloadResponse, LoginError, LoginResponse, LocalProtocolError, SyncError, ) from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.meta import ChannelMeta from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ) from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .auto_join import should_auto_join from .config_patch import MatrixAccountPatch, apply_patch from .crypto import import_room_keys from .encryption import check_crypto_store_ready, format_encryption_unavailable_error from .group_mentions import GroupMentionStrategy from .monitor import MatrixMonitor from .normalizer import MatrixNormalizer from .polls import create_poll, end_poll, send_poll_response from .probe import probe_matrix from .profile_sync import resolve_avatar_url from .send import ( classify_send_error, send_formatted_media, send_formatted_text, send_location, send_reaction, send_sticker, send_stream_chunk, send_thread_message, ) from .session import MatrixSessionHelper from .sync_store import SSRFGuard, SyncTokenStore @register_builtin_adapter class MatrixAdapter(BaseChannelAdapter): channel_id: ClassVar[str] = "matrix" channel_type: ClassVar[ChannelType] = ChannelType.MATRIX webhook_path: ClassVar[str | None] = None text_chunk_limit: ClassVar[int] = 65536 supports_markdown: ClassVar[bool] = True supports_streaming: ClassVar[bool] = True streaming_modes: ClassVar[list[str]] = ["off", "block", "lane", "reasoning"] max_media_size_mb: ClassVar[int] = 100 capabilities = ChannelCapabilities( chat_types=["direct", "group", "thread"], reactions=True, edit=True, unsend=True, reply=True, threads=True, media=True, pin=True, polls=True, sticker=True, location=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "block", "lane", "reasoning"], block_streaming=True, lane_streaming=True, reasoning_streaming=True, text_chunk_limit=65536, max_media_size_mb=100, ) meta = ChannelMeta(id="matrix", label="Matrix") def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._homeserver: str = "" self._client: AsyncClient | None = None self._monitor: MatrixMonitor | None = None self._ready_event = asyncio.Event() self._joined_rooms: set[str] = set() self._direct_rooms: set[str] = set() self._ready_at = utc_now_naive() self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self._normalizer = MatrixNormalizer(self) self._session = MatrixSessionHelper(self) self._mention_strategy = GroupMentionStrategy(self.config) self._stream_state: dict[str, int] = {} crypto_store_dir = self.config.get("crypto_store_dir", "./matrix_crypto_store") self._sync_token_store = SyncTokenStore(crypto_store_dir, self.config.get("user_id", "unknown")) @property def user_id(self) -> str: return self._client.user_id if self._client else "" @property def status(self) -> str: return self._status.value async def connect(self) -> None: self._status = ChannelStatus.CONNECTING self._ready_event.clear() homeserver = self.config.get("homeserver", "https://matrix.org") access_token = self.config.get("access_token") password = self.config.get("password") user_id = self.config.get("user_id", "") device_id = self.config.get("device_id", "yuxi-bot") self._check_private_network() if not user_id: self._status = ChannelStatus.ERROR raise ChannelException("Matrix user_id not configured") crypto_store_dir = self.config.get( "crypto_store_dir", f"./matrix_crypto_store/{homeserver.replace('://', '_')}" ) self._client = AsyncClient( homeserver=homeserver, user=user_id, store_path=crypto_store_dir, ) if access_token: self._client.access_token = access_token self._client.user_id = user_id logger.info("Matrix: using configured access_token") elif password: try: resp = await self._client.login(password=password, device_id=device_id) if isinstance(resp, LoginResponse): self._client.access_token = resp.access_token self._client.user_id = resp.user_id logger.info(f"Matrix login ok: {resp.user_id} @ {resp.homeserver} (device: {resp.device_id})") else: self._status = ChannelStatus.ERROR raise ChannelAuthenticationError() except LoginError: self._status = ChannelStatus.ERROR raise ChannelAuthenticationError() else: self._status = ChannelStatus.ERROR raise ChannelAuthenticationError() await import_room_keys(self._client, crypto_store_dir) crypto_ready = check_crypto_store_ready(crypto_store_dir) if crypto_ready["status"] == "unavailable": logger.warning(format_encryption_unavailable_error(self.config)) elif crypto_ready["status"] == "empty": logger.info(f"Matrix crypto store ready for first use: {crypto_store_dir}") sync_timeout_ms = self.config.get("sync_timeout_ms", 30000) initial_sync_token = self._sync_token_store.load() if initial_sync_token: logger.info("Matrix resuming sync from stored token") try: sync_resp = await self._client.sync( timeout_ms=sync_timeout_ms, since=initial_sync_token, ) if sync_resp: self._joined_rooms = set(sync_resp.rooms.join.keys()) self._direct_rooms = self._resolve_direct_rooms() logger.info(f"Matrix initial sync: {len(self._joined_rooms)} rooms") else: logger.warning("Matrix initial sync returned empty response") except (SyncError, LocalProtocolError) as e: self._status = ChannelStatus.ERROR raise ChannelException(f"Matrix initial sync failed: {e}", retryable=True) room_allowlist = self.config.get("rooms", {}) for room_id, room_config in room_allowlist.items(): if not room_config.get("allow", True): continue if not should_auto_join(room_id, self.config): continue if room_id not in self._joined_rooms: try: resp = await self._client.join(room_id) if hasattr(resp, "room_id"): self._joined_rooms.add(room_id) logger.info(f"Matrix joined room: {room_id}") except Exception as e: logger.warning(f"Matrix join room {room_id} failed: {e}") self._monitor = MatrixMonitor( self.config, self._client, self, sync_token_store=self._sync_token_store, ) self._monitor.on_message(self._handle_message) await self._monitor.start(initial_since=sync_resp.next_batch if sync_resp else initial_sync_token) self._ready_event.set() self._ready_at = utc_now_naive() self._status = ChannelStatus.CONNECTED self._homeserver = homeserver logger.info(f"Matrix adapter connected: {user_id} @ {homeserver}") async def disconnect(self) -> None: self._status = ChannelStatus.DISCONNECTED self._ready_event.clear() if self._monitor: await self._monitor.stop() self._monitor = None if self._client: try: await self._client.close() except Exception: pass self._client = None self._joined_rooms.clear() self._direct_rooms.clear() self._stream_state.clear() logger.info("Matrix adapter disconnected") async def create_poll( self, room_id: str, question: str, answers: list[str], max_selections: int = 1 ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do(): return await create_poll(self._client, room_id, question, answers, max_selections=max_selections) return await self._send_with_circuit(_do) async def vote_poll(self, room_id: str, poll_event_id: str, answer_ids: list[str]) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do(): return await send_poll_response(self._client, room_id, poll_event_id, answer_ids) return await self._send_with_circuit(_do) async def end_poll(self, room_id: str, poll_event_id: str, summary: str = "") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do(): return await end_poll(self._client, room_id, poll_event_id, summary) return await self._send_with_circuit(_do) async def send_location( self, room_id: str, lat: float, lon: float, body: str = "", description: str = "" ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do(): return await send_location(self._client, room_id, lat, lon, body, description) return await self._send_with_circuit(_do) async def send_sticker( self, room_id: str, sticker_url: str, body: str = "", mimetype: str = "image/png" ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do(): return await send_sticker(self._client, room_id, sticker_url, body, mimetype) return await self._send_with_circuit(_do) async def send_ack_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult | None: ack_reaction = self.config.get("ackReaction") if not ack_reaction or not self._client: return None return await self.send_reaction(chat_id, msg_id, ack_reaction) def _should_send_ack(self, chat_type: str) -> bool: scope = self.config.get("ackReactionScope", "group-mentions") if scope == "always": return True if scope == "dm-only": return chat_type == "direct" if scope == "group-mentions": return chat_type == "group" if scope == "never": return False return False def _check_private_network(self) -> None: allow_private = self.config.get("dangerouslyAllowPrivateNetwork", False) if allow_private: return error = SSRFGuard.validate_url(self._homeserver, allow_private) if error: logger.warning(f"Matrix private network warning: {error}") async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") chat_id = response.identity.channel_chat_id root_event_id = response.metadata.get("root_event_id") content = response.content prefix = self.config.get("responsePrefix", "") if prefix and content: content = f"{prefix} {content}" chunk_limit = min(self.text_chunk_limit, 65536) if len(content.encode("utf-8")) <= chunk_limit: final_response = ChannelResponse( identity=response.identity, message_type=response.message_type, content=content, reply_to_message_id=response.reply_to_message_id, metadata=response.metadata, timestamp=response.timestamp, ) async def _do_send(): if root_event_id: return await send_thread_message(self._client, chat_id, root_event_id, content) return await send_formatted_text(self._client, chat_id, final_response) return await self._send_with_circuit(_do_send) return await self._send_chunked(chat_id, root_event_id, response, chunk_limit, content) async def send_media( self, chat_id: str, media_type: str, data: Any, filename: str = "", caption: str = "", reply_to: str | None = None, ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") media_data = data if isinstance(data, bytes) else str(data).encode("utf-8") max_mb = self.config.get("mediaMaxMb", self.max_media_size_mb) max_bytes = max_mb * 1024 * 1024 if len(media_data) > max_bytes: return DeliveryResult( success=False, error=f"Media size {len(media_data)} bytes exceeds limit of {max_bytes} bytes ({max_mb} MB)", ) async def _do_send(): return await send_formatted_media( self._client, chat_id, media_type, media_data, "application/octet-stream", filename=filename, caption=caption, reply_to=reply_to, ) return await self._send_with_circuit(_do_send) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_send(): return await send_stream_chunk( self._client, chat_id, msg_id, content, finished=True, ) return await self._send_with_circuit(_do_send) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_send(): return await send_stream_chunk( self._client, chat_id, msg_id, chunk, finished, stream_state=self._stream_state, ) return await self._send_with_circuit(_do_send) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_send(): return await send_reaction(self._client, chat_id, msg_id, emoji) return await self._send_with_circuit(_do_send) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_send(): resp = await self._client.room_redact( room_id=chat_id, event_id=msg_id, ) return DeliveryResult(success=True, message_id=resp.event_id) return await self._send_with_circuit(_do_send) async def download_media(self, file_id: str) -> bytes: if not self._client: raise ChannelException("Client not initialized") try: resp, _ = await self._client.download(file_id) if isinstance(resp, DownloadResponse): return resp.body raise ChannelException(f"Media download failed for: {file_id}") except ChannelException: raise except Exception as e: raise ChannelException(f"Media download failed for {file_id}: {e}") async def send_typing_indicator(self, chat_id: str, typing: bool = True, timeout_ms: int = 30000) -> None: if not self._client: return try: await self._client.room_typing( room_id=chat_id, typing_state=typing, timeout=timeout_ms, ) except Exception as e: logger.debug(f"Matrix typing indicator failed: {e}") async def send_read_receipt(self, chat_id: str, event_id: str) -> None: if not self._client: return try: await self._client.room_read_markers( room_id=chat_id, fully_read_event=event_id, read_event=event_id, ) except Exception as e: logger.debug(f"Matrix read receipt failed: {e}") async def invite_user(self, room_id: str, user_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_invite(): resp = await self._client.room_invite(room_id, user_id) return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None)) return await self._send_with_circuit(_do_invite) async def kick_user(self, room_id: str, user_id: str, reason: str = "") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_kick(): resp = await self._client.room_kick(room_id, user_id, reason=reason) return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None)) return await self._send_with_circuit(_do_kick) async def ban_user(self, room_id: str, user_id: str, reason: str = "") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_ban(): resp = await self._client.room_ban(room_id, user_id, reason=reason) return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None)) return await self._send_with_circuit(_do_ban) async def leave_room(self, room_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_leave(): resp = await self._client.room_leave(room_id) self._joined_rooms.discard(room_id) return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None)) return await self._send_with_circuit(_do_leave) async def join_room(self, room_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") async def _do_join(): resp = await self._client.join(room_id) self._joined_rooms.add(room_id) return DeliveryResult(success=True, message_id=getattr(resp, "event_id", None)) return await self._send_with_circuit(_do_join) async def read_messages(self, chat_id: str, limit: int = 50, direction: str = "backward") -> list[dict[str, Any]]: if not self._client: return [] try: resp = await self._client.room_messages( room_id=chat_id, start="", limit=limit, direction=direction, ) events = getattr(resp, "chunk", []) return [ { "event_id": getattr(e, "event_id", ""), "sender": getattr(e, "sender", ""), "timestamp": getattr(e, "origin_server_ts", 0), "content": getattr(e, "source", {}).get("content", {}), } for e in events ] except Exception as e: logger.warning(f"Matrix read_messages failed for {chat_id}: {e}") return [] async def get_channel_info(self, chat_id: str) -> dict[str, Any]: if not self._client: return {} try: name_resp = await self._client.room_get_state_event(chat_id, "m.room.name") topic_resp = await self._client.room_get_state_event(chat_id, "m.room.topic") join_rules = await self._client.room_get_state_event(chat_id, "m.room.join_rules") members_resp = await self._client.joined_members(chat_id) name = getattr(name_resp, "name", "") if name_resp else "" topic = getattr(topic_resp, "topic", "") if topic_resp else "" join_rule = getattr(join_rules, "join_rule", "") if join_rules else "" member_count = len(getattr(members_resp, "members", [])) return { "room_id": chat_id, "name": name, "topic": topic, "join_rule": join_rule, "member_count": member_count, "is_dm": self._is_dm_room(chat_id), "is_joined": chat_id in self._joined_rooms, } except Exception as e: logger.warning(f"Matrix get_channel_info failed for {chat_id}: {e}") return {"room_id": chat_id, "error": str(e)} async def get_member_info(self, room_id: str, user_id: str) -> dict[str, Any]: if not self._client: return {} try: dn_resp = await self._client.get_displayname(user_id) avatar_resp = await self._client.get_avatar(user_id) display_name = getattr(dn_resp, "displayname", "") if dn_resp else "" avatar_url = getattr(avatar_resp, "avatar_url", "") if avatar_resp else "" return { "user_id": user_id, "display_name": display_name, "avatar_url": avatar_url, } except Exception as e: logger.warning(f"Matrix get_member_info failed for {user_id}: {e}") return {"user_id": user_id, "error": str(e)} async def set_profile(self, display_name: str = "", avatar_url: str = "") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: if display_name: await self._client.set_displayname(display_name) logger.info(f"Matrix display name updated: {display_name}") if avatar_url: resolved_url = resolve_avatar_url(avatar_url, self._homeserver) or avatar_url await self._client.set_avatar(resolved_url) logger.info("Matrix avatar updated") return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def pin_message(self, room_id: str, event_id: str) -> DeliveryResult: return await self._manage_pins(room_id, event_id, action="pin") async def unpin_message(self, room_id: str, event_id: str) -> DeliveryResult: return await self._manage_pins(room_id, event_id, action="unpin") async def list_pins(self, room_id: str) -> list[str]: if not self._client: return [] try: resp = await self._client.room_get_state_event(room_id, "m.room.pinned_events") if resp and hasattr(resp, "pinned"): return resp.pinned return [] except Exception as e: logger.warning(f"Matrix list_pins failed for {room_id}: {e}") return [] async def _manage_pins(self, room_id: str, event_id: str, action: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") try: current_pins = await self.list_pins(room_id) if action == "pin" and event_id not in current_pins: current_pins.append(event_id) elif action == "unpin": current_pins = [p for p in current_pins if p != event_id] else: return DeliveryResult(success=False, error=f"Unknown pin action: {action}") await self._client.room_put_state( room_id=room_id, event_type="m.room.pinned_events", content={"pinned": current_pins}, ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) def normalize_inbound(self, raw: Any) -> ChannelMessage | None: room_id = getattr(raw, "room_id", "unknown") return self._normalizer.normalize(raw, room_id) def format_outbound(self, response: ChannelResponse) -> Any: return { "room_id": response.identity.channel_chat_id, "content": response.content, "reply_to_message_id": response.reply_to_message_id, "metadata": response.metadata, } async def health_check(self) -> HealthStatus: if self._status != ChannelStatus.CONNECTED or not self._client: return HealthStatus( status="unhealthy", last_error="Matrix not connected", ) probe_timeout = self.config.get("probe_timeout_s", None) result = await probe_matrix(self._client, timeout_s=probe_timeout) if result.status == "healthy": result.last_connected_at = self._ready_at result.metadata["joined_rooms"] = len(self._joined_rooms) result.metadata["sync_running"] = self._monitor.running if self._monitor else False result.metadata["direct_rooms"] = len(self._direct_rooms) return result async def pre_connect(self) -> dict: homeserver = self.config.get("homeserver", "https://matrix.org") user_id = self.config.get("user_id", "") access_token = self.config.get("access_token") password = self.config.get("password") device_id = self.config.get("device_id", "yuxi-bot") probe_timeout = self.config.get("probe_timeout_s", None) if not user_id: return {"status": "error", "message": "Missing user_id"} if not access_token and not password: return {"status": "error", "message": "Missing access_token or password"} try: client = AsyncClient(homeserver=homeserver, user=user_id) if access_token: client.access_token = access_token client.user_id = user_id else: resp = await client.login(password=password, device_id=device_id) if isinstance(resp, LoginResponse): client.access_token = resp.access_token client.user_id = resp.user_id else: await client.close() return {"status": "error", "message": "Login failed"} prob_result = await probe_matrix(client, timeout_s=probe_timeout) await client.close() return { "status": "ok", "homeserver": homeserver, "user_id": user_id, "versions_probe": prob_result.status, } except Exception as e: return {"status": "error", "message": str(e)} async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._client: return {} try: dn_resp = await self._client.get_displayname(channel_user_id) avatar_resp = await self._client.get_avatar(channel_user_id) raw_display_name = dn_resp.displayname if hasattr(dn_resp, "displayname") else "" raw_avatar_url = avatar_resp.avatar_url if hasattr(avatar_resp, "avatar_url") else "" resolved_avatar = resolve_avatar_url(raw_avatar_url, self._homeserver) if raw_avatar_url else "" return { "user_id": channel_user_id, "display_name": raw_display_name, "avatar_url": resolved_avatar or raw_avatar_url, } except Exception: return {} async def update_config(self, config: dict[str, Any]) -> None: patch = MatrixAccountPatch.from_dict(config) self.config = apply_patch(self.config, patch) self._mention_strategy = GroupMentionStrategy(self.config) logger.info("Matrix adapter config updated") async def _refresh_token_if_needed(self) -> bool: password = self.config.get("password") if not password or not self._client: return False try: resp = await self._client.login( password=password, device_id=self.config.get("device_id", "yuxi-bot"), ) if isinstance(resp, LoginResponse): self._client.access_token = resp.access_token self._client.user_id = resp.user_id logger.info("Matrix token refreshed") return True except Exception as e: logger.warning(f"Matrix token refresh failed: {e}") return False async def _send_with_circuit(self, func) -> DeliveryResult: try: return await self._circuit_breaker.call(func) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: error_type, retry_after = classify_send_error(e) if error_type == "auth": if await self._refresh_token_if_needed(): try: return await self._circuit_breaker.call(func) except Exception as retry_e: return DeliveryResult(success=False, error=str(retry_e)) return DeliveryResult(success=False, error="Token refresh failed") if error_type == "rate_limit" and retry_after: await asyncio.sleep(retry_after / 1000) try: return await self._circuit_breaker.call(func) except Exception as retry_e: return DeliveryResult(success=False, error=str(retry_e)) return DeliveryResult(success=False, error=str(e)) async def _send_chunked( self, chat_id: str, root_event_id: str | None, response: ChannelResponse, limit: int, content: str ) -> DeliveryResult: chunk_mode = self.config.get("chunkMode", "length") chunks = _split_content(content, limit, chunk_mode) for chunk_text in chunks: chunk_response = ChannelResponse( identity=response.identity, message_type=response.message_type, content=chunk_text, reply_to_message_id=response.reply_to_message_id, metadata=response.metadata, timestamp=response.timestamp, ) async def _do_chunk(): if root_event_id: return await send_thread_message(self._client, chat_id, root_event_id, chunk_text) return await send_formatted_text(self._client, chat_id, chunk_response) result = await self._send_with_circuit(_do_chunk) if not result.success: return result logger.debug(f"Matrix chunk sent: {len(chunk_text)} chars") return DeliveryResult(success=True) def _is_dm_room(self, room_id: str) -> bool: return room_id in self._direct_rooms def _resolve_direct_rooms(self) -> set[str]: try: account_data = self._client.account_data if self._client else {} if account_data: direct_data = account_data.get("m.direct", {}) if isinstance(direct_data, dict): result = set() for room_ids in direct_data.values(): if isinstance(room_ids, list): result.update(room_ids) return result except Exception: pass return set() def _split_content(content: str, limit: int, mode: str) -> list[str]: encoded = content.encode("utf-8") if len(encoded) <= limit: return [content] if mode == "newline": return _split_by_newlines(encoded, limit) return _split_by_length(encoded, limit) def _split_by_length(encoded: bytes, limit: int) -> list[str]: chunks = [] offset = 0 while offset < len(encoded): chunk_bytes = encoded[offset : offset + limit] chunks.append(chunk_bytes.decode("utf-8", errors="ignore")) offset += limit return chunks def _split_by_newlines(encoded: bytes, limit: int) -> list[str]: text = encoded.decode("utf-8") lines = text.split("\n") chunks = [] current = "" for line in lines: candidate = f"{current}\n{line}" if current else line if len(candidate.encode("utf-8")) > limit and current: chunks.append(current) current = line else: current = candidate if current: chunks.append(current) return chunks or [text]