refactor(twitch): 重构Twitch适配器,新增Helix API支持与功能优化
本次提交对Twitch适配器进行了全面升级与优化: 1. 修复UTF8截断逻辑,避免越界访问 2. 重构群聊策略配置,标准化mention相关规则 3. 新增消息缓存管理器,支持通过消息ID查询已发送消息 4. 更新配置schema,新增prefer_helix_send开关和deprecated策略自动转换 5. 新增CLEARMSG和ROOMSTATE IRC消息解析,补充事件订阅支持 6. 优化令牌刷新逻辑,增加重试机制与退避策略 7. 新增Helix API聊天消息发送、删除和公告功能 8. 扩展事件订阅类型,新增直播状态、频道更新等系统事件 9. 新增reply、delete_message、announcement等动作支持,完善操作能力 10. 重构流式发送逻辑,新增进度指示器和配置项 11. 优化重连策略,增加指数退避与计数重置
This commit is contained in:
parent
31736aef74
commit
18d1ea2aac
@ -5,8 +5,6 @@ from typing import Any
|
|||||||
from yuxi.channels.models import ChannelIdentity, ChannelResponse, DeliveryResult
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse, DeliveryResult
|
||||||
from yuxi.channels.protocols.actions import ActionContext
|
from yuxi.channels.protocols.actions import ActionContext
|
||||||
|
|
||||||
_ACTION_HANDLERS: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_response(ctx: ActionContext, adapter: Any, content_key: str = "content") -> ChannelResponse | None:
|
def _build_response(ctx: ActionContext, adapter: Any, content_key: str = "content") -> ChannelResponse | None:
|
||||||
content = ctx.get(content_key, ctx.get("media_url", ""))
|
content = ctx.get(content_key, ctx.get("media_url", ""))
|
||||||
@ -26,18 +24,97 @@ async def handle_action(ctx: ActionContext, adapter: Any) -> DeliveryResult:
|
|||||||
response = _build_response(ctx, adapter, "content")
|
response = _build_response(ctx, adapter, "content")
|
||||||
if response is None:
|
if response is None:
|
||||||
return DeliveryResult(success=False, error="missing chat_id or content")
|
return DeliveryResult(success=False, error="missing chat_id or content")
|
||||||
|
if adapter.config.get("prefer_helix_send", False):
|
||||||
|
result = await adapter._send_via_helix(adapter.format_outbound(response)["target"], response.content)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
return await adapter.send(response)
|
return await adapter.send(response)
|
||||||
|
|
||||||
if ctx.action == "send_media":
|
if ctx.action == "send_media":
|
||||||
response = _build_response(ctx, adapter, "media_url")
|
response = _build_response(ctx, adapter, "media_url")
|
||||||
if response is None:
|
if response is None:
|
||||||
return DeliveryResult(success=False, error="missing chat_id or media_url")
|
return DeliveryResult(success=False, error="missing chat_id or media_url")
|
||||||
return await adapter.send_media(ctx.chat_id, "url", response.content)
|
return await adapter.send_media(ctx.chat_id, "url", response.content)
|
||||||
|
|
||||||
|
if ctx.action == "reply":
|
||||||
|
chat_id = ctx.get("chat_id", "")
|
||||||
|
content = ctx.get("content", "")
|
||||||
|
reply_to_msg_id = ctx.get("reply_to_msg_id", "")
|
||||||
|
if not chat_id or not content:
|
||||||
|
return DeliveryResult(success=False, error="missing chat_id or content")
|
||||||
|
if not reply_to_msg_id:
|
||||||
|
return DeliveryResult(success=False, error="missing reply_to_msg_id")
|
||||||
|
result = await adapter._send_via_helix(chat_id, content, reply_msg_id=reply_to_msg_id)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
|
prefixed = f"@{ctx.get('reply_to_username', 'user')} {content}"
|
||||||
|
response = ChannelResponse(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=adapter.channel_type,
|
||||||
|
channel_user_id="",
|
||||||
|
channel_chat_id=chat_id,
|
||||||
|
),
|
||||||
|
content=prefixed,
|
||||||
|
)
|
||||||
|
return await adapter.send(response)
|
||||||
|
|
||||||
|
if ctx.action in ("unsend", "delete_message"):
|
||||||
|
chat_id = ctx.get("chat_id", "")
|
||||||
|
message_id = ctx.get("message_id", "")
|
||||||
|
if not chat_id or not adapter._helix:
|
||||||
|
return DeliveryResult(success=False, error="missing chat_id or helix not available")
|
||||||
|
if not message_id:
|
||||||
|
cached = adapter._outbound_cache.find_by_message_id(ctx.get("content", ""))
|
||||||
|
if cached:
|
||||||
|
message_id = cached.get("message_id", "")
|
||||||
|
if not message_id:
|
||||||
|
return DeliveryResult(success=False, error="missing message_id for message deletion")
|
||||||
|
|
||||||
|
broadcaster_id = await adapter._resolve_broadcaster_id(chat_id)
|
||||||
|
if not broadcaster_id:
|
||||||
|
return DeliveryResult(success=False, error="broadcaster_not_found")
|
||||||
|
|
||||||
|
moderator_id = adapter._bot_user_id
|
||||||
|
if not moderator_id:
|
||||||
|
return DeliveryResult(success=False, error="bot_user_id_unknown")
|
||||||
|
|
||||||
|
success = await adapter._helix.delete_chat_message(
|
||||||
|
broadcaster_id=broadcaster_id,
|
||||||
|
moderator_id=moderator_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return DeliveryResult(success=True, metadata={"messageId": message_id})
|
||||||
|
return DeliveryResult(success=False, error="helix_delete_failed")
|
||||||
|
|
||||||
|
if ctx.action == "announcement":
|
||||||
|
chat_id = ctx.get("chat_id", "")
|
||||||
|
content = ctx.get("content", "")
|
||||||
|
color = ctx.get("color", "primary")
|
||||||
|
if not chat_id or not content or not adapter._helix:
|
||||||
|
return DeliveryResult(success=False, error="missing chat_id or content or helix not available")
|
||||||
|
|
||||||
|
broadcaster_id = await adapter._resolve_broadcaster_id(chat_id)
|
||||||
|
if not broadcaster_id:
|
||||||
|
return DeliveryResult(success=False, error="broadcaster_not_found")
|
||||||
|
|
||||||
|
moderator_id = adapter._bot_user_id
|
||||||
|
if not moderator_id:
|
||||||
|
return DeliveryResult(success=False, error="bot_user_id_unknown")
|
||||||
|
|
||||||
|
success = await adapter._helix.send_chat_announcement(
|
||||||
|
broadcaster_id=broadcaster_id,
|
||||||
|
moderator_id=moderator_id,
|
||||||
|
message=content,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return DeliveryResult(success=True)
|
||||||
|
return DeliveryResult(success=False, error="announcement_failed")
|
||||||
|
|
||||||
unsupported_hints: dict[str, str] = {
|
unsupported_hints: dict[str, str] = {
|
||||||
"reply": "Twitch IRC does not support message replies",
|
|
||||||
"edit": "Twitch IRC does not support message editing",
|
"edit": "Twitch IRC does not support message editing",
|
||||||
"unsend": "Twitch IRC does not support message deletion",
|
|
||||||
"delete_message": "Twitch IRC does not support message deletion",
|
|
||||||
"edit_message": "Twitch IRC does not support message editing",
|
"edit_message": "Twitch IRC does not support message editing",
|
||||||
"reactions": "Twitch IRC does not support reactions",
|
"reactions": "Twitch IRC does not support reactions",
|
||||||
"send_reaction": "Twitch IRC does not support reactions",
|
"send_reaction": "Twitch IRC does not support reactions",
|
||||||
@ -51,7 +128,7 @@ async def handle_action(ctx: ActionContext, adapter: Any) -> DeliveryResult:
|
|||||||
|
|
||||||
|
|
||||||
def supports_action(action: str) -> bool:
|
def supports_action(action: str) -> bool:
|
||||||
return action in {"send", "send_media"}
|
return action in {"send", "send_media", "reply", "unsend", "delete_message", "announcement"}
|
||||||
|
|
||||||
|
|
||||||
def describe_message_tool() -> dict[str, Any]:
|
def describe_message_tool() -> dict[str, Any]:
|
||||||
@ -70,6 +147,37 @@ def describe_message_tool() -> dict[str, Any]:
|
|||||||
"media_url": {"type": "string", "description": "Media URL to send"},
|
"media_url": {"type": "string", "description": "Media URL to send"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"reply": {
|
||||||
|
"description": "Reply to a message in a Twitch channel via Helix API",
|
||||||
|
"parameters": {
|
||||||
|
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||||
|
"content": {"type": "string", "description": "Reply text content"},
|
||||||
|
"reply_to_msg_id": {"type": "string", "description": "Message ID to reply to"},
|
||||||
|
"reply_to_username": {"type": "string", "description": "Username being replied to (for IRC fallback)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"unsend": {
|
||||||
|
"description": "Delete a message from a Twitch channel via Helix API",
|
||||||
|
"parameters": {
|
||||||
|
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||||
|
"message_id": {"type": "string", "description": "Message ID to delete"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"delete_message": {
|
||||||
|
"description": "Delete a message from a Twitch channel via Helix API",
|
||||||
|
"parameters": {
|
||||||
|
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||||
|
"message_id": {"type": "string", "description": "Message ID to delete"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"announcement": {
|
||||||
|
"description": "Send a colored announcement to a Twitch channel",
|
||||||
|
"parameters": {
|
||||||
|
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||||
|
"content": {"type": "string", "description": "Announcement text"},
|
||||||
|
"color": {"type": "string", "description": "Announcement color: blue/green/orange/purple/primary"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -86,19 +194,16 @@ def resolve_execution_mode(action: str) -> str:
|
|||||||
|
|
||||||
def get_action_stats() -> dict[str, list[str]]:
|
def get_action_stats() -> dict[str, list[str]]:
|
||||||
return {
|
return {
|
||||||
"implemented": ["send", "send_media"],
|
"implemented": ["send", "send_media", "reply", "unsend", "delete_message", "announcement"],
|
||||||
"planned": [],
|
"planned": [],
|
||||||
"unsupported": [
|
"unsupported": [
|
||||||
"reply",
|
|
||||||
"edit",
|
"edit",
|
||||||
"unsend",
|
|
||||||
"reactions",
|
"reactions",
|
||||||
"polls",
|
"polls",
|
||||||
"native_commands",
|
"native_commands",
|
||||||
"pin",
|
"pin",
|
||||||
"unpin",
|
"unpin",
|
||||||
"send_reaction",
|
"send_reaction",
|
||||||
"delete_message",
|
|
||||||
"edit_message",
|
"edit_message",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,11 @@ from typing import Any, ClassVar
|
|||||||
|
|
||||||
from yuxi.channels.base import BaseChannelAdapter
|
from yuxi.channels.base import BaseChannelAdapter
|
||||||
from yuxi.channels.capabilities import ChannelCapabilities
|
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 (
|
from yuxi.channels.exceptions import (
|
||||||
ChannelAuthenticationError,
|
ChannelAuthenticationError,
|
||||||
)
|
)
|
||||||
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
||||||
|
from yuxi.channels.meta import ChannelMeta
|
||||||
from yuxi.channels.models import (
|
from yuxi.channels.models import (
|
||||||
ChannelMessage,
|
ChannelMessage,
|
||||||
ChannelResponse,
|
ChannelResponse,
|
||||||
@ -26,28 +26,38 @@ from yuxi.channels.models import (
|
|||||||
from yuxi.channels.registry import register_builtin_adapter
|
from yuxi.channels.registry import register_builtin_adapter
|
||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
|
from .actions import (
|
||||||
|
describe_message_tool,
|
||||||
|
extract_target_from_args,
|
||||||
|
handle_action,
|
||||||
|
resolve_execution_mode,
|
||||||
|
)
|
||||||
|
from .actions import (
|
||||||
|
supports_action as _supports_action,
|
||||||
|
)
|
||||||
from .auth_provider import create_auth_provider
|
from .auth_provider import create_auth_provider
|
||||||
from .deduplicator import MessageDeduplicator
|
from .deduplicator import MessageDeduplicator
|
||||||
from .eventsub_client import EventSubListener
|
from .eventsub_client import EventSubListener
|
||||||
from .helix import HelixClient
|
from .helix import HelixClient
|
||||||
from .irc_parser import parse_badges, parse_irc_line, extract_nick
|
from .irc_parser import extract_nick, parse_badges, parse_irc_line
|
||||||
from .markdown_utils import strip_twitch_markdown
|
from .markdown_utils import strip_twitch_markdown
|
||||||
|
from .normalizer import normalize_irc_message, resolve_mentions
|
||||||
from .outbound_cache import OutboundCacheManager
|
from .outbound_cache import OutboundCacheManager
|
||||||
from .pairing import PairingStore, check_pairing_policy
|
from .pairing import PairingStore, check_pairing_policy
|
||||||
from .actions import (
|
|
||||||
handle_action,
|
|
||||||
supports_action as _supports_action,
|
|
||||||
describe_message_tool,
|
|
||||||
extract_target_from_args,
|
|
||||||
resolve_execution_mode,
|
|
||||||
)
|
|
||||||
from .normalizer import normalize_irc_message, resolve_mentions
|
|
||||||
from .probe import get_app_access_token, validate_token
|
from .probe import get_app_access_token, validate_token
|
||||||
from .rate_limiter import RateLimiter
|
from .rate_limiter import RateLimiter
|
||||||
from .send import format_privmsg_line, format_action_line, format_pong, format_cap_req, find_utf8_cut
|
from .send import find_utf8_cut, format_action_line, format_cap_req, format_pong, format_privmsg_line
|
||||||
from .session import check_allowed_roles, check_group_policy
|
from .session import check_allowed_roles, check_group_policy
|
||||||
from .token_utils import ensure_oauth_prefix
|
from .token_utils import ensure_oauth_prefix
|
||||||
|
|
||||||
|
PROGRESS_BAR_WIDTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_bar(text_len_hint: int, max_chars: int) -> str:
|
||||||
|
ratio = min(text_len_hint / max(max_chars, 1), 1.0)
|
||||||
|
filled = int(ratio * PROGRESS_BAR_WIDTH)
|
||||||
|
return "▓" * filled + "░" * (PROGRESS_BAR_WIDTH - filled)
|
||||||
|
|
||||||
|
|
||||||
@register_builtin_adapter
|
@register_builtin_adapter
|
||||||
class TwitchAdapter(BaseChannelAdapter):
|
class TwitchAdapter(BaseChannelAdapter):
|
||||||
@ -63,15 +73,15 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
delivery_mode: ClassVar[str] = "direct"
|
delivery_mode: ClassVar[str] = "direct"
|
||||||
|
|
||||||
capabilities = ChannelCapabilities(
|
capabilities = ChannelCapabilities(
|
||||||
chat_types=["group"],
|
chat_types=["group", "direct"],
|
||||||
supports_markdown=False,
|
supports_markdown=False,
|
||||||
supports_streaming=True,
|
supports_streaming=True,
|
||||||
streaming_modes=["off", "block"],
|
streaming_modes=["off", "block"],
|
||||||
text_chunk_limit=500,
|
text_chunk_limit=500,
|
||||||
max_media_size_mb=0, # IRC protocol does not support media upload; URLs sent as text
|
max_media_size_mb=0,
|
||||||
reply=False,
|
reply=True,
|
||||||
edit=False,
|
edit=False,
|
||||||
unsend=False,
|
unsend=True,
|
||||||
reactions=False,
|
reactions=False,
|
||||||
polls=False,
|
polls=False,
|
||||||
native_commands=False,
|
native_commands=False,
|
||||||
@ -102,9 +112,11 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
|
|
||||||
self._bot_user_id: str | None = None
|
self._bot_user_id: str | None = None
|
||||||
self._bot_username: str | None = None
|
self._bot_username: str | None = None
|
||||||
|
self._broadcaster_ids: dict[str, str] = {}
|
||||||
|
|
||||||
self._irc_task: asyncio.Task | None = None
|
self._irc_task: asyncio.Task | None = None
|
||||||
self._reconnect_task: asyncio.Task | None = None
|
self._reconnect_task: asyncio.Task | None = None
|
||||||
|
self._reconnect_attempts: int = 0
|
||||||
|
|
||||||
self._deduplicator = MessageDeduplicator()
|
self._deduplicator = MessageDeduplicator()
|
||||||
|
|
||||||
@ -275,6 +287,14 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
if not content.strip():
|
if not content.strip():
|
||||||
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
|
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
|
||||||
|
|
||||||
|
prefer_helix = self.config.get("prefer_helix_send", False)
|
||||||
|
if prefer_helix:
|
||||||
|
target = self.format_outbound(response)["target"]
|
||||||
|
result = await self._send_via_helix(target, content)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
|
logger.info("[Twitch] Helix send failed, falling back to IRC PRIVMSG")
|
||||||
|
|
||||||
async def _do_send():
|
async def _do_send():
|
||||||
target = self.format_outbound(response)["target"]
|
target = self.format_outbound(response)["target"]
|
||||||
chunks = self._split_irc_text(content, target)
|
chunks = self._split_irc_text(content, target)
|
||||||
@ -291,8 +311,19 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
if not chunk.strip():
|
if not chunk.strip():
|
||||||
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
|
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
|
||||||
|
|
||||||
coalesce_min_chars = self.config.get("stream_coalesce_min_chars", 30)
|
stream_config = self.config.get("streaming", {})
|
||||||
coalesce_max_delay_ms = self.config.get("stream_coalesce_max_delay_ms", 0)
|
block_cfg = stream_config.get("block", {}) if isinstance(stream_config, dict) else {}
|
||||||
|
|
||||||
|
coalesce_min_chars = self.config.get(
|
||||||
|
"stream_coalesce_min_chars",
|
||||||
|
block_cfg.get("coalesce_min_chars", 30),
|
||||||
|
)
|
||||||
|
coalesce_max_delay_ms = self.config.get(
|
||||||
|
"stream_coalesce_max_delay_ms",
|
||||||
|
block_cfg.get("coalesce_idle_ms", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
show_progress = stream_config.get("progress_indicator", True)
|
||||||
|
|
||||||
buffer_key = f"{chat_id}:{msg_id}"
|
buffer_key = f"{chat_id}:{msg_id}"
|
||||||
if coalesce_max_delay_ms > 0 and not finished:
|
if coalesce_max_delay_ms > 0 and not finished:
|
||||||
@ -307,12 +338,12 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
buf["chunks"].append(chunk)
|
buf["chunks"].append(chunk)
|
||||||
total = "".join(buf["chunks"])
|
total = "".join(buf["chunks"])
|
||||||
if len(total.encode("utf-8")) >= coalesce_min_chars:
|
if len(total.encode("utf-8")) >= coalesce_min_chars:
|
||||||
return await self._send_with_protection(self._flush_stream_buffer, buffer_key)
|
return await self._send_with_protection(self._flush_stream_buffer, buffer_key, progress=show_progress)
|
||||||
if buf["task"] is None or buf["task"].done():
|
if buf["task"] is None or buf["task"].done():
|
||||||
buf["task"] = asyncio.create_task(self._delayed_flush(buffer_key, coalesce_max_delay_ms / 1000.0))
|
buf["task"] = asyncio.create_task(self._delayed_flush(buffer_key, coalesce_max_delay_ms / 1000.0))
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
elif finished and buffer_key in self._stream_buffers:
|
elif finished and buffer_key in self._stream_buffers:
|
||||||
return await self._send_with_protection(self._flush_stream_buffer, buffer_key)
|
return await self._send_with_protection(self._flush_stream_buffer, buffer_key, progress=show_progress)
|
||||||
else:
|
else:
|
||||||
if finished:
|
if finished:
|
||||||
|
|
||||||
@ -330,8 +361,10 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
silent = self.config.get("silent", False)
|
silent = self.config.get("silent", False)
|
||||||
fmt = format_action_line if silent else format_privmsg_line
|
fmt = format_action_line if silent else format_privmsg_line
|
||||||
prefix_len = len(f"PRIVMSG {chat_id} :")
|
prefix_len = len(f"PRIVMSG {chat_id} :")
|
||||||
suffix = "…"
|
max_chars = 500 - prefix_len
|
||||||
text = chunk[: 510 - prefix_len - len(suffix.encode("utf-8"))] + suffix
|
suffix = _progress_bar(len(chunk), max_chars) if show_progress else "…"
|
||||||
|
suffix_len = len(suffix.encode("utf-8"))
|
||||||
|
text = chunk[: max_chars - suffix_len] + suffix
|
||||||
if not await self._rate_limiter.acquire():
|
if not await self._rate_limiter.acquire():
|
||||||
return DeliveryResult(success=False, error="rate_limit_exceeded")
|
return DeliveryResult(success=False, error="rate_limit_exceeded")
|
||||||
self._writer.write(fmt(chat_id, text).encode("utf-8") + b"\r\n")
|
self._writer.write(fmt(chat_id, text).encode("utf-8") + b"\r\n")
|
||||||
@ -344,7 +377,7 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
await asyncio.sleep(delay_sec)
|
await asyncio.sleep(delay_sec)
|
||||||
await self._flush_stream_buffer(buffer_key)
|
await self._flush_stream_buffer(buffer_key)
|
||||||
|
|
||||||
async def _flush_stream_buffer(self, buffer_key: str) -> DeliveryResult:
|
async def _flush_stream_buffer(self, buffer_key: str, progress: bool = True) -> DeliveryResult:
|
||||||
buf = self._stream_buffers.pop(buffer_key, None)
|
buf = self._stream_buffers.pop(buffer_key, None)
|
||||||
if buf is None:
|
if buf is None:
|
||||||
return DeliveryResult(success=True)
|
return DeliveryResult(success=True)
|
||||||
@ -385,6 +418,45 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
def _record_outbound(self, target: str, content: str) -> None:
|
def _record_outbound(self, target: str, content: str) -> None:
|
||||||
self._outbound_cache.record(target, content)
|
self._outbound_cache.record(target, content)
|
||||||
|
|
||||||
|
async def _resolve_broadcaster_id(self, channel: str) -> str | None:
|
||||||
|
channel_name = channel.lstrip("#").lower()
|
||||||
|
if channel_name in self._broadcaster_ids:
|
||||||
|
return self._broadcaster_ids[channel_name]
|
||||||
|
|
||||||
|
if not self._helix:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = await self._helix.get_user_by_name(channel_name)
|
||||||
|
if user:
|
||||||
|
self._broadcaster_ids[channel_name] = user["id"]
|
||||||
|
return user["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_via_helix(self, target: str, content: str, reply_msg_id: str | None = None) -> DeliveryResult:
|
||||||
|
if not self._helix:
|
||||||
|
return DeliveryResult(success=False, error="helix_not_available")
|
||||||
|
|
||||||
|
broadcaster_id = await self._resolve_broadcaster_id(target)
|
||||||
|
if not broadcaster_id:
|
||||||
|
return DeliveryResult(success=False, error="broadcaster_not_found")
|
||||||
|
|
||||||
|
sender_id = self._bot_user_id
|
||||||
|
if not sender_id:
|
||||||
|
return DeliveryResult(success=False, error="bot_user_id_unknown")
|
||||||
|
|
||||||
|
result = await self._helix.send_chat_message(
|
||||||
|
broadcaster_id=broadcaster_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
message=content,
|
||||||
|
reply_parent_msg_id=reply_msg_id,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return DeliveryResult(success=False, error="helix_send_failed")
|
||||||
|
|
||||||
|
message_id = result.get("message_id", "")
|
||||||
|
self._outbound_cache.record(target, content, message_id)
|
||||||
|
return DeliveryResult(success=True, metadata={"messageId": message_id})
|
||||||
|
|
||||||
def get_outbound_cache(self) -> list[dict[str, Any]]:
|
def get_outbound_cache(self) -> list[dict[str, Any]]:
|
||||||
return self._outbound_cache.get_all()
|
return self._outbound_cache.get_all()
|
||||||
|
|
||||||
@ -838,10 +910,14 @@ class TwitchAdapter(BaseChannelAdapter):
|
|||||||
|
|
||||||
await self._refresh_token_if_needed()
|
await self._refresh_token_if_needed()
|
||||||
|
|
||||||
await asyncio.sleep(3)
|
delay = min(3 * (2**self._reconnect_attempts), 120)
|
||||||
|
self._reconnect_attempts += 1
|
||||||
|
logger.info(f"[Twitch] IRC reconnecting in {delay}s (attempt {self._reconnect_attempts})")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
try:
|
try:
|
||||||
await self._connect_irc()
|
await self._connect_irc()
|
||||||
self._status = ChannelStatus.CONNECTED
|
self._status = ChannelStatus.CONNECTED
|
||||||
|
self._reconnect_attempts = 0
|
||||||
logger.info("[Twitch] IRC reconnected")
|
logger.info("[Twitch] IRC reconnected")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self._status = ChannelStatus.DISCONNECTED
|
self._status = ChannelStatus.DISCONNECTED
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -74,9 +75,17 @@ class RefreshingAuthProvider(AuthProvider):
|
|||||||
async def _refresh(self) -> bool:
|
async def _refresh(self) -> bool:
|
||||||
if not self._client_id or not self._client_secret or not self._refresh_token:
|
if not self._client_id or not self._client_secret or not self._refresh_token:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
new_tokens = await refresh_access_token(self._client_id, self._client_secret, self._refresh_token)
|
new_tokens = await refresh_access_token(self._client_id, self._client_secret, self._refresh_token)
|
||||||
if new_tokens is None:
|
if new_tokens is None:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
delay = 2**attempt
|
||||||
|
logger.warning(f"[TwitchAuth] Token refresh attempt {attempt + 1} failed, retrying in {delay}s")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
return False
|
return False
|
||||||
new_access = new_tokens.get("access_token", "")
|
new_access = new_tokens.get("access_token", "")
|
||||||
new_refresh = new_tokens.get("refresh_token", "")
|
new_refresh = new_tokens.get("refresh_token", "")
|
||||||
@ -92,7 +101,14 @@ class RefreshingAuthProvider(AuthProvider):
|
|||||||
logger.info(f"[TwitchAuth] Token refreshed, expires in {expires_in}s")
|
logger.info(f"[TwitchAuth] Token refreshed, expires in {expires_in}s")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[TwitchAuth] Token refresh error: {e}")
|
if attempt < max_retries - 1:
|
||||||
|
delay = 2**attempt
|
||||||
|
logger.warning(
|
||||||
|
f"[TwitchAuth] Token refresh error (attempt {attempt + 1}): {e}, retrying in {delay}s"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
logger.error(f"[TwitchAuth] Token refresh failed after {max_retries} attempts: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _is_expired(self) -> bool:
|
def _is_expired(self) -> bool:
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class TwitchAccountSchema(BaseModel):
|
|||||||
silent: bool = False
|
silent: bool = False
|
||||||
dm_policy: str = "pairing"
|
dm_policy: str = "pairing"
|
||||||
probe_timeout_ms: int = 10000
|
probe_timeout_ms: int = 10000
|
||||||
|
prefer_helix_send: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TwitchConfigSchema(BaseModel):
|
class TwitchConfigSchema(BaseModel):
|
||||||
@ -41,7 +42,7 @@ class TwitchConfigSchema(BaseModel):
|
|||||||
client_secret: str = ""
|
client_secret: str = ""
|
||||||
refresh_token: str = ""
|
refresh_token: str = ""
|
||||||
channels: list[str] = []
|
channels: list[str] = []
|
||||||
group_policy: str = Field(default="open", pattern=r"^(open|allowlist|disabled|mention_only)$")
|
group_policy: str = Field(default="open", pattern=r"^(open|allowlist|disabled|mention|mention_only)$")
|
||||||
require_mention: bool = True
|
require_mention: bool = True
|
||||||
allowedRoles: list[str] = []
|
allowedRoles: list[str] = []
|
||||||
group_allow_from: list[str] = []
|
group_allow_from: list[str] = []
|
||||||
@ -59,6 +60,7 @@ class TwitchConfigSchema(BaseModel):
|
|||||||
silent: bool = False
|
silent: bool = False
|
||||||
dm_policy: str = "pairing"
|
dm_policy: str = "pairing"
|
||||||
probe_timeout_ms: int = 10000
|
probe_timeout_ms: int = 10000
|
||||||
|
prefer_helix_send: bool = False
|
||||||
accounts: dict[str, TwitchAccountSchema] = {}
|
accounts: dict[str, TwitchAccountSchema] = {}
|
||||||
defaultAccount: str = ""
|
defaultAccount: str = ""
|
||||||
|
|
||||||
@ -82,7 +84,7 @@ def validate_twitch_config(config: dict[str, Any]) -> list[str]:
|
|||||||
errors.append("at least one channel is required in 'channels' list")
|
errors.append("at least one channel is required in 'channels' list")
|
||||||
|
|
||||||
group_policy = config.get("group_policy", "open")
|
group_policy = config.get("group_policy", "open")
|
||||||
valid_policies = {"open", "allowlist", "disabled", "mention_only"}
|
valid_policies = {"open", "allowlist", "disabled", "mention", "mention_only"}
|
||||||
if group_policy not in valid_policies:
|
if group_policy not in valid_policies:
|
||||||
errors.append(f"group_policy value invalid: '{group_policy}', valid values: {valid_policies}")
|
errors.append(f"group_policy value invalid: '{group_policy}', valid values: {valid_policies}")
|
||||||
|
|
||||||
@ -146,6 +148,10 @@ def super_refine_twitch_config(config: dict[str, Any]) -> dict[str, Any]:
|
|||||||
logger.info("[TwitchConfig] Converting legacy 'allowall' to 'open'")
|
logger.info("[TwitchConfig] Converting legacy 'allowall' to 'open'")
|
||||||
refined["group_policy"] = "open"
|
refined["group_policy"] = "open"
|
||||||
|
|
||||||
|
if group_policy == "mention_only":
|
||||||
|
logger.info("[TwitchConfig] 'mention_only' is deprecated, use 'mention' instead (auto-converted)")
|
||||||
|
refined["group_policy"] = "mention"
|
||||||
|
|
||||||
group_allow_from = refined.get("group_allow_from", refined.get("groupAllowFrom", []))
|
group_allow_from = refined.get("group_allow_from", refined.get("groupAllowFrom", []))
|
||||||
if group_policy == "open" and ("*" in group_allow_from):
|
if group_policy == "open" and ("*" in group_allow_from):
|
||||||
logger.info("[TwitchConfig] group_policy is 'open', wildcard in allow_from is redundant")
|
logger.info("[TwitchConfig] group_policy is 'open', wildcard in allow_from is redundant")
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, UTC
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -41,6 +41,11 @@ class EventSubListener:
|
|||||||
{"type": "channel.unban", "version": "1"},
|
{"type": "channel.unban", "version": "1"},
|
||||||
{"type": "channel.moderator.add", "version": "1"},
|
{"type": "channel.moderator.add", "version": "1"},
|
||||||
{"type": "channel.moderator.remove", "version": "1"},
|
{"type": "channel.moderator.remove", "version": "1"},
|
||||||
|
{"type": "stream.online", "version": "1"},
|
||||||
|
{"type": "stream.offline", "version": "1"},
|
||||||
|
{"type": "channel.update", "version": "2"},
|
||||||
|
{"type": "channel.chat.clear", "version": "1"},
|
||||||
|
{"type": "channel.chat.message_delete", "version": "1"},
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -231,6 +236,18 @@ class EventSubListener:
|
|||||||
if subscription_type == "channel.moderator.remove":
|
if subscription_type == "channel.moderator.remove":
|
||||||
return self._normalize_mod_remove(event, broadcaster_id)
|
return self._normalize_mod_remove(event, broadcaster_id)
|
||||||
|
|
||||||
|
if subscription_type == "stream.online":
|
||||||
|
return self._normalize_stream_online(event, broadcaster_id)
|
||||||
|
if subscription_type == "stream.offline":
|
||||||
|
return self._normalize_stream_offline(event, broadcaster_id)
|
||||||
|
|
||||||
|
if subscription_type == "channel.update":
|
||||||
|
return self._normalize_channel_update(event, broadcaster_id)
|
||||||
|
if subscription_type == "channel.chat.clear":
|
||||||
|
return self._normalize_chat_clear(event, broadcaster_id)
|
||||||
|
if subscription_type == "channel.chat.message_delete":
|
||||||
|
return self._normalize_chat_message_delete(event, broadcaster_id)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -527,3 +544,125 @@ class EventSubListener:
|
|||||||
metadata={"event": "moderator_remove"},
|
metadata={"event": "moderator_remove"},
|
||||||
timestamp=datetime.now(UTC),
|
timestamp=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_stream_online(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||||
|
broadcaster_name = event.get("broadcaster_user_name", "")
|
||||||
|
stream_type = event.get("type", "live")
|
||||||
|
started_at = event.get("started_at", "")
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=ChannelType.TWITCH,
|
||||||
|
channel_user_id=broadcaster_id,
|
||||||
|
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||||
|
channel_message_id=f"eventsub:stream_online:{event.get('id')}",
|
||||||
|
),
|
||||||
|
event_type=EventType.SYSTEM_EVENT,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content=f"🔴 {broadcaster_name} went live!",
|
||||||
|
metadata={
|
||||||
|
"event": "stream_online",
|
||||||
|
"type": stream_type,
|
||||||
|
"started_at": started_at,
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_stream_offline(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||||
|
broadcaster_name = event.get("broadcaster_user_name", "")
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=ChannelType.TWITCH,
|
||||||
|
channel_user_id=broadcaster_id,
|
||||||
|
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||||
|
channel_message_id=f"eventsub:stream_offline:{event.get('id')}",
|
||||||
|
),
|
||||||
|
event_type=EventType.SYSTEM_EVENT,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content=f"⚫ {broadcaster_name} went offline",
|
||||||
|
metadata={
|
||||||
|
"event": "stream_offline",
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_channel_update(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||||
|
broadcaster_name = event.get("broadcaster_user_name", "")
|
||||||
|
title = event.get("title", "")
|
||||||
|
category_name = event.get("category_name", "")
|
||||||
|
language = event.get("language", "")
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=ChannelType.TWITCH,
|
||||||
|
channel_user_id=broadcaster_id,
|
||||||
|
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||||
|
channel_message_id=f"eventsub:channel_update:{event.get('id')}",
|
||||||
|
),
|
||||||
|
event_type=EventType.SYSTEM_EVENT,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content=f"📺 {broadcaster_name} updated channel: {title}",
|
||||||
|
metadata={
|
||||||
|
"event": "channel_update",
|
||||||
|
"title": title,
|
||||||
|
"category": category_name,
|
||||||
|
"language": language,
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_chat_clear(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=ChannelType.TWITCH,
|
||||||
|
channel_user_id="twitch_system",
|
||||||
|
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||||
|
channel_message_id=f"eventsub:chat_clear:{event.get('id')}",
|
||||||
|
),
|
||||||
|
event_type=EventType.SYSTEM_EVENT,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content=f"🧹 Chat cleared in #{event.get('broadcaster_user_name', '')}",
|
||||||
|
metadata={
|
||||||
|
"event": "chat_clear",
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_chat_message_delete(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||||
|
target_user_name = event.get("target_user_name", "")
|
||||||
|
target_user_id = event.get("target_user_id", "")
|
||||||
|
message_id = event.get("message_id", "")
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=ChannelIdentity(
|
||||||
|
channel_id="twitch",
|
||||||
|
channel_type=ChannelType.TWITCH,
|
||||||
|
channel_user_id=target_user_id or "twitch_system",
|
||||||
|
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||||
|
channel_message_id=f"eventsub:msg_delete:{event.get('id')}",
|
||||||
|
),
|
||||||
|
event_type=EventType.MESSAGE_DELETED,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content="",
|
||||||
|
metadata={
|
||||||
|
"event": "message_delete",
|
||||||
|
"target_user_name": target_user_name,
|
||||||
|
"target_message_id": message_id,
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|||||||
@ -46,16 +46,15 @@ class HelixClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _request_with_backoff(self, method: str, url: str, **kwargs) -> aiohttp.ClientResponse | None:
|
async def _request_with_backoff(self, method: str, url: str, **kwargs) -> aiohttp.ClientResponse | None:
|
||||||
last_status: int | None = None
|
|
||||||
for attempt in range(self.MAX_RETRIES):
|
for attempt in range(self.MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
async with self._ensure_session.request(method, url, **kwargs) as resp:
|
async with self._ensure_session.request(method, url, **kwargs) as resp:
|
||||||
if resp.status not in self.RETRY_STATUSES or attempt == self.MAX_RETRIES - 1:
|
if resp.status not in self.RETRY_STATUSES or attempt == self.MAX_RETRIES - 1:
|
||||||
return resp
|
return resp
|
||||||
last_status = resp.status
|
|
||||||
except (TimeoutError, aiohttp.ClientError) as e:
|
except (TimeoutError, aiohttp.ClientError) as e:
|
||||||
if attempt == self.MAX_RETRIES - 1:
|
if attempt == self.MAX_RETRIES - 1:
|
||||||
raise
|
logger.error(f"Helix {method} {url} connection failed after {self.MAX_RETRIES} retries: {e}")
|
||||||
|
return None
|
||||||
logger.warning(f"Helix {method} {url} attempt {attempt + 1} failed: {e}")
|
logger.warning(f"Helix {method} {url} attempt {attempt + 1} failed: {e}")
|
||||||
|
|
||||||
delay = min(self.BASE_BACKOFF * (2**attempt), self.MAX_BACKOFF)
|
delay = min(self.BASE_BACKOFF * (2**attempt), self.MAX_BACKOFF)
|
||||||
@ -190,3 +189,90 @@ class HelixClient:
|
|||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"EventSub delete subscription error: {e}")
|
logger.error(f"EventSub delete subscription error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_chat_message(
|
||||||
|
self,
|
||||||
|
broadcaster_id: str,
|
||||||
|
sender_id: str,
|
||||||
|
message: str,
|
||||||
|
reply_parent_msg_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
url = f"{self.BASE_URL}/chat/messages"
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"broadcaster_id": broadcaster_id,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
if reply_parent_msg_id:
|
||||||
|
body["reply_parent_msg_id"] = reply_parent_msg_id
|
||||||
|
|
||||||
|
resp = await self._request_with_backoff(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers={**self._headers(), "Content-Type": "application/json"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
if resp is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.json()
|
||||||
|
return data.get("data", [{}])[0] if data.get("data") else None
|
||||||
|
logger.error(f"Helix send_chat_message failed: {resp.status} {await resp.text()}")
|
||||||
|
return None
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Helix send_chat_message connection error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_chat_message(
|
||||||
|
self,
|
||||||
|
broadcaster_id: str,
|
||||||
|
moderator_id: str,
|
||||||
|
message_id: str,
|
||||||
|
) -> bool:
|
||||||
|
url = f"{self.BASE_URL}/chat/messages"
|
||||||
|
params = {
|
||||||
|
"broadcaster_id": broadcaster_id,
|
||||||
|
"moderator_id": moderator_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
resp = await self._request_with_backoff("DELETE", url, headers=self._headers(), params=params)
|
||||||
|
if resp is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return resp.status in (200, 204)
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Helix delete_chat_message error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_chat_announcement(
|
||||||
|
self,
|
||||||
|
broadcaster_id: str,
|
||||||
|
moderator_id: str,
|
||||||
|
message: str,
|
||||||
|
color: str = "primary",
|
||||||
|
) -> bool:
|
||||||
|
url = f"{self.BASE_URL}/chat/announcements"
|
||||||
|
valid_colors = {"blue", "green", "orange", "purple", "primary"}
|
||||||
|
if color not in valid_colors:
|
||||||
|
color = "primary"
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"broadcaster_id": broadcaster_id,
|
||||||
|
"moderator_id": moderator_id,
|
||||||
|
"message": message,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
|
resp = await self._request_with_backoff(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers={**self._headers(), "Content-Type": "application/json"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
if resp is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return resp.status in (200, 204)
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Helix send_chat_announcement error: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, UTC
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from yuxi.channels.models import (
|
from yuxi.channels.models import (
|
||||||
@ -16,9 +16,9 @@ from yuxi.channels.models import (
|
|||||||
from .irc_parser import (
|
from .irc_parser import (
|
||||||
ParsedIRCMessage,
|
ParsedIRCMessage,
|
||||||
extract_nick,
|
extract_nick,
|
||||||
|
make_irc_message_id,
|
||||||
parse_badges,
|
parse_badges,
|
||||||
parse_emotes,
|
parse_emotes,
|
||||||
make_irc_message_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_FILTERED_NOTICE_MSG_IDS = frozenset(
|
_FILTERED_NOTICE_MSG_IDS = frozenset(
|
||||||
@ -49,7 +49,7 @@ _EVENT_MAP: dict[str, str] = {
|
|||||||
"announcement": "announcement",
|
"announcement": "announcement",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SKIP_COMMANDS = frozenset({"JOIN", "PART", "HOSTTARGET", "ROOMSTATE", "USERSTATE"})
|
_SKIP_COMMANDS = frozenset({"JOIN", "PART", "HOSTTARGET", "USERSTATE"})
|
||||||
|
|
||||||
|
|
||||||
def _make_identity(
|
def _make_identity(
|
||||||
@ -178,6 +178,27 @@ def normalize_irc_message(parsed: ParsedIRCMessage) -> ChannelMessage | None:
|
|||||||
timestamp=datetime.now(UTC),
|
timestamp=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- CLEARMSG: 单条消息删除 ----
|
||||||
|
if command == "CLEARMSG":
|
||||||
|
channel = parsed.params[0] if parsed.params else ""
|
||||||
|
target_msg_id = tags.get("target-msg-id", "")
|
||||||
|
login = tags.get("login", "")
|
||||||
|
target_user_id = tags.get("target-user-id", "")
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=_make_identity(target_user_id or "twitch_system", channel, target_msg_id),
|
||||||
|
event_type=EventType.MESSAGE_DELETED,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content="",
|
||||||
|
metadata={
|
||||||
|
"event": "clearmsg",
|
||||||
|
"target_msg_id": target_msg_id,
|
||||||
|
"login": login,
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
# ---- WHISPER: 私信 ----
|
# ---- WHISPER: 私信 ----
|
||||||
if command == "WHISPER":
|
if command == "WHISPER":
|
||||||
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
|
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
|
||||||
@ -204,6 +225,30 @@ def normalize_irc_message(parsed: ParsedIRCMessage) -> ChannelMessage | None:
|
|||||||
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
|
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- ROOMSTATE: 房间状态变更 ----
|
||||||
|
if command == "ROOMSTATE":
|
||||||
|
channel = parsed.params[0] if parsed.params else ""
|
||||||
|
room_state_flags = {
|
||||||
|
"emote_only": tags.get("emote-only") == "1",
|
||||||
|
"subs_only": tags.get("subs-only") == "1",
|
||||||
|
"followers_only": tags.get("followers-only", "-1"),
|
||||||
|
"slow": tags.get("slow", "0"),
|
||||||
|
"r9k": tags.get("r9k") == "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChannelMessage(
|
||||||
|
identity=_make_identity("twitch_system", channel),
|
||||||
|
event_type=EventType.SYSTEM_EVENT,
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
chat_type=ChatType.GROUP,
|
||||||
|
content="Room state changed",
|
||||||
|
metadata={
|
||||||
|
"event": "roomstate",
|
||||||
|
"room_state": room_state_flags,
|
||||||
|
},
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
# ---- NOTICE: 系统通知 ----
|
# ---- NOTICE: 系统通知 ----
|
||||||
if command == "NOTICE":
|
if command == "NOTICE":
|
||||||
channel = parsed.params[0] if parsed.params else ""
|
channel = parsed.params[0] if parsed.params else ""
|
||||||
|
|||||||
@ -10,16 +10,25 @@ class OutboundCacheManager:
|
|||||||
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||||
self._max_size = max_size
|
self._max_size = max_size
|
||||||
|
|
||||||
def record(self, target: str, content: str) -> None:
|
def record(self, target: str, content: str, message_id: str | None = None) -> str:
|
||||||
entry = {
|
entry = {
|
||||||
"channel": target,
|
"channel": target,
|
||||||
"content": content,
|
"content": content,
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
cache_key = f"{target}:{len(self._cache)}"
|
cache_key = message_id or f"{target}:{len(self._cache)}"
|
||||||
|
if message_id:
|
||||||
|
entry["message_id"] = message_id
|
||||||
self._cache[cache_key] = entry
|
self._cache[cache_key] = entry
|
||||||
while len(self._cache) > self._max_size:
|
while len(self._cache) > self._max_size:
|
||||||
self._cache.popitem(last=False)
|
self._cache.popitem(last=False)
|
||||||
|
return cache_key
|
||||||
|
|
||||||
def get_all(self) -> list[dict[str, Any]]:
|
def get_all(self) -> list[dict[str, Any]]:
|
||||||
return list(self._cache.values())
|
return list(self._cache.values())
|
||||||
|
|
||||||
|
def find_by_message_id(self, message_id: str) -> dict[str, Any] | None:
|
||||||
|
for entry in self._cache.values():
|
||||||
|
if entry.get("message_id") == message_id:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|||||||
@ -18,6 +18,7 @@ def format_cap_req(capabilities: list[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def find_utf8_cut(encoded: bytes, byte_limit: int) -> int:
|
def find_utf8_cut(encoded: bytes, byte_limit: int) -> int:
|
||||||
|
byte_limit = min(byte_limit, len(encoded))
|
||||||
cut = byte_limit
|
cut = byte_limit
|
||||||
while cut > 0 and (encoded[cut - 1] & 0xC0) == 0x80:
|
while cut > 0 and (encoded[cut - 1] & 0xC0) == 0x80:
|
||||||
cut -= 1
|
cut -= 1
|
||||||
|
|||||||
@ -97,11 +97,10 @@ def check_group_policy(
|
|||||||
return True
|
return True
|
||||||
case "disabled":
|
case "disabled":
|
||||||
return False
|
return False
|
||||||
case "mention_only":
|
case "mention":
|
||||||
require_mention = config.get("require_mention", True)
|
|
||||||
if not require_mention:
|
|
||||||
return True
|
|
||||||
return bool(bot_name) and bot_name in content.lower()
|
return bool(bot_name) and bot_name in content.lower()
|
||||||
|
case "mention_only":
|
||||||
|
return not config.get("require_mention", True) or (bool(bot_name) and bot_name in content.lower())
|
||||||
case "allowlist":
|
case "allowlist":
|
||||||
allowlist = config.get("group_allow_from", [])
|
allowlist = config.get("group_allow_from", [])
|
||||||
channels_config = config.get("channels_config", {})
|
channels_config = config.get("channels_config", {})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user