refactor(irc): 整理导入并修复代码风格
1. 调整导入顺序和移除多余空行 2. 重构IRC NAMES命令的成员解析逻辑,正确剥离所有前缀 3. 更新配置schema:修改默认消息块大小为350字节,新增disableBlockStreaming配置项 4. 完善CTCP处理:新增ACTION支持,提取CTCP动作文本 5. 新增IRC线程上下文模拟器类 6. 新增SRV记录解析支持,自动解析IRC服务器域名 7. 新增多种IRC通知处理:ACCOUNT、AWAY、INVITE、CAP 8. 重构消息发送逻辑,添加熔断器和重试机制 9. 重写流式消息合并逻辑,新增智能合并缓冲 10. 扩展IRC CAP支持,新增多个常用扩展能力 11. 修复配置读取逻辑,适配新的配置结构
This commit is contained in:
parent
8f352d91bb
commit
4e07292a81
@ -7,7 +7,8 @@ from .accounts import (
|
|||||||
)
|
)
|
||||||
from .adapter import IRCAdapter, IrcStatus
|
from .adapter import IRCAdapter, IrcStatus
|
||||||
from .commands import sender_allowed_for_commands
|
from .commands import sender_allowed_for_commands
|
||||||
from .config_schema import get_config_schema, get_schema_keys, validate_config as validate_config_schema
|
from .config_schema import get_config_schema, get_schema_keys
|
||||||
|
from .config_schema import validate_config as validate_config_schema
|
||||||
from .config_ui_hints import get_config_ui_hints
|
from .config_ui_hints import get_config_ui_hints
|
||||||
from .configured_state import has_configured_state, is_configured
|
from .configured_state import has_configured_state, is_configured
|
||||||
from .directory import DirectoryGroup, DirectoryUser, list_groups, list_users
|
from .directory import DirectoryGroup, DirectoryUser, list_groups, list_users
|
||||||
|
|||||||
@ -15,6 +15,8 @@ from yuxi.channels.exceptions import (
|
|||||||
ChannelAuthenticationError,
|
ChannelAuthenticationError,
|
||||||
ChannelException,
|
ChannelException,
|
||||||
)
|
)
|
||||||
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
||||||
|
from yuxi.channels.sdk.retry import with_retry, RetryConfig
|
||||||
from yuxi.channels.meta import ChannelMeta
|
from yuxi.channels.meta import ChannelMeta
|
||||||
from yuxi.channels.models import (
|
from yuxi.channels.models import (
|
||||||
ChannelMessage,
|
ChannelMessage,
|
||||||
@ -25,6 +27,7 @@ from yuxi.channels.models import (
|
|||||||
HealthStatus,
|
HealthStatus,
|
||||||
)
|
)
|
||||||
from yuxi.channels.registry import register_builtin_adapter
|
from yuxi.channels.registry import register_builtin_adapter
|
||||||
|
from yuxi.channels.utils.streaming_fallback import SmartCoalesceBuffer
|
||||||
from yuxi.utils.datetime_utils import utc_now_naive
|
from yuxi.utils.datetime_utils import utc_now_naive
|
||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
@ -218,9 +221,23 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
)
|
)
|
||||||
if self._block_streaming_coalesce is None:
|
if self._block_streaming_coalesce is None:
|
||||||
self._block_streaming_coalesce = {}
|
self._block_streaming_coalesce = {}
|
||||||
self._disable_block_streaming = self.config.get("disable_block_streaming", False)
|
|
||||||
|
streaming_cfg = self.config.get("streaming", {})
|
||||||
|
if isinstance(streaming_cfg, dict):
|
||||||
|
block_cfg = streaming_cfg.get("block", {})
|
||||||
|
if isinstance(block_cfg, dict) and block_cfg.get("coalesce"):
|
||||||
|
if "delay_ms" not in self._block_streaming_coalesce:
|
||||||
|
self._block_streaming_coalesce["delay_ms"] = block_cfg.get(
|
||||||
|
"coalesce_idle_ms", block_cfg.get("idle_ms", 1000)
|
||||||
|
)
|
||||||
|
if "min_chars" not in self._block_streaming_coalesce:
|
||||||
|
self._block_streaming_coalesce["min_chars"] = block_cfg.get(
|
||||||
|
"coalesce_min_chars", block_cfg.get("min_chars", 1500)
|
||||||
|
)
|
||||||
|
self._disable_block_streaming = self.config.get("disableBlockStreaming", False)
|
||||||
self._streaming_buffer: dict[str, list[str]] = {}
|
self._streaming_buffer: dict[str, list[str]] = {}
|
||||||
self._streaming_timer: dict[str, asyncio.Task | None] = {}
|
self._streaming_timer: dict[str, asyncio.Task | None] = {}
|
||||||
|
self._coalesce_buffers: dict[str, SmartCoalesceBuffer] = {}
|
||||||
|
|
||||||
self.security_config = IRCSecurityConfig(
|
self.security_config = IRCSecurityConfig(
|
||||||
dm_policy=self.dm_policy,
|
dm_policy=self.dm_policy,
|
||||||
@ -256,6 +273,7 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
self._account_id = config.get("_account_id", "default")
|
self._account_id = config.get("_account_id", "default")
|
||||||
self._extra_caps: list[str] = []
|
self._extra_caps: list[str] = []
|
||||||
self._delivery_tracker: Any = None
|
self._delivery_tracker: Any = None
|
||||||
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="irc")
|
||||||
|
|
||||||
def _resolve_auto_join(self) -> list:
|
def _resolve_auto_join(self) -> list:
|
||||||
channels_env = _env_or("channels", "", self.config)
|
channels_env = _env_or("channels", "", self.config)
|
||||||
@ -505,24 +523,27 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
if self._status != ChannelStatus.CONNECTED:
|
if self._status != ChannelStatus.CONNECTED:
|
||||||
return DeliveryResult(success=False, error="IRC not connected")
|
return DeliveryResult(success=False, error="IRC not connected")
|
||||||
|
|
||||||
try:
|
chat_id = response.identity.channel_chat_id or self._nick
|
||||||
chat_id = response.identity.channel_chat_id or self._nick
|
text = sanitize_outbound_text(response.content)
|
||||||
text = sanitize_outbound_text(response.content)
|
text = convert_markdown_tables(text, self._markdown_table_mode)
|
||||||
text = convert_markdown_tables(text, self._markdown_table_mode)
|
|
||||||
|
|
||||||
reply_to_id = ""
|
reply_to_id = ""
|
||||||
if self._reply_enabled and response.reply_to_message_id:
|
if self._reply_enabled and response.reply_to_message_id:
|
||||||
reply_to_id = response.reply_to_message_id
|
reply_to_id = response.reply_to_message_id
|
||||||
|
|
||||||
await send_privmsg(
|
async def _do_send() -> DeliveryResult:
|
||||||
self._connection.send_line,
|
await with_retry(
|
||||||
chat_id,
|
lambda: send_privmsg(
|
||||||
text,
|
self._connection.send_line,
|
||||||
nick=self._nick,
|
chat_id,
|
||||||
username=self._username,
|
text,
|
||||||
server=self._server,
|
nick=self._nick,
|
||||||
reply_to_id=reply_to_id,
|
username=self._username,
|
||||||
chunker_mode=self._chunker_mode,
|
server=self._server,
|
||||||
|
reply_to_id=reply_to_id,
|
||||||
|
chunker_mode=self._chunker_mode,
|
||||||
|
),
|
||||||
|
config=RetryConfig(max_retries=2, base_delay=0.5, jitter=True),
|
||||||
)
|
)
|
||||||
await self._connection.drain()
|
await self._connection.drain()
|
||||||
self._last_outbound_at = time.monotonic()
|
self._last_outbound_at = time.monotonic()
|
||||||
@ -531,6 +552,10 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
self._sent_message_cache.put(msg_id, chat_id, text)
|
self._sent_message_cache.put(msg_id, chat_id, text)
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._circuit_breaker.call(_do_send)
|
||||||
|
except CircuitBreakerOpenError:
|
||||||
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return DeliveryResult(success=False, error=str(e))
|
return DeliveryResult(success=False, error=str(e))
|
||||||
|
|
||||||
@ -601,9 +626,20 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
if self._status != ChannelStatus.CONNECTED:
|
if self._status != ChannelStatus.CONNECTED:
|
||||||
return DeliveryResult(success=False, error="IRC not connected")
|
return DeliveryResult(success=False, error="IRC not connected")
|
||||||
|
|
||||||
if self._disable_block_streaming:
|
text = sanitize_outbound_text(chunk_text)
|
||||||
text = sanitize_outbound_text(chunk_text)
|
text = convert_markdown_tables(text, self._markdown_table_mode)
|
||||||
text = convert_markdown_tables(text, self._markdown_table_mode)
|
|
||||||
|
coalesce_config = self._block_streaming_coalesce
|
||||||
|
coalesce_enabled = (
|
||||||
|
bool(coalesce_config)
|
||||||
|
and isinstance(coalesce_config, dict)
|
||||||
|
and not self._disable_block_streaming
|
||||||
|
)
|
||||||
|
|
||||||
|
if coalesce_enabled:
|
||||||
|
return await self._send_coalesced(chat_id, message_id, text, finished, coalesce_config)
|
||||||
|
|
||||||
|
if self._disable_block_streaming or not coalesce_enabled:
|
||||||
await send_privmsg(
|
await send_privmsg(
|
||||||
self._connection.send_line,
|
self._connection.send_line,
|
||||||
chat_id,
|
chat_id,
|
||||||
@ -617,81 +653,103 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
self._last_outbound_at = time.monotonic()
|
self._last_outbound_at = time.monotonic()
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
|
|
||||||
coalesce_config = self._block_streaming_coalesce
|
if finished:
|
||||||
coalesce_delay = coalesce_config.get("delay_ms", 0) / 1000.0 if isinstance(coalesce_config, dict) else 0
|
final_text = text
|
||||||
|
else:
|
||||||
try:
|
ellipsis = "\u2026"
|
||||||
text = sanitize_outbound_text(chunk_text)
|
ellipsis_bytes = len(ellipsis.encode("utf-8"))
|
||||||
text = convert_markdown_tables(text, self._markdown_table_mode)
|
chunk_bytes = text.encode("utf-8")
|
||||||
|
if len(chunk_bytes) < 30:
|
||||||
if coalesce_delay > 0 and not finished:
|
|
||||||
key = f"{chat_id}:{message_id}"
|
|
||||||
self._streaming_buffer.setdefault(key, []).append(text)
|
|
||||||
if self._streaming_timer.get(key):
|
|
||||||
self._streaming_timer[key].cancel() # type: ignore[union-attr]
|
|
||||||
self._streaming_timer[key] = asyncio.create_task(self._flush_coalesced(key, chat_id, coalesce_delay))
|
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
|
if len(chunk_bytes) + ellipsis_bytes > self.text_chunk_limit:
|
||||||
if coalesce_delay > 0 and finished:
|
cut_bytes = self.text_chunk_limit - ellipsis_bytes
|
||||||
key = f"{chat_id}:{message_id}"
|
final_text = _truncate_utf8(chunk_bytes, cut_bytes) + ellipsis
|
||||||
buffered = self._streaming_buffer.pop(key, [])
|
|
||||||
timer = self._streaming_timer.pop(key, None)
|
|
||||||
if timer:
|
|
||||||
timer.cancel()
|
|
||||||
text = "".join(buffered) + text if buffered else text
|
|
||||||
|
|
||||||
if finished:
|
|
||||||
final_text = text
|
|
||||||
else:
|
else:
|
||||||
ellipsis = "\u2026"
|
final_text = text + ellipsis
|
||||||
ellipsis_bytes = len(ellipsis.encode("utf-8"))
|
|
||||||
chunk_bytes = text.encode("utf-8")
|
|
||||||
if len(chunk_bytes) < 30:
|
|
||||||
return DeliveryResult(success=True)
|
|
||||||
if len(chunk_bytes) + ellipsis_bytes > self.text_chunk_limit:
|
|
||||||
cut_bytes = self.text_chunk_limit - ellipsis_bytes
|
|
||||||
final_text = _truncate_utf8(chunk_bytes, cut_bytes) + ellipsis
|
|
||||||
else:
|
|
||||||
final_text = text + ellipsis
|
|
||||||
|
|
||||||
await send_privmsg(
|
await send_privmsg(
|
||||||
self._connection.send_line,
|
self._connection.send_line,
|
||||||
chat_id,
|
chat_id,
|
||||||
final_text,
|
final_text,
|
||||||
nick=self._nick,
|
nick=self._nick,
|
||||||
username=self._username,
|
username=self._username,
|
||||||
server=self._server,
|
server=self._server,
|
||||||
chunker_mode=self._chunker_mode,
|
chunker_mode=self._chunker_mode,
|
||||||
|
)
|
||||||
|
await self._connection.drain()
|
||||||
|
self._last_outbound_at = time.monotonic()
|
||||||
|
if finished and message_id:
|
||||||
|
self._sent_message_cache.put(message_id, chat_id, final_text)
|
||||||
|
return DeliveryResult(success=True)
|
||||||
|
|
||||||
|
async def _send_coalesced(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
text: str,
|
||||||
|
finished: bool,
|
||||||
|
coalesce_config: dict,
|
||||||
|
) -> DeliveryResult:
|
||||||
|
key = f"{chat_id}:{message_id}"
|
||||||
|
coalesce_delay = coalesce_config.get("delay_ms", 1000) / 1000.0
|
||||||
|
min_chars = coalesce_config.get("min_chars", 1500)
|
||||||
|
|
||||||
|
if key not in self._coalesce_buffers:
|
||||||
|
self._coalesce_buffers[key] = SmartCoalesceBuffer(
|
||||||
|
min_chars=min_chars,
|
||||||
|
idle_ms=int(coalesce_delay * 1000),
|
||||||
|
max_wait_ms=5000,
|
||||||
)
|
)
|
||||||
await self._connection.drain()
|
|
||||||
self._last_outbound_at = time.monotonic()
|
buf = self._coalesce_buffers[key]
|
||||||
if finished and message_id:
|
|
||||||
self._sent_message_cache.put(message_id, chat_id, final_text)
|
if finished:
|
||||||
|
buf.feed(text)
|
||||||
|
result = buf.flush()
|
||||||
|
self._coalesce_buffers.pop(key, None)
|
||||||
|
self._streaming_timer.pop(key, None)
|
||||||
|
if result:
|
||||||
|
await self._send_irc_text(chat_id, result)
|
||||||
|
if message_id:
|
||||||
|
self._sent_message_cache.put(message_id, chat_id, result)
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
except Exception as e:
|
|
||||||
return DeliveryResult(success=False, error=str(e))
|
|
||||||
|
|
||||||
async def _flush_coalesced(self, key: str, chat_id: str, delay: float) -> None:
|
result = buf.feed(text)
|
||||||
await asyncio.sleep(delay)
|
if result:
|
||||||
buffered = self._streaming_buffer.pop(key, None)
|
self._coalesce_buffers.pop(key, None)
|
||||||
self._streaming_timer.pop(key, None)
|
await self._send_irc_text(chat_id, result)
|
||||||
if not buffered:
|
return DeliveryResult(success=True)
|
||||||
return
|
|
||||||
combined = "".join(buffered)
|
if key not in self._streaming_timer or (self._streaming_timer.get(key) and self._streaming_timer[key].done()):
|
||||||
try:
|
if key in self._streaming_timer and self._streaming_timer[key] and not self._streaming_timer[key].done():
|
||||||
await send_privmsg(
|
self._streaming_timer[key].cancel()
|
||||||
self._connection.send_line,
|
self._streaming_timer[key] = asyncio.create_task(
|
||||||
chat_id,
|
self._flush_coalesce_buffer(key, chat_id, coalesce_delay)
|
||||||
combined,
|
|
||||||
nick=self._nick,
|
|
||||||
username=self._username,
|
|
||||||
server=self._server,
|
|
||||||
chunker_mode=self._chunker_mode,
|
|
||||||
)
|
)
|
||||||
await self._connection.drain()
|
return DeliveryResult(success=True)
|
||||||
self._last_outbound_at = time.monotonic()
|
|
||||||
except Exception as e:
|
async def _flush_coalesce_buffer(self, key: str, chat_id: str, delay: float) -> None:
|
||||||
logger.warning(f"IRC coalesce flush failed: {e}")
|
await asyncio.sleep(delay)
|
||||||
|
buf = self._coalesce_buffers.pop(key, None)
|
||||||
|
self._streaming_timer.pop(key, None)
|
||||||
|
if buf is None:
|
||||||
|
return
|
||||||
|
flushed = buf.flush()
|
||||||
|
if flushed:
|
||||||
|
await self._send_irc_text(chat_id, flushed)
|
||||||
|
|
||||||
|
async def _send_irc_text(self, chat_id: str, text: str) -> None:
|
||||||
|
await send_privmsg(
|
||||||
|
self._connection.send_line,
|
||||||
|
chat_id,
|
||||||
|
text,
|
||||||
|
nick=self._nick,
|
||||||
|
username=self._username,
|
||||||
|
server=self._server,
|
||||||
|
chunker_mode=self._chunker_mode,
|
||||||
|
)
|
||||||
|
await self._connection.drain()
|
||||||
|
self._last_outbound_at = time.monotonic()
|
||||||
|
|
||||||
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
||||||
return {"nick": resolve_channel_user_id(channel_user_id)}
|
return {"nick": resolve_channel_user_id(channel_user_id)}
|
||||||
@ -742,6 +800,10 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
channel = args[0] if args else ""
|
channel = args[0] if args else ""
|
||||||
nick = extract_nick(prefix)
|
nick = extract_nick(prefix)
|
||||||
self._channel_runtime.handle_join(channel, nick)
|
self._channel_runtime.handle_join(channel, nick)
|
||||||
|
if len(args) >= 3:
|
||||||
|
account = args[1] if len(args) > 1 else None
|
||||||
|
realname = args[2] if len(args) > 2 else None
|
||||||
|
logger.debug(f"IRC extended-join: {nick} -> {channel} (account={account}, realname={realname})")
|
||||||
|
|
||||||
elif command == "PART":
|
elif command == "PART":
|
||||||
channel = args[0] if args else ""
|
channel = args[0] if args else ""
|
||||||
@ -807,7 +869,17 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
|
|
||||||
def _send_cap_and_nickuser(self) -> None:
|
def _send_cap_and_nickuser(self) -> None:
|
||||||
self._connection.send_line("CAP LS 302")
|
self._connection.send_line("CAP LS 302")
|
||||||
caps = ["server-time"]
|
caps = [
|
||||||
|
"server-time",
|
||||||
|
"account-tag",
|
||||||
|
"message-ids",
|
||||||
|
"account-notify",
|
||||||
|
"away-notify",
|
||||||
|
"extended-join",
|
||||||
|
"multi-prefix",
|
||||||
|
"invite-notify",
|
||||||
|
"cap-notify",
|
||||||
|
]
|
||||||
if self._use_sasl:
|
if self._use_sasl:
|
||||||
caps.append("sasl")
|
caps.append("sasl")
|
||||||
caps.extend(self._extra_caps)
|
caps.extend(self._extra_caps)
|
||||||
@ -879,7 +951,17 @@ class IRCAdapter(BaseChannelAdapter):
|
|||||||
logger.info(f"IRC joined channels: {self._channel_runtime.joined_channels}")
|
logger.info(f"IRC joined channels: {self._channel_runtime.joined_channels}")
|
||||||
|
|
||||||
async def _on_reconnect_handler(self) -> None:
|
async def _on_reconnect_handler(self) -> None:
|
||||||
caps_to_request = ["server-time"]
|
caps_to_request = [
|
||||||
|
"server-time",
|
||||||
|
"account-tag",
|
||||||
|
"message-ids",
|
||||||
|
"account-notify",
|
||||||
|
"away-notify",
|
||||||
|
"extended-join",
|
||||||
|
"multi-prefix",
|
||||||
|
"invite-notify",
|
||||||
|
"cap-notify",
|
||||||
|
]
|
||||||
if self._use_sasl:
|
if self._use_sasl:
|
||||||
caps_to_request.append("sasl")
|
caps_to_request.append("sasl")
|
||||||
caps_to_request.extend(self._extra_caps)
|
caps_to_request.extend(self._extra_caps)
|
||||||
|
|||||||
@ -25,7 +25,15 @@ class ChannelRuntime:
|
|||||||
|
|
||||||
def handle_names(self, channel: str, names_str: str) -> None:
|
def handle_names(self, channel: str, names_str: str) -> None:
|
||||||
channel_lower = channel.lower()
|
channel_lower = channel.lower()
|
||||||
members = {name.lstrip("@+") for name in names_str.split() if name}
|
members = set()
|
||||||
|
for raw_name in names_str.split():
|
||||||
|
if not raw_name:
|
||||||
|
continue
|
||||||
|
name = raw_name
|
||||||
|
while name and name[0] in "@+%~&!":
|
||||||
|
name = name[1:]
|
||||||
|
if name:
|
||||||
|
members.add(name)
|
||||||
self._channel_members[channel_lower] = members
|
self._channel_members[channel_lower] = members
|
||||||
logger.debug(f"IRC NAMES {channel_lower}: {len(members)} members")
|
logger.debug(f"IRC NAMES {channel_lower}: {len(members)} members")
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
_CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
_CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
||||||
"server": {
|
"server": {
|
||||||
"type": "str",
|
"type": "str",
|
||||||
@ -181,7 +180,7 @@ _CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
|||||||
"text_chunk_limit": {
|
"text_chunk_limit": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"required": False,
|
"required": False,
|
||||||
"default": 512,
|
"default": 350,
|
||||||
"description": "Maximum bytes per IRC message chunk",
|
"description": "Maximum bytes per IRC message chunk",
|
||||||
},
|
},
|
||||||
"reply_enabled": {
|
"reply_enabled": {
|
||||||
@ -202,6 +201,12 @@ _CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
|||||||
"default": {},
|
"default": {},
|
||||||
"description": "Block streaming coalesce config (delay_ms)",
|
"description": "Block streaming coalesce config (delay_ms)",
|
||||||
},
|
},
|
||||||
|
"disableBlockStreaming": {
|
||||||
|
"type": "bool",
|
||||||
|
"required": False,
|
||||||
|
"default": False,
|
||||||
|
"description": "Disable block streaming mode, send each chunk immediately",
|
||||||
|
},
|
||||||
"markdown.tableMode": {
|
"markdown.tableMode": {
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"required": False,
|
"required": False,
|
||||||
|
|||||||
@ -2,5 +2,4 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .accounts import has_configured_state, is_configured
|
from .accounts import has_configured_state, is_configured
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["has_configured_state", "is_configured"]
|
__all__ = ["has_configured_state", "is_configured"]
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -9,6 +10,14 @@ from typing import Any
|
|||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_address(host: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class IRCConnection:
|
class IRCConnection:
|
||||||
PING_TIMEOUT = 30
|
PING_TIMEOUT = 30
|
||||||
RECONNECT_DELAY = 5
|
RECONNECT_DELAY = 5
|
||||||
@ -26,6 +35,8 @@ class IRCConnection:
|
|||||||
self._writer: asyncio.StreamWriter | None = None
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
self._keepalive_task: asyncio.Task | None = None
|
self._keepalive_task: asyncio.Task | None = None
|
||||||
self._on_reconnect: Callable[[], None] | None = None
|
self._on_reconnect: Callable[[], None] | None = None
|
||||||
|
self._srv_host: str | None = None
|
||||||
|
self._srv_port: int | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reader(self) -> asyncio.StreamReader | None:
|
def reader(self) -> asyncio.StreamReader | None:
|
||||||
@ -39,6 +50,19 @@ class IRCConnection:
|
|||||||
self._on_reconnect = callback
|
self._on_reconnect = callback
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
|
host = self._server
|
||||||
|
port = self._port
|
||||||
|
|
||||||
|
if not self._proxy and not _is_ip_address(host):
|
||||||
|
srv_result = await resolve_srv_record(host)
|
||||||
|
if srv_result:
|
||||||
|
self._srv_host, self._srv_port = srv_result
|
||||||
|
logger.info(
|
||||||
|
f"IRC DNS SRV resolved: {host} -> {self._srv_host}:{self._srv_port}"
|
||||||
|
)
|
||||||
|
host = self._srv_host
|
||||||
|
port = self._srv_port
|
||||||
|
|
||||||
if self._proxy:
|
if self._proxy:
|
||||||
await self._connect_via_proxy()
|
await self._connect_via_proxy()
|
||||||
elif self._use_tls:
|
elif self._use_tls:
|
||||||
@ -46,19 +70,19 @@ class IRCConnection:
|
|||||||
ctx.check_hostname = True
|
ctx.check_hostname = True
|
||||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||||
self._reader, self._writer = await asyncio.open_connection(
|
self._reader, self._writer = await asyncio.open_connection(
|
||||||
host=self._server,
|
host=host,
|
||||||
port=self._port,
|
port=port,
|
||||||
ssl=ctx,
|
ssl=ctx,
|
||||||
server_hostname=self._server,
|
server_hostname=self._server,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._reader, self._writer = await asyncio.open_connection(
|
self._reader, self._writer = await asyncio.open_connection(
|
||||||
host=self._server,
|
host=host,
|
||||||
port=self._port,
|
port=port,
|
||||||
)
|
)
|
||||||
self._reconnect_attempts = 0
|
self._reconnect_attempts = 0
|
||||||
self._last_pong = time.monotonic()
|
self._last_pong = time.monotonic()
|
||||||
logger.info(f"IRC TCP connected: {self._server}:{self._port} (TLS={self._use_tls})")
|
logger.info(f"IRC TCP connected: {host}:{port} (TLS={self._use_tls})")
|
||||||
|
|
||||||
async def _connect_via_proxy(self) -> None:
|
async def _connect_via_proxy(self) -> None:
|
||||||
proxy = self._proxy or {}
|
proxy = self._proxy or {}
|
||||||
|
|||||||
@ -10,9 +10,9 @@ async def handle_ctcp(
|
|||||||
send_fn,
|
send_fn,
|
||||||
source_nick: str,
|
source_nick: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> None:
|
) -> bool:
|
||||||
if not is_ctcp_message(text):
|
if not is_ctcp_message(text):
|
||||||
return
|
return False
|
||||||
|
|
||||||
command, args = parse_ctcp(text)
|
command, args = parse_ctcp(text)
|
||||||
logger.debug(f"IRC CTCP from {source_nick}: {command} {args}")
|
logger.debug(f"IRC CTCP from {source_nick}: {command} {args}")
|
||||||
@ -29,8 +29,21 @@ async def handle_ctcp(
|
|||||||
reply = format_ctcp("TIME", f"{_time.time():.0f}")
|
reply = format_ctcp("TIME", f"{_time.time():.0f}")
|
||||||
send_raw_line(send_fn, f"NOTICE {source_nick} :{reply}")
|
send_raw_line(send_fn, f"NOTICE {source_nick} :{reply}")
|
||||||
elif command == "CLIENTINFO":
|
elif command == "CLIENTINFO":
|
||||||
reply = format_ctcp("CLIENTINFO", "PING VERSION TIME")
|
reply = format_ctcp("CLIENTINFO", "PING VERSION TIME ACTION")
|
||||||
send_raw_line(send_fn, f"NOTICE {source_nick} :{reply}")
|
send_raw_line(send_fn, f"NOTICE {source_nick} :{reply}")
|
||||||
|
elif command == "ACTION":
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ctcp_action(text: str) -> str | None:
|
||||||
|
if not is_ctcp_message(text):
|
||||||
|
return None
|
||||||
|
command, args = parse_ctcp(text)
|
||||||
|
if command == "ACTION":
|
||||||
|
return args
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def supports_ctcp() -> bool:
|
def supports_ctcp() -> bool:
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import warnings
|
import warnings
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,8 @@ from .commands import (
|
|||||||
has_control_command,
|
has_control_command,
|
||||||
parse_command,
|
parse_command,
|
||||||
)
|
)
|
||||||
from .ctcp import handle_ctcp
|
from .ctcp import extract_ctcp_action, handle_ctcp
|
||||||
from .protocol import extract_nick, is_ctcp_message, parse_irc_line, parse_irc_tags_dict
|
from .protocol import extract_nick, is_ctcp_message, parse_irc_line, parse_irc_prefix, parse_irc_tags_dict
|
||||||
from .sanitize import sanitize_inbound_text
|
from .sanitize import sanitize_inbound_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -104,6 +104,22 @@ class IRCMonitor:
|
|||||||
self._handle_monitor_reply(command, args)
|
self._handle_monitor_reply(command, args)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if command == "ACCOUNT":
|
||||||
|
self._handle_account_notify(prefix, args)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command == "AWAY":
|
||||||
|
self._handle_away_notify(prefix, args)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command == "INVITE":
|
||||||
|
self._handle_invite_notify(prefix, args)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command == "CAP":
|
||||||
|
self._handle_cap_notify(args)
|
||||||
|
continue
|
||||||
|
|
||||||
if command == "PRIVMSG":
|
if command == "PRIVMSG":
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
continue
|
continue
|
||||||
@ -118,8 +134,13 @@ class IRCMonitor:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if is_ctcp_message(text):
|
if is_ctcp_message(text):
|
||||||
await handle_ctcp(self._adapter.send_line, sender_nick, text)
|
action_text = extract_ctcp_action(text)
|
||||||
continue
|
if action_text is not None:
|
||||||
|
text = f"* {sender_nick} {action_text}"
|
||||||
|
logger.debug(f"IRC CTCP ACTION from {sender_nick}: {action_text}")
|
||||||
|
else:
|
||||||
|
await handle_ctcp(self._adapter.send_line, sender_nick, text)
|
||||||
|
continue
|
||||||
|
|
||||||
if sender_nick == self._adapter.nick:
|
if sender_nick == self._adapter.nick:
|
||||||
self._handle_echo_message(tags, msgid, text)
|
self._handle_echo_message(tags, msgid, text)
|
||||||
@ -261,6 +282,49 @@ class IRCMonitor:
|
|||||||
case "734":
|
case "734":
|
||||||
logger.warning(f"MONITOR list full: {targets}")
|
logger.warning(f"MONITOR list full: {targets}")
|
||||||
|
|
||||||
|
def _handle_account_notify(self, prefix: str | None, args: list[str]) -> None:
|
||||||
|
if len(args) < 2:
|
||||||
|
return
|
||||||
|
nick = args[0]
|
||||||
|
account = args[1].lstrip(":")
|
||||||
|
if account == "*":
|
||||||
|
logger.debug(f"IRC account-notify: {nick} logged out")
|
||||||
|
else:
|
||||||
|
logger.debug(f"IRC account-notify: {nick} logged in as {account}")
|
||||||
|
|
||||||
|
def _handle_away_notify(self, prefix: str | None, args: list[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
return
|
||||||
|
nick = extract_nick(prefix)
|
||||||
|
if len(args) >= 1 and args[0].startswith(":"):
|
||||||
|
away_msg = " ".join(args).lstrip(":")
|
||||||
|
logger.debug(f"IRC away-notify: {nick} is away ({away_msg})")
|
||||||
|
else:
|
||||||
|
logger.debug(f"IRC away-notify: {nick} is back")
|
||||||
|
|
||||||
|
def _handle_invite_notify(self, prefix: str | None, args: list[str]) -> None:
|
||||||
|
if len(args) < 2:
|
||||||
|
return
|
||||||
|
inviter = extract_nick(prefix)
|
||||||
|
target = args[0]
|
||||||
|
channel = args[1].lstrip(":")
|
||||||
|
logger.debug(f"IRC invite-notify: {inviter} invited {target} to {channel}")
|
||||||
|
|
||||||
|
def _handle_cap_notify(self, args: list[str]) -> None:
|
||||||
|
if len(args) < 3:
|
||||||
|
return
|
||||||
|
target = args[0]
|
||||||
|
subcmd = args[1]
|
||||||
|
cap_name = args[2].lstrip(":")
|
||||||
|
if subcmd == "NEW":
|
||||||
|
logger.info(f"IRC cap-notify: {target} added CAP {cap_name}")
|
||||||
|
elif subcmd == "DEL":
|
||||||
|
logger.info(f"IRC cap-notify: {target} removed CAP {cap_name}")
|
||||||
|
elif subcmd == "ACK":
|
||||||
|
pass
|
||||||
|
elif subcmd == "NAK":
|
||||||
|
logger.warning(f"IRC cap-notify: {target} NAK for {cap_name}")
|
||||||
|
|
||||||
def _add_dedup(self, msgid: str) -> None:
|
def _add_dedup(self, msgid: str) -> None:
|
||||||
if len(self._dedup_window) >= self._dedup_max:
|
if len(self._dedup_window) >= self._dedup_max:
|
||||||
self._dedup_window.clear()
|
self._dedup_window.clear()
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class IRCThreadSimulator:
|
||||||
|
def __init__(self, context_size: int = 10):
|
||||||
|
self._channel_contexts: dict[str, list[dict]] = {}
|
||||||
|
self._dm_contexts: dict[str, list[dict]] = {}
|
||||||
|
self._context_size = context_size
|
||||||
|
|
||||||
|
def add_message(self, channel: str, sender: str, content: str, is_dm: bool = False) -> None:
|
||||||
|
key = sender if is_dm else channel
|
||||||
|
contexts = self._dm_contexts if is_dm else self._channel_contexts
|
||||||
|
|
||||||
|
window = contexts.get(key)
|
||||||
|
if window is None:
|
||||||
|
window = []
|
||||||
|
contexts[key] = window
|
||||||
|
|
||||||
|
window.append(
|
||||||
|
{
|
||||||
|
"sender": sender,
|
||||||
|
"content": content,
|
||||||
|
"timestamp": datetime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(window) > self._context_size:
|
||||||
|
window.pop(0)
|
||||||
|
|
||||||
|
def get_context_text(self, channel: str, is_dm: bool = False) -> str:
|
||||||
|
key = channel if is_dm else channel
|
||||||
|
contexts = self._dm_contexts if is_dm else self._channel_contexts
|
||||||
|
|
||||||
|
window = contexts.get(key, [])
|
||||||
|
lines = [f"<{msg['sender']}> {msg['content']}" for msg in window]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_reply_prefix(target_nick: str) -> str:
|
||||||
|
return f"@{target_nick}: "
|
||||||
|
|
||||||
|
def clear_context(self, channel: str) -> None:
|
||||||
|
self._channel_contexts.pop(channel, None)
|
||||||
|
self._dm_contexts.pop(channel, None)
|
||||||
Loading…
Reference in New Issue
Block a user