Compare commits
7 Commits
v4.18.2
...
feat/heibox
| Author | SHA1 | Date | |
|---|---|---|---|
| 602ae4eee2 | |||
| 3d82f42311 | |||
| 5530a2260a | |||
| c24de24ca4 | |||
| b54b4c79ed | |||
| c6cc7aae84 | |||
| aec2e3bb91 |
@@ -180,6 +180,10 @@ class PlatformManager:
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "heihe":
|
||||
from .sources.heihe.heihe_adapter import (
|
||||
HeihePlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection, connect
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
Group,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .heihe_event import HeiheMessageEvent
|
||||
|
||||
HEIHE_CONFIG_METADATA = {
|
||||
"heihe_ws_url": {
|
||||
"description": "Heihe WebSocket URL",
|
||||
"type": "string",
|
||||
"hint": "一般情况下不需要修改。",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "Bot Token",
|
||||
"type": "string",
|
||||
"hint": "黑盒 Bot Token。可填写纯 Token(推荐),适配器会自动添加 Authorization 头。",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"type": "string",
|
||||
"hint": "用于 WebSocket 握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "Bot ID",
|
||||
"type": "string",
|
||||
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "Auto Reconnect",
|
||||
"type": "bool",
|
||||
"hint": "WebSocket 断开后是否自动重连。",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "Heartbeat Interval (seconds)",
|
||||
"type": "int",
|
||||
"hint": "发送心跳包间隔。<=0 表示关闭主动心跳。",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "Reconnect Delay (seconds)",
|
||||
"type": "int",
|
||||
"hint": "WebSocket 断开后的重连等待时间。",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "Ignore Self Message",
|
||||
"type": "bool",
|
||||
"hint": "是否忽略机器人自身发送的消息。",
|
||||
},
|
||||
}
|
||||
|
||||
HEIHE_I18N_RESOURCES = {
|
||||
"zh-CN": {
|
||||
"heihe_ws_url": {
|
||||
"description": "黑盒 WebSocket 地址",
|
||||
"hint": "一般情况下不需要修改。",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "机器人 Token",
|
||||
"hint": "建议填写纯 Token,适配器会自动补齐 Authorization 头。",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"hint": "用于握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "机器人 ID",
|
||||
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "自动重连",
|
||||
"hint": "WebSocket 断开后是否自动重连。",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "心跳间隔(秒)",
|
||||
"hint": "设置 <=0 将关闭主动心跳。",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "重连间隔(秒)",
|
||||
"hint": "WebSocket 断开后的重连等待时间。",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "忽略机器人自身消息",
|
||||
"hint": "开启后,机器人自己发出的消息将不会触发事件处理。",
|
||||
},
|
||||
},
|
||||
"en-US": {
|
||||
"heihe_ws_url": {
|
||||
"description": "Heihe WebSocket URL",
|
||||
"hint": "Usually no need to change this.",
|
||||
},
|
||||
"heihe_token": {
|
||||
"description": "Bot Token",
|
||||
"hint": "Plain token is recommended. Authorization header is added automatically.",
|
||||
},
|
||||
"heihe_origin": {
|
||||
"description": "WebSocket Origin",
|
||||
"hint": "Origin header used in websocket handshake. Default: https://chat.xiaoheihe.cn.",
|
||||
},
|
||||
"heihe_bot_id": {
|
||||
"description": "Bot ID",
|
||||
"hint": "Optional. If empty, the adapter will infer it from incoming messages.",
|
||||
},
|
||||
"heihe_auto_reconnect": {
|
||||
"description": "Auto Reconnect",
|
||||
"hint": "Whether to reconnect automatically after websocket disconnects.",
|
||||
},
|
||||
"heihe_heartbeat_interval": {
|
||||
"description": "Heartbeat Interval (seconds)",
|
||||
"hint": "Set <=0 to disable active heartbeat.",
|
||||
},
|
||||
"heihe_reconnect_delay": {
|
||||
"description": "Reconnect Delay (seconds)",
|
||||
"hint": "Delay before reconnecting after disconnect.",
|
||||
},
|
||||
"heihe_ignore_self_message": {
|
||||
"description": "Ignore Self Message",
|
||||
"hint": "When enabled, messages sent by the bot itself will be ignored.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"heihe",
|
||||
"黑盒机器人(WebSocket)适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "heihe",
|
||||
"type": "heihe",
|
||||
"enable": False,
|
||||
"heihe_ws_url": "wss://chat.xiaoheihe.cn/chatroom/ws/connect",
|
||||
"heihe_token": "",
|
||||
"heihe_origin": "https://chat.xiaoheihe.cn",
|
||||
"heihe_bot_id": "",
|
||||
"heihe_auto_reconnect": True,
|
||||
"heihe_heartbeat_interval": 20,
|
||||
"heihe_reconnect_delay": 5,
|
||||
"heihe_ignore_self_message": True,
|
||||
},
|
||||
config_metadata=HEIHE_CONFIG_METADATA,
|
||||
i18n_resources=HEIHE_I18N_RESOURCES,
|
||||
)
|
||||
class HeihePlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
platform_settings: dict,
|
||||
event_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
|
||||
self.ws_url = str(platform_config.get("heihe_ws_url", "")).strip()
|
||||
self.token = str(platform_config.get("heihe_token", "")).strip()
|
||||
self.origin = str(
|
||||
platform_config.get("heihe_origin", "https://chat.xiaoheihe.cn"),
|
||||
).strip()
|
||||
self.bot_id = str(platform_config.get("heihe_bot_id", "")).strip()
|
||||
self.auto_reconnect = bool(platform_config.get("heihe_auto_reconnect", True))
|
||||
self.heartbeat_interval = int(
|
||||
cast(int, platform_config.get("heihe_heartbeat_interval", 20)),
|
||||
)
|
||||
self.reconnect_delay = int(
|
||||
cast(int, platform_config.get("heihe_reconnect_delay", 5)),
|
||||
)
|
||||
self.ignore_self_message = bool(
|
||||
platform_config.get("heihe_ignore_self_message", True),
|
||||
)
|
||||
|
||||
if not self.ws_url:
|
||||
raise ValueError("heihe_ws_url 不能为空。")
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
name="heihe",
|
||||
description="黑盒机器人(WebSocket)适配器",
|
||||
id=cast(str, self.config.get("id", "heihe")),
|
||||
support_streaming_message=False,
|
||||
)
|
||||
|
||||
self.ws: ClientConnection | None = None
|
||||
self.running = False
|
||||
self.heartbeat_task: asyncio.Task | None = None
|
||||
self._last_heartbeat_ts = 0
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
async def run(self) -> None:
|
||||
self.running = True
|
||||
while self.running:
|
||||
try:
|
||||
await self._connect_and_loop()
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.warning("[heihe] websocket disconnected: %s", e)
|
||||
except Exception as e:
|
||||
logger.error("[heihe] websocket failed: %s", e)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
if not self.auto_reconnect:
|
||||
break
|
||||
await asyncio.sleep(max(1, self.reconnect_delay))
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.running = False
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
await HeiheMessageEvent.send_with_adapter(
|
||||
self,
|
||||
message_chain,
|
||||
session.session_id,
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def send_payload(self, payload: Mapping[str, Any]) -> None:
|
||||
if not self.ws:
|
||||
raise RuntimeError("[heihe] websocket not connected")
|
||||
if self.ws.close_code is not None:
|
||||
raise RuntimeError("[heihe] websocket already closed")
|
||||
|
||||
body = dict(payload)
|
||||
body.setdefault("timestamp", int(time.time()))
|
||||
await self.ws.send(json.dumps(body, ensure_ascii=False))
|
||||
|
||||
async def _connect_and_loop(self) -> None:
|
||||
logger.info("[heihe] connecting websocket: %s", self.ws_url)
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
headers["X-Token"] = self.token
|
||||
|
||||
websocket = await connect(
|
||||
self.ws_url,
|
||||
additional_headers=headers,
|
||||
max_size=10 * 1024 * 1024,
|
||||
ping_interval=None,
|
||||
)
|
||||
self.ws = websocket
|
||||
logger.info("[heihe] websocket connected")
|
||||
|
||||
if self.heartbeat_interval > 0:
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
try:
|
||||
async for raw in websocket:
|
||||
await self._handle_incoming(raw)
|
||||
finally:
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.heartbeat_task = None
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
try:
|
||||
while self.running and self.ws and self.ws.close_code is None:
|
||||
await asyncio.sleep(self.heartbeat_interval)
|
||||
self._last_heartbeat_ts = int(time.time())
|
||||
await self.send_payload(
|
||||
{
|
||||
"type": "ping",
|
||||
"ping": self._last_heartbeat_ts,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("[heihe] heartbeat error: %s", e)
|
||||
|
||||
async def _handle_incoming(self, raw: Any) -> None:
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return
|
||||
if not isinstance(raw, str):
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("[heihe] skip non-json frame: %s", raw[:200])
|
||||
return
|
||||
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
await self._handle_packet(item)
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
await self._handle_packet(data)
|
||||
|
||||
async def _handle_packet(self, packet: dict[str, Any]) -> None:
|
||||
if "ping" in packet:
|
||||
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
|
||||
return
|
||||
if str(packet.get("type", "")).lower() == "ping":
|
||||
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
|
||||
return
|
||||
|
||||
event_type = str(
|
||||
packet.get("event")
|
||||
or packet.get("event_type")
|
||||
or packet.get("type")
|
||||
or packet.get("topic")
|
||||
or "",
|
||||
).lower()
|
||||
payload_obj = packet.get("data")
|
||||
payload = payload_obj if isinstance(payload_obj, dict) else packet
|
||||
|
||||
if not self._is_message_event(event_type, payload):
|
||||
return
|
||||
|
||||
abm = self._convert_message(payload, packet)
|
||||
if not abm:
|
||||
return
|
||||
await self.handle_msg(abm)
|
||||
|
||||
@staticmethod
|
||||
def _is_message_event(event_type: str, payload: Mapping[str, Any]) -> bool:
|
||||
if "message" in event_type:
|
||||
return True
|
||||
keys = payload.keys()
|
||||
return "content" in keys or "text" in keys or "message" in keys
|
||||
|
||||
def _convert_message(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
raw_packet: Mapping[str, Any],
|
||||
) -> AstrBotMessage | None:
|
||||
message_obj = payload.get("message")
|
||||
message = message_obj if isinstance(message_obj, Mapping) else payload
|
||||
|
||||
sender_data_obj = (
|
||||
payload.get("sender") or payload.get("author") or payload.get("user") or {}
|
||||
)
|
||||
sender_data = sender_data_obj if isinstance(sender_data_obj, Mapping) else {}
|
||||
sender_id = str(
|
||||
sender_data.get("id")
|
||||
or sender_data.get("user_id")
|
||||
or payload.get("sender_id")
|
||||
or payload.get("user_id")
|
||||
or "",
|
||||
).strip()
|
||||
sender_name = str(
|
||||
sender_data.get("nickname")
|
||||
or sender_data.get("name")
|
||||
or sender_data.get("username")
|
||||
or sender_id
|
||||
or "unknown",
|
||||
)
|
||||
|
||||
self_id = str(
|
||||
payload.get("self_id")
|
||||
or payload.get("bot_id")
|
||||
or self.bot_id
|
||||
or self.meta().id,
|
||||
)
|
||||
if self.ignore_self_message and sender_id and self_id and sender_id == self_id:
|
||||
return None
|
||||
|
||||
channel_id = str(
|
||||
payload.get("channel_id")
|
||||
or payload.get("room_id")
|
||||
or payload.get("chat_id")
|
||||
or payload.get("session_id")
|
||||
or "",
|
||||
).strip()
|
||||
guild_id = str(
|
||||
payload.get("guild_id")
|
||||
or payload.get("server_id")
|
||||
or payload.get("group_id")
|
||||
or "",
|
||||
).strip()
|
||||
is_private = bool(payload.get("is_private", False))
|
||||
if str(payload.get("message_type", "")).lower() in {"private", "friend", "dm"}:
|
||||
is_private = True
|
||||
|
||||
session_id = channel_id or sender_id
|
||||
if not session_id:
|
||||
return None
|
||||
|
||||
text = str(message.get("content") or message.get("text") or "").strip()
|
||||
components = self._build_components(text, payload)
|
||||
if not components:
|
||||
return None
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self_id
|
||||
abm.message_id = str(
|
||||
message.get("id")
|
||||
or message.get("message_id")
|
||||
or payload.get("message_id")
|
||||
or payload.get("msg_id")
|
||||
or uuid.uuid4().hex
|
||||
)
|
||||
timestamp_raw = (
|
||||
payload.get("timestamp")
|
||||
or payload.get("time")
|
||||
or message.get("timestamp")
|
||||
or message.get("time")
|
||||
)
|
||||
abm.timestamp = int(time.time())
|
||||
if isinstance(timestamp_raw, int):
|
||||
abm.timestamp = (
|
||||
timestamp_raw // 1000
|
||||
if timestamp_raw > 1_000_000_000_000
|
||||
else timestamp_raw
|
||||
)
|
||||
|
||||
if not is_private and (channel_id or guild_id):
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group = Group(
|
||||
group_id=guild_id or channel_id, group_name=guild_id or ""
|
||||
)
|
||||
else:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
|
||||
abm.session_id = session_id
|
||||
abm.sender = MessageMember(user_id=sender_id or "unknown", nickname=sender_name)
|
||||
abm.message = components
|
||||
abm.message_str = self._build_message_str(components)
|
||||
abm.raw_message = dict(raw_packet)
|
||||
return abm
|
||||
|
||||
@staticmethod
|
||||
def _build_components(text: str, payload: Mapping[str, Any]) -> list:
|
||||
components: list = []
|
||||
if text:
|
||||
components.append(Plain(text=text))
|
||||
|
||||
mentions_obj = payload.get("mentions")
|
||||
if isinstance(mentions_obj, list):
|
||||
for mention in mentions_obj:
|
||||
if not isinstance(mention, Mapping):
|
||||
continue
|
||||
user_id = str(mention.get("user_id") or mention.get("id") or "").strip()
|
||||
name = str(mention.get("name") or mention.get("nickname") or "").strip()
|
||||
if user_id or name:
|
||||
components.append(At(qq=user_id, name=name))
|
||||
|
||||
attachments_obj = payload.get("attachments")
|
||||
if isinstance(attachments_obj, list):
|
||||
for item in attachments_obj:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
url = str(item.get("url") or item.get("file_url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
kind = str(item.get("type") or item.get("media_type") or "").lower()
|
||||
if "image" in kind:
|
||||
components.append(Image.fromURL(url))
|
||||
else:
|
||||
components.append(Plain(text=f"[{kind or 'file'}] {url}"))
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str(components: list) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(comp.text)
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f"@{comp.name or comp.qq}")
|
||||
elif isinstance(comp, Image):
|
||||
parts.append("[image]")
|
||||
else:
|
||||
parts.append(f"[{comp.type}]")
|
||||
return " ".join(i for i in parts if i).strip()
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
event = HeiheMessageEvent(
|
||||
message_str=abm.message_str,
|
||||
message_obj=abm,
|
||||
platform_meta=self.meta(),
|
||||
session_id=abm.session_id,
|
||||
adapter=self,
|
||||
)
|
||||
self.commit_event(event)
|
||||
@@ -0,0 +1,108 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain, Reply
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .heihe_adapter import HeihePlatformAdapter
|
||||
|
||||
|
||||
class HeiheMessageEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
adapter: "HeihePlatformAdapter",
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.adapter = adapter
|
||||
|
||||
@classmethod
|
||||
async def send_with_adapter(
|
||||
cls,
|
||||
adapter: "HeihePlatformAdapter",
|
||||
message: MessageChain,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
payload = await cls._build_send_payload(message, session_id)
|
||||
await adapter.send_payload(payload)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
await self.send_with_adapter(self.adapter, message, self.session_id)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self,
|
||||
generator: AsyncGenerator,
|
||||
use_fallback: bool = False,
|
||||
):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@classmethod
|
||||
async def _build_send_payload(
|
||||
cls,
|
||||
message: MessageChain,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
text_parts: list[str] = []
|
||||
segments: list[dict[str, Any]] = []
|
||||
|
||||
for component in message.chain:
|
||||
if isinstance(component, Plain):
|
||||
if component.text:
|
||||
text_parts.append(component.text)
|
||||
segments.append({"type": "text", "text": component.text})
|
||||
continue
|
||||
|
||||
if isinstance(component, At):
|
||||
at_name = str(component.name or component.qq or "").strip()
|
||||
if at_name:
|
||||
text_parts.append(f"@{at_name}")
|
||||
segments.append(
|
||||
{
|
||||
"type": "mention",
|
||||
"user_id": str(component.qq or ""),
|
||||
"name": at_name,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(component, Reply):
|
||||
if component.id:
|
||||
segments.append({"type": "reply", "message_id": component.id})
|
||||
continue
|
||||
|
||||
if isinstance(component, Image):
|
||||
image_url = ""
|
||||
try:
|
||||
image_url = await component.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[heihe] image upload fallback failed: %s", e)
|
||||
|
||||
if image_url:
|
||||
segments.append({"type": "image", "url": image_url})
|
||||
text_parts.append("[image]")
|
||||
continue
|
||||
|
||||
content = "".join(text_parts).strip()
|
||||
payload: dict[str, Any] = {
|
||||
"action": "send_message",
|
||||
"channel_id": session_id,
|
||||
"content": content,
|
||||
"segments": segments,
|
||||
}
|
||||
return payload
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
@@ -25,6 +26,9 @@ from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
|
||||
from .tg_event import TelegramPlatformEvent
|
||||
|
||||
@@ -375,8 +379,19 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
elif update.message.voice:
|
||||
file = await update.message.voice.get_file()
|
||||
|
||||
file_basename = os.path.basename(file.file_path)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
temp_path = os.path.join(temp_dir, file_basename)
|
||||
temp_path = await download_image_by_url(file.file_path, path=temp_path)
|
||||
path_wav = os.path.join(
|
||||
temp_dir,
|
||||
f"{file_basename}.wav",
|
||||
)
|
||||
path_wav = await convert_audio_to_wav(temp_path, path_wav)
|
||||
|
||||
message.message = [
|
||||
Comp.Record(file=file.file_path, url=file.file_path),
|
||||
Comp.Record(file=path_wav, url=path_wav),
|
||||
]
|
||||
|
||||
elif update.message.photo:
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "Install it and restart AstrBot.",
|
||||
"guideStep3": "If you use Docker, prefer the image update path."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Update Desktop App",
|
||||
"message": "Check and upgrade the AstrBot desktop application.",
|
||||
"currentVersion": "Current version: ",
|
||||
"latestVersion": "Latest version: ",
|
||||
"checking": "Checking desktop app updates...",
|
||||
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
|
||||
"isLatest": "Already on the latest version",
|
||||
"installing": "Downloading and installing update. The app will restart automatically...",
|
||||
"checkFailed": "Failed to check updates. Please try again later.",
|
||||
"installFailed": "Upgrade failed. Please try again later."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Update Dashboard to Latest Version Only",
|
||||
"currentVersion": "Current Version",
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "完成安装后重启 AstrBot。",
|
||||
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "更新桌面应用",
|
||||
"message": "将检查并升级 AstrBot 桌面端程序。",
|
||||
"currentVersion": "当前版本:",
|
||||
"latestVersion": "最新版本:",
|
||||
"checking": "正在检查桌面应用更新...",
|
||||
"hasNewVersion": "发现新版本,可点击确认升级。",
|
||||
"isLatest": "已经是最新版本",
|
||||
"installing": "正在下载并安装更新,完成后将自动重启应用...",
|
||||
"checkFailed": "检查更新失败,请稍后重试。",
|
||||
"installFailed": "升级失败,请稍后重试。"
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "单独更新管理面板到最新版本",
|
||||
"currentVersion": "当前版本",
|
||||
|
||||
@@ -50,24 +50,27 @@ let installLoading = ref(false);
|
||||
const isDesktopReleaseMode = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
|
||||
const resolveReleaseBaseUrl = () => {
|
||||
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
|
||||
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
|
||||
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
|
||||
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
|
||||
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
|
||||
};
|
||||
const releaseBaseUrl = resolveReleaseBaseUrl();
|
||||
const getReleaseUrlByTag = (tag: string | null | undefined) => {
|
||||
const normalizedTag = (tag || '').trim();
|
||||
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
|
||||
return `${releaseBaseUrl}/latest`;
|
||||
const desktopUpdateDialog = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const desktopUpdateInstalling = ref(false);
|
||||
const desktopUpdateHasNewVersion = ref(false);
|
||||
const desktopUpdateCurrentVersion = ref('-');
|
||||
const desktopUpdateLatestVersion = ref('-');
|
||||
const desktopUpdateStatus = ref('');
|
||||
|
||||
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return `${releaseBaseUrl}/tag/${normalizedTag}`;
|
||||
const bridge = window.astrbotAppUpdater;
|
||||
if (
|
||||
bridge &&
|
||||
typeof bridge.checkForAppUpdate === 'function' &&
|
||||
typeof bridge.installAppUpdate === 'function'
|
||||
) {
|
||||
return bridge;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedGitHubProxy = () => {
|
||||
@@ -89,16 +92,6 @@ const releasesHeader = computed(() => [
|
||||
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]);
|
||||
const latestReleaseTag = computed(() => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
return firstRelease.tag_name as string;
|
||||
}
|
||||
return hasNewVersion.value
|
||||
? t('core.header.updateDialog.redirectConfirm.latestLabel')
|
||||
: (botCurrVersion.value || '-');
|
||||
});
|
||||
|
||||
// Form validation
|
||||
const formValid = ref(true);
|
||||
const passwordRules = computed(() => [
|
||||
@@ -126,47 +119,88 @@ const accountEditStatus = ref({
|
||||
message: ''
|
||||
});
|
||||
|
||||
const open = (link: string) => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
function requestExternalRedirect(link: string) {
|
||||
pendingRedirectUrl.value = link;
|
||||
redirectConfirmDialog.value = true;
|
||||
function cancelDesktopUpdate() {
|
||||
if (desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
desktopUpdateDialog.value = false;
|
||||
}
|
||||
|
||||
function cancelExternalRedirect() {
|
||||
redirectConfirmDialog.value = false;
|
||||
pendingRedirectUrl.value = '';
|
||||
}
|
||||
async function openDesktopUpdateDialog() {
|
||||
desktopUpdateDialog.value = true;
|
||||
desktopUpdateChecking.value = true;
|
||||
desktopUpdateInstalling.value = false;
|
||||
desktopUpdateHasNewVersion.value = false;
|
||||
desktopUpdateCurrentVersion.value = '-';
|
||||
desktopUpdateLatestVersion.value = '-';
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
|
||||
|
||||
function confirmExternalRedirect() {
|
||||
const targetUrl = pendingRedirectUrl.value;
|
||||
cancelExternalRedirect();
|
||||
if (targetUrl) {
|
||||
open(targetUrl);
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateChecking.value = false;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.checkForAppUpdate();
|
||||
if (!result?.ok) {
|
||||
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result?.latestVersion || result?.currentVersion || '-';
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result.latestVersion || result.currentVersion || '-';
|
||||
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
|
||||
desktopUpdateStatus.value = result.hasUpdate
|
||||
? t('core.header.updateDialog.desktopApp.hasNewVersion')
|
||||
: t('core.header.updateDialog.desktopApp.isLatest');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getReleaseUrlForDesktop = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
return getReleaseUrlByTag(firstRelease.tag_name as string);
|
||||
async function confirmDesktopUpdate() {
|
||||
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
|
||||
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
|
||||
return getReleaseUrlByTag(tag);
|
||||
};
|
||||
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateInstalling.value = true;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
|
||||
|
||||
try {
|
||||
const result = await bridge.installAppUpdate();
|
||||
if (result?.ok) {
|
||||
desktopUpdateDialog.value = false;
|
||||
return;
|
||||
}
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} finally {
|
||||
desktopUpdateInstalling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateClick() {
|
||||
if (isDesktopReleaseMode.value) {
|
||||
requestExternalRedirect('');
|
||||
resolvingReleaseTarget.value = true;
|
||||
checkUpdate();
|
||||
void getReleases().finally(() => {
|
||||
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
|
||||
resolvingReleaseTarget.value = false;
|
||||
});
|
||||
void openDesktopUpdateDialog();
|
||||
return;
|
||||
}
|
||||
checkUpdate();
|
||||
@@ -680,40 +714,38 @@ onMounted(async () => {
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="redirectConfirmDialog" max-width="460">
|
||||
<v-dialog v-model="desktopUpdateDialog" max-width="460">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.title') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-3">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.message') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.message') }}
|
||||
</div>
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
|
||||
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
|
||||
<strong>{{ desktopUpdateCurrentVersion }}</strong>
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
|
||||
{{ botCurrVersion || '-' }}
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
|
||||
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
</div>
|
||||
</v-alert>
|
||||
<div class="text-caption mt-3">
|
||||
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
|
||||
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
|
||||
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
|
||||
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
|
||||
{{ desktopUpdateStatus }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
|
||||
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
|
||||
{{ t('core.common.dialog.cancelButton') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
|
||||
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
|
||||
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
|
||||
:loading="desktopUpdateInstalling"
|
||||
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
|
||||
{{ t('core.common.dialog.confirmButton') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
+19
@@ -1,7 +1,26 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface AstrBotDesktopAppUpdateCheckResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string | null;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
interface AstrBotDesktopAppUpdateResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface AstrBotAppUpdaterBridge {
|
||||
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
|
||||
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
|
||||
astrbotDesktop?: {
|
||||
isDesktop: boolean;
|
||||
isDesktopRuntime: () => Promise<boolean>;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.18.1"
|
||||
version = "4.18.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user