mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-14 17:00:17 +08:00
Compare commits
13 Commits
fix/llm_to
...
codex/prop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2241c8d74 | ||
|
|
b5e29511ac | ||
|
|
d6738a03f3 | ||
|
|
6dd5e1e080 | ||
|
|
c93bedf04d | ||
|
|
110cc8736c | ||
|
|
ce05ac0db6 | ||
|
|
9ae33e9344 | ||
|
|
c6b2c65b04 | ||
|
|
00c50b3b92 | ||
|
|
090f9008b6 | ||
|
|
473377f340 | ||
|
|
9daf8f0a84 |
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
|
||||
<div align="center">
|
||||
@@ -7,7 +7,8 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
289
README_es.md
Normal file
289
README_es.md
Normal file
@@ -0,0 +1,289 @@
|
||||

|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://astrbot.app/">Documentación</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Hoja de ruta</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Registro de incidencias</a> |
|
||||
<a href="mailto:community@astrbot.app">Soporte por correo</a>
|
||||
</div>
|
||||
|
||||
AstrBot es una plataforma de chatbot Agent todo en uno de código abierto que se integra con las principales aplicaciones de mensajería instantánea. Proporciona una infraestructura de IA conversacional confiable y escalable para individuos, desarrolladores y equipos. Ya sea que estés construyendo un compañero de IA personal, un servicio de atención al cliente inteligente, un asistente de automatización o una base de conocimiento empresarial, AstrBot te permite crear rápidamente aplicaciones de IA listas para producción dentro de los flujos de trabajo de tu plataforma de mensajería instantánea.
|
||||
|
||||

|
||||
|
||||
## Características principales
|
||||
|
||||
1. 💯 Gratis y de código abierto.
|
||||
2. ✨ Conversaciones con LLM de IA, multimodal, Agent, MCP, habilidades, base de conocimiento, configuración de personalidad, compresión automática de contexto.
|
||||
3. 🤖 Soporta integración con Dify, Alibaba Cloud Bailian, Coze y otras plataformas de Agent.
|
||||
4. 🌐 Multiplataforma: QQ, WeChat Work, Feishu, DingTalk, cuentas oficiales de WeChat, Telegram, Slack y [más](#plataformas-de-mensajería-soportadas).
|
||||
5. 📦 Extensiones mediante plugins con más de 1000 plugins disponibles para instalación en un clic.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) para ejecución aislada y segura de código, llamadas a shell y reutilización de recursos a nivel de sesión.
|
||||
7. 💻 Soporte de WebUI.
|
||||
8. 🌈 Soporte de Web ChatUI con Agent Sandbox integrado y búsqueda web.
|
||||
9. 🌐 Soporte de internacionalización (i18n).
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Juego de roles y compañía emocional</th>
|
||||
<th>✨ Agent proactivo</th>
|
||||
<th>🚀 Capacidades Agentic generales</th>
|
||||
<th>🧩 Más de 1000 plugins de la comunidad</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Inicio rápido
|
||||
|
||||
### Despliegue en un clic
|
||||
|
||||
Para los usuarios que quieran experimentar AstrBot rápidamente, estén familiarizados con el uso de la línea de comandos y puedan instalar un entorno `uv` por su cuenta, recomendamos el método de despliegue en un clic con `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Ejecuta este comando solo la primera vez para inicializar el entorno
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requiere tener [uv](https://docs.astral.sh/uv/) instalado.
|
||||
> AstrBot requiere Python 3.12 o superior. La opción `--python 3.12` asegura que `uv` cree el entorno de la herramienta con Python 3.12.
|
||||
|
||||
> [!NOTE]
|
||||
> Para usuarios de macOS: debido a las comprobaciones de seguridad de macOS, la primera ejecución del comando `astrbot` puede tardar más (aproximadamente 10-20s).
|
||||
|
||||
Actualizar `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot desplegado mediante `uv` **no soporta la actualización a través de la WebUI**. Para actualizar, ejecuta el comando anterior desde la línea de comandos.
|
||||
|
||||
### Despliegue con Docker
|
||||
|
||||
Para usuarios familiarizados con contenedores y que buscan un método de despliegue más estable y listo para producción, recomendamos desplegar AstrBot con Docker / Docker Compose.
|
||||
|
||||
Consulta la documentación oficial: [Desplegar AstrBot con Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Desplegar en RainYun
|
||||
|
||||
Para usuarios que desean un despliegue en un clic y no quieren administrar servidores por sí mismos, recomendamos el servicio de despliegue en la nube en un clic de RainYun ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Despliegue como aplicación de escritorio
|
||||
|
||||
Para usuarios que quieran usar AstrBot en el escritorio y principalmente usen ChatUI, recomendamos AstrBot App.
|
||||
|
||||
Visita [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) para descargar e instalar; este método está diseñado para uso en escritorio y no se recomienda para escenarios de servidor.
|
||||
|
||||
### Despliegue con Launcher
|
||||
|
||||
Para usuarios de escritorio que también desean un despliegue rápido y uso aislado de múltiples instancias, recomendamos AstrBot Launcher.
|
||||
|
||||
Visita [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) para descargar e instalar.
|
||||
|
||||
### Desplegar en Replit
|
||||
|
||||
El despliegue en Replit es mantenido por la comunidad y es adecuado para demostraciones en línea y pruebas ligeras.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
El despliegue mediante AUR está dirigido a usuarios de Arch Linux que prefieren instalar AstrBot a través del flujo de trabajo de paquetes del sistema.
|
||||
|
||||
Ejecuta el siguiente comando para instalar `astrbot-git`, luego inicia AstrBot en tu entorno local.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**Más métodos de despliegue**
|
||||
|
||||
Si necesitas gestión basada en panel o una personalización más profunda, consulta [Despliegue con BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) para la configuración desde la tienda de aplicaciones de BT Panel, [Despliegue con 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) para el despliegue desde el mercado de aplicaciones de 1Panel, [Despliegue con CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) para despliegue visual en NAS/servidor doméstico, y [Despliegue manual](https://docs.astrbot.app/deploy/astrbot/cli.html) para una instalación completamente personalizada desde el código fuente con `uv`.
|
||||
|
||||
## Plataformas de mensajería soportadas
|
||||
|
||||
Conecta AstrBot a tu plataforma de chat favorita.
|
||||
|
||||
| Plataforma | Mantenedor |
|
||||
|---------|---------------|
|
||||
| QQ | Oficial |
|
||||
| Implementación del protocolo OneBot v11 | Oficial |
|
||||
| Telegram | Oficial |
|
||||
| Wecom y Wecom AI Bot | Oficial |
|
||||
| Cuentas oficiales de WeChat | Oficial |
|
||||
| Feishu (Lark) | Oficial |
|
||||
| DingTalk | Oficial |
|
||||
| Slack | Oficial |
|
||||
| Discord | Oficial |
|
||||
| LINE | Oficial |
|
||||
| Satori | Oficial |
|
||||
| KOOK | Oficial |
|
||||
| Misskey | Oficial |
|
||||
| Mattermost | Oficial |
|
||||
| WhatsApp (Próximamente) | Oficial |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Comunidad |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Comunidad |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Comunidad |
|
||||
|
||||
## Servicios de modelo soportados
|
||||
|
||||
| Servicio | Tipo |
|
||||
|---------|---------------|
|
||||
| OpenAI y servicios compatibles | Servicios LLM |
|
||||
| Anthropic | Servicios LLM |
|
||||
| Google Gemini | Servicios LLM |
|
||||
| Moonshot AI | Servicios LLM |
|
||||
| Zhipu AI | Servicios LLM |
|
||||
| DeepSeek | Servicios LLM |
|
||||
| Ollama (Autoalojado) | Servicios LLM |
|
||||
| LM Studio (Autoalojado) | Servicios LLM |
|
||||
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | Servicios LLM (API Gateway, soporta todos los modelos) |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Servicios LLM |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | Servicios LLM |
|
||||
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Servicios LLM |
|
||||
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Servicios LLM |
|
||||
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Servicios LLM |
|
||||
| ModelScope | Servicios LLM |
|
||||
| OneAPI | Servicios LLM |
|
||||
| Dify | Plataformas LLMOps |
|
||||
| Aplicaciones de Alibaba Cloud Bailian | Plataformas LLMOps |
|
||||
| Coze | Plataformas LLMOps |
|
||||
| OpenAI Whisper | Servicios de voz a texto |
|
||||
| SenseVoice | Servicios de voz a texto |
|
||||
| Xiaomi MiMo Omni | Servicios de voz a texto |
|
||||
| OpenAI TTS | Servicios de texto a voz |
|
||||
| Gemini TTS | Servicios de texto a voz |
|
||||
| GPT-Sovits-Inference | Servicios de texto a voz |
|
||||
| GPT-Sovits | Servicios de texto a voz |
|
||||
| FishAudio | Servicios de texto a voz |
|
||||
| Edge TTS | Servicios de texto a voz |
|
||||
| Alibaba Cloud Bailian TTS | Servicios de texto a voz |
|
||||
| Azure TTS | Servicios de texto a voz |
|
||||
| Minimax TTS | Servicios de texto a voz |
|
||||
| Xiaomi MiMo TTS | Servicios de texto a voz |
|
||||
| Volcano Engine TTS | Servicios de texto a voz |
|
||||
|
||||
## ❤️ Patrocinadores
|
||||
|
||||
<p align="center">
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
|
||||
|
||||
## ❤️ Contribuir
|
||||
|
||||
¡Issues y Pull Requests son siempre bienvenidos! No dudes en enviar tus cambios a este proyecto :)
|
||||
|
||||
### Cómo contribuir
|
||||
|
||||
Puedes contribuir revisando issues o ayudando con la revisión de pull requests. Cualquier issue o PR es bienvenido para fomentar la participación de la comunidad. Por supuesto, estas son solo sugerencias: puedes contribuir de la manera que prefieras. Para agregar nuevas funcionalidades, por favor discútelo primero a través de un Issue.
|
||||
|
||||
### Entorno de desarrollo
|
||||
|
||||
AstrBot usa `ruff` para el formateo y linting de código.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
|
||||
## 🌍 Comunidad
|
||||
|
||||
### Grupos de QQ
|
||||
|
||||
- Grupo 1: 322154837 (Lleno)
|
||||
- Grupo 3: 630166526 (Lleno)
|
||||
- Grupo 4: 1077826412 (Lleno)
|
||||
- Grupo 5: 822130018 (Lleno)
|
||||
- Grupo 6: 753075035 (Lleno)
|
||||
- Grupo 7: 743746109 (Lleno)
|
||||
- Grupo 8: 1030353265 (Lleno)
|
||||
- Grupo 9: 1076659624 (Lleno)
|
||||
- Grupo 10: 1078079676 (Lleno)
|
||||
- Grupo 11: 704659519 (Lleno)
|
||||
- Grupo 12: 916228568 (Lleno)
|
||||
- Grupo 13: 1092185289
|
||||
- Grupo 14: 1103419483
|
||||
|
||||
- Grupo de desarrolladores (Charla): 975206796
|
||||
- Grupo de desarrolladores (Formal): 1039761811
|
||||
|
||||
### Servidor de Discord
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
## ❤️ Agradecimientos especiales
|
||||
|
||||
Un agradecimiento especial a todos los contribuidores y desarrolladores de plugins por sus contribuciones a AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
|
||||
</a>
|
||||
|
||||
Además, el nacimiento de este proyecto no habría sido posible sin la ayuda de los siguientes proyectos de código abierto:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - El increíble framework felino
|
||||
|
||||
## ⭐ Historial de estrellas
|
||||
|
||||
> [!TIP]
|
||||
> Si este proyecto te ha ayudado en tu vida o trabajo, o si estás interesado en su desarrollo futuro, por favor dale una estrella al proyecto. Es la fuerza impulsora detrás del mantenimiento de este proyecto de código abierto <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_La compañía y la capacidad nunca deberían estar en conflicto. Lo que aspiramos a crear es un robot que pueda entender emociones, proporcionar compañía genuina y realizar tareas de manera confiable._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<div>
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.0"
|
||||
__version__ = "4.26.1"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -118,18 +118,43 @@ class LocalShellComponent(ShellComponent):
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout or 300,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout or 300)
|
||||
except subprocess.TimeoutExpired:
|
||||
should_kill_parent = sys.platform != "win32"
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
taskkill_result = subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
should_kill_parent = taskkill_result.returncode != 0
|
||||
except Exception:
|
||||
should_kill_parent = True
|
||||
if should_kill_parent:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return {
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
"stdout": _decode_shell_output(stdout),
|
||||
"stderr": _decode_shell_output(stderr),
|
||||
"exit_code": proc.returncode,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@@ -210,7 +210,8 @@ class InternalAgentSubStage(Stage):
|
||||
await event.send_typing()
|
||||
except Exception:
|
||||
logger.warning("send_typing failed", exc_info=True)
|
||||
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
|
||||
if await call_event_hook(event, EventType.OnWaitingLLMRequestEvent):
|
||||
return
|
||||
|
||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||
logger.debug("acquired session lock for llm request")
|
||||
|
||||
@@ -269,10 +269,11 @@ class AstrMessageEvent(abc.ABC):
|
||||
match = re.search(pattern, buffer)
|
||||
if not match:
|
||||
break
|
||||
matched_text = match.group()
|
||||
await self.send(MessageChain([Plain(matched_text)]))
|
||||
matched_text = match.group().strip()
|
||||
if matched_text:
|
||||
await self.send(MessageChain([Plain(matched_text)]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
buffer = buffer[match.end() :]
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
return buffer
|
||||
|
||||
async def send_streaming(
|
||||
|
||||
@@ -228,7 +228,8 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
|
||||
if buffer.strip():
|
||||
buffer = buffer.strip()
|
||||
if buffer:
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import MediaResolver
|
||||
from astrbot.core.utils.media_utils import MediaResolver, detect_image_mime_type_async
|
||||
|
||||
|
||||
class MattermostClient:
|
||||
@@ -168,12 +168,19 @@ class MattermostClient:
|
||||
path = await segment.convert_to_file_path()
|
||||
file_path = Path(path)
|
||||
file_bytes = await asyncio.to_thread(file_path.read_bytes)
|
||||
mime_type = (
|
||||
await detect_image_mime_type_async(
|
||||
file_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
or mimetypes.guess_type(file_path.name)[0]
|
||||
)
|
||||
file_ids.append(
|
||||
await self.upload_file(
|
||||
channel_id,
|
||||
file_bytes,
|
||||
file_path.name,
|
||||
mimetypes.guess_type(file_path.name)[0] or "image/jpeg",
|
||||
mime_type or "image/jpeg",
|
||||
)
|
||||
)
|
||||
elif isinstance(segment, (File, Record, Video)):
|
||||
|
||||
@@ -25,7 +25,7 @@ from tenacity import (
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri
|
||||
|
||||
@@ -747,10 +747,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
file_source = file_path
|
||||
elif i.url:
|
||||
file_source = i.url
|
||||
elif isinstance(i, At):
|
||||
qq_id = getattr(i, "qq", "")
|
||||
if qq_id and qq_id != "all":
|
||||
plain_text += f"<@{qq_id}>"
|
||||
else:
|
||||
logger.debug(f"qq_official 忽略 {i.type}")
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ from astrbot.api.message_components import (
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.media_utils import resolve_media_ref_to_base64_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .satori_adapter import SatoriPlatformAdapter
|
||||
@@ -52,6 +53,27 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
||||
user = login.get("user", {})
|
||||
self.user_id = user.get("id") if user else None
|
||||
|
||||
@staticmethod
|
||||
async def _image_to_data_url(component: Image) -> str | None:
|
||||
"""Resolve an image component to a MIME-aware data URL.
|
||||
|
||||
Args:
|
||||
component: Image message component to resolve.
|
||||
|
||||
Returns:
|
||||
A data URL preserving the detected image MIME type, or None when
|
||||
the image cannot be resolved.
|
||||
"""
|
||||
|
||||
image_ref = component.url or component.file
|
||||
if not image_ref:
|
||||
return None
|
||||
image_data = await resolve_media_ref_to_base64_data(
|
||||
image_ref,
|
||||
media_type="image",
|
||||
)
|
||||
return image_data.to_data_url() if image_data else None
|
||||
|
||||
@classmethod
|
||||
async def send_with_adapter(
|
||||
cls,
|
||||
@@ -184,12 +206,14 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
||||
await self.send(temp_chain)
|
||||
content_parts = []
|
||||
try:
|
||||
image_base64 = await component.convert_to_base64()
|
||||
if image_base64:
|
||||
image_data_url = await self._image_to_data_url(
|
||||
component
|
||||
)
|
||||
if image_data_url:
|
||||
img_chain = MessageChain(
|
||||
[
|
||||
Plain(
|
||||
text=f'<img src="data:image/jpeg;base64,{image_base64}"/>',
|
||||
text=f'<img src="{image_data_url}"/>',
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -228,9 +252,9 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
||||
|
||||
elif isinstance(component, Image):
|
||||
try:
|
||||
image_base64 = await component.convert_to_base64()
|
||||
if image_base64:
|
||||
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||
image_data_url = await self._image_to_data_url(component)
|
||||
if image_data_url:
|
||||
return f'<img src="{image_data_url}"/>'
|
||||
except Exception as e:
|
||||
logger.error(f"图片转换为base64失败: {e}")
|
||||
|
||||
@@ -321,9 +345,9 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
||||
|
||||
elif isinstance(component, Image):
|
||||
try:
|
||||
image_base64 = await component.convert_to_base64()
|
||||
if image_base64:
|
||||
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||
image_data_url = await cls._image_to_data_url(component)
|
||||
if image_data_url:
|
||||
return f'<img src="{image_data_url}"/>'
|
||||
except Exception as e:
|
||||
logger.error(f"图片转换为base64失败: {e}")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Iterable
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
@@ -48,7 +49,7 @@ class SlackMessageEvent(AstrMessageEvent):
|
||||
path = await segment.convert_to_file_path()
|
||||
response = await web_client.files_upload_v2(
|
||||
file=path,
|
||||
filename="image.jpg",
|
||||
filename=Path(path).name,
|
||||
)
|
||||
if not response["ok"]:
|
||||
logger.error(f"Slack file upload failed: {response['error']}")
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import File, Image, Json, Plain, Record
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.media_utils import (
|
||||
MEDIA_MIME_EXTENSIONS,
|
||||
detect_image_mime_type_async,
|
||||
)
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
@@ -78,11 +84,16 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
image_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
suffix = MEDIA_MIME_EXTENSIONS.get(mime_type or "", ".jpg")
|
||||
filename = f"{str(uuid.uuid4())}{suffix}"
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
await asyncio.to_thread(Path(path).write_bytes, image_bytes)
|
||||
data = f"[IMAGE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
@@ -97,8 +108,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
record_bytes = base64.b64decode(record_base64)
|
||||
await asyncio.to_thread(Path(path).write_bytes, record_bytes)
|
||||
data = f"[RECORD]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
|
||||
@@ -29,7 +29,11 @@ from astrbot.core import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.platform.webhook_server import FastAPIWebhookServer
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import MediaResolver
|
||||
from astrbot.core.utils.media_utils import (
|
||||
MEDIA_MIME_EXTENSIONS,
|
||||
MediaResolver,
|
||||
detect_image_mime_type_async,
|
||||
)
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from .wecom_event import WecomPlatformEvent
|
||||
@@ -388,8 +392,7 @@ class WecomPlatformAdapter(Platform):
|
||||
)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
await asyncio.to_thread(Path(path).write_bytes, resp.content)
|
||||
|
||||
try:
|
||||
path_wav = await MediaResolver(
|
||||
@@ -453,9 +456,13 @@ class WecomPlatformAdapter(Platform):
|
||||
media_id,
|
||||
)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
resp.content,
|
||||
default_mime_type=None,
|
||||
)
|
||||
suffix = MEDIA_MIME_EXTENSIONS.get(mime_type or "", ".jpg")
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}{suffix}")
|
||||
await asyncio.to_thread(Path(path).write_bytes, resp.content)
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
elif msgtype == "voice":
|
||||
media_id = msg.get("voice", {}).get("media_id", "")
|
||||
@@ -467,8 +474,7 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
await asyncio.to_thread(Path(path).write_bytes, resp.content)
|
||||
|
||||
try:
|
||||
path_wav = await MediaResolver(
|
||||
|
||||
@@ -29,7 +29,11 @@ from astrbot.api.platform import (
|
||||
from astrbot.core import astrbot_config
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import MediaResolver
|
||||
from astrbot.core.utils.media_utils import (
|
||||
MEDIA_MIME_EXTENSIONS,
|
||||
MediaResolver,
|
||||
detect_image_mime_type_async,
|
||||
)
|
||||
|
||||
from .weixin_oc_client import WeixinOCClient
|
||||
from .weixin_oc_event import WeixinOCMessageEvent
|
||||
@@ -757,11 +761,16 @@ class WeixinOCAdapter(Platform):
|
||||
)
|
||||
else:
|
||||
content = await self.client.download_cdn_bytes(encrypted_query_param)
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
content,
|
||||
default_mime_type=None,
|
||||
)
|
||||
suffix = MEDIA_MIME_EXTENSIONS.get(mime_type or "", ".jpg")
|
||||
image_path = self._save_inbound_media(
|
||||
content,
|
||||
prefix="weixin_oc_img",
|
||||
file_name="image.jpg",
|
||||
fallback_suffix=".jpg",
|
||||
file_name=f"image{suffix}",
|
||||
fallback_suffix=suffix,
|
||||
)
|
||||
return Image.fromFileSystem(str(image_path))
|
||||
|
||||
|
||||
@@ -249,9 +249,10 @@ class _PermissionGuardedTool(FunctionTool):
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
# Delegate to handler first (plugin tools).
|
||||
# @filter.llm_tool decorated tools have a handler attribute, which is the actual callable.
|
||||
if self._wrapped.handler is not None:
|
||||
result = self._wrapped.handler(context, **kwargs)
|
||||
event = context.context.event
|
||||
result = self._wrapped.handler(event, **kwargs)
|
||||
if _inspect.isasyncgen(result):
|
||||
last: Any = None
|
||||
async for item in result:
|
||||
@@ -261,11 +262,12 @@ class _PermissionGuardedTool(FunctionTool):
|
||||
return await result
|
||||
return result
|
||||
|
||||
# Fall back to overridden call() on subclasses (e.g. MCPTool).
|
||||
# If the tool has a "call" method that is not the default FunctionTool.call, invoke it.
|
||||
call_override = getattr(type(self._wrapped), "call", None)
|
||||
if call_override is not None and call_override is not FunctionTool.call:
|
||||
return await self._wrapped.call(context, **kwargs)
|
||||
|
||||
# Compatibility fallback: if the tool has a "run" method, invoke it. This is for legacy tools that don't use the new handler/call interface.
|
||||
run = getattr(self._wrapped, "run", None)
|
||||
if run is not None:
|
||||
event = context.context.event
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
@@ -24,30 +23,6 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
super().__init__(repo_mirror, verify=verify)
|
||||
self.MAIN_PATH = get_astrbot_path()
|
||||
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
|
||||
self.CORE_PACKAGE_BASE_URL = (
|
||||
"https://astrbot-registry.soulter.top/download/astrbot-core"
|
||||
)
|
||||
|
||||
def _build_core_package_url(self, version: str | None) -> str | None:
|
||||
"""Build the hosted core package URL for a release tag.
|
||||
|
||||
Args:
|
||||
version: Release tag, such as ``v4.26.0``.
|
||||
|
||||
Returns:
|
||||
Public package URL, or None when hosted package download is disabled.
|
||||
"""
|
||||
|
||||
if not version or not str(version).startswith("v"):
|
||||
return None
|
||||
|
||||
base_url = os.environ.get(
|
||||
"ASTRBOT_CORE_PACKAGE_BASE_URL",
|
||||
self.CORE_PACKAGE_BASE_URL,
|
||||
).strip()
|
||||
if not base_url:
|
||||
return None
|
||||
return f"{base_url.rstrip('/')}/{version}/source.zip"
|
||||
|
||||
def terminate_child_processes(self) -> None:
|
||||
"""终止当前进程的所有子进程
|
||||
@@ -221,18 +196,15 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
|
||||
) # 避免版本管理混乱
|
||||
|
||||
target_version = None
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
if self.compare_version(VERSION, latest_version) >= 0:
|
||||
raise Exception("当前已经是最新版本。")
|
||||
target_version = latest_version
|
||||
file_url = update_data[0]["zipball_url"]
|
||||
elif str(version).startswith("v"):
|
||||
# 更新到指定版本
|
||||
for data in update_data:
|
||||
if data["tag_name"] == version:
|
||||
target_version = data["tag_name"]
|
||||
file_url = data["zipball_url"]
|
||||
if not file_url:
|
||||
raise Exception(f"未找到版本号为 {version} 的更新文件。")
|
||||
@@ -248,28 +220,6 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
|
||||
zip_path = Path(path)
|
||||
ensure_dir(zip_path.parent)
|
||||
hosted_package_url = self._build_core_package_url(target_version)
|
||||
if hosted_package_url:
|
||||
try:
|
||||
logger.info(
|
||||
f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}"
|
||||
)
|
||||
await self._download_file(
|
||||
hosted_package_url,
|
||||
str(zip_path),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise RuntimeError(
|
||||
"Downloaded hosted package is not a valid ZIP file"
|
||||
)
|
||||
return zip_path
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"从托管存储下载 AstrBot Core 更新包失败: {exc},"
|
||||
"将回退到当前更新源。"
|
||||
)
|
||||
|
||||
await self._download_file(
|
||||
file_url,
|
||||
str(zip_path),
|
||||
|
||||
@@ -10,6 +10,7 @@ import binascii
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -358,14 +359,14 @@ def describe_media_ref(media_ref: object | None) -> str:
|
||||
|
||||
|
||||
def detect_image_mime_type(
|
||||
image_bytes: bytes,
|
||||
image_source: bytes | str | Path,
|
||||
*,
|
||||
default_mime_type: str | None = "image/jpeg",
|
||||
) -> str | None:
|
||||
"""Detect an image MIME type from bytes.
|
||||
"""Detect an image MIME type from encoded bytes or a local path.
|
||||
|
||||
Args:
|
||||
image_bytes: Encoded image bytes to inspect.
|
||||
image_source: Encoded image bytes or a local image path to inspect.
|
||||
default_mime_type: MIME type to return when detection fails.
|
||||
|
||||
Returns:
|
||||
@@ -374,7 +375,12 @@ def detect_image_mime_type(
|
||||
"""
|
||||
|
||||
try:
|
||||
with PILImage.open(io.BytesIO(image_bytes)) as image:
|
||||
image_file = (
|
||||
io.BytesIO(image_source)
|
||||
if isinstance(image_source, bytes)
|
||||
else image_source
|
||||
)
|
||||
with PILImage.open(image_file) as image:
|
||||
image.verify()
|
||||
image_format = str(image.format or "").upper()
|
||||
except Exception:
|
||||
@@ -383,6 +389,29 @@ def detect_image_mime_type(
|
||||
return IMAGE_FORMAT_MIME_TYPES.get(image_format, default_mime_type)
|
||||
|
||||
|
||||
async def detect_image_mime_type_async(
|
||||
image_source: bytes | str | Path,
|
||||
*,
|
||||
default_mime_type: str | None = "image/jpeg",
|
||||
) -> str | None:
|
||||
"""Detect an image MIME type without blocking the event loop.
|
||||
|
||||
Args:
|
||||
image_source: Encoded image bytes or a local image path to inspect.
|
||||
default_mime_type: MIME type to return when detection fails.
|
||||
|
||||
Returns:
|
||||
The detected MIME type, or ``default_mime_type`` when detection fails or
|
||||
the format is unknown.
|
||||
"""
|
||||
|
||||
return await asyncio.to_thread(
|
||||
detect_image_mime_type,
|
||||
image_source,
|
||||
default_mime_type=default_mime_type,
|
||||
)
|
||||
|
||||
|
||||
def _guess_mime_type(path: Path, fallback: str | None = None) -> str | None:
|
||||
"""Guess a MIME type from a filename, with an optional fallback."""
|
||||
return mimetypes.guess_type(path.name)[0] or fallback
|
||||
@@ -418,18 +447,35 @@ async def _materialize_media_ref(
|
||||
suffix = default_suffix or DEFAULT_MEDIA_SUFFIXES.get(media_type, ".bin")
|
||||
|
||||
if media_ref.startswith(("http://", "https://")):
|
||||
parsed = urlparse(media_ref)
|
||||
target_suffix = Path(parsed.path).suffix or suffix
|
||||
target_path = _temp_media_path(media_type, target_suffix)
|
||||
if media_type == "image":
|
||||
target_path = _temp_media_path("image", ".bin")
|
||||
else:
|
||||
parsed = urlparse(media_ref)
|
||||
target_suffix = Path(parsed.path).suffix or suffix
|
||||
target_path = _temp_media_path(media_type, target_suffix)
|
||||
cleanup_paths.append(target_path)
|
||||
try:
|
||||
await download_file(media_ref, str(target_path))
|
||||
except Exception:
|
||||
_cleanup_paths(cleanup_paths)
|
||||
raise
|
||||
mime_type = _guess_mime_type(target_path)
|
||||
if media_type == "image":
|
||||
detected_mime_type = await detect_image_mime_type_async(
|
||||
target_path,
|
||||
default_mime_type=None,
|
||||
)
|
||||
if detected_mime_type:
|
||||
mime_type = detected_mime_type
|
||||
detected_suffix = _extension_from_mime_type(detected_mime_type)
|
||||
if detected_suffix and target_path.suffix.lower() != detected_suffix:
|
||||
detected_path = _temp_media_path("image", detected_suffix)
|
||||
await asyncio.to_thread(target_path.rename, detected_path)
|
||||
cleanup_paths[-1] = detected_path
|
||||
target_path = detected_path
|
||||
return _LocalMediaFile(
|
||||
path=target_path,
|
||||
mime_type=_guess_mime_type(target_path),
|
||||
mime_type=mime_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
)
|
||||
|
||||
@@ -440,10 +486,18 @@ async def _materialize_media_ref(
|
||||
if media_ref.startswith("data:"):
|
||||
mime_type, media_bytes = _parse_base64_data_uri(media_ref)
|
||||
target_suffix = _extension_from_mime_type(mime_type) or suffix
|
||||
if media_type == "image" and target_suffix == suffix:
|
||||
detected_mime_type = await detect_image_mime_type_async(
|
||||
media_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
if detected_mime_type:
|
||||
mime_type = detected_mime_type
|
||||
target_suffix = _extension_from_mime_type(detected_mime_type) or suffix
|
||||
target_path = _temp_media_path(media_type, target_suffix)
|
||||
cleanup_paths.append(target_path)
|
||||
try:
|
||||
target_path.write_bytes(media_bytes)
|
||||
await asyncio.to_thread(target_path.write_bytes, media_bytes)
|
||||
except Exception:
|
||||
_cleanup_paths(cleanup_paths)
|
||||
raise
|
||||
@@ -458,14 +512,26 @@ async def _materialize_media_ref(
|
||||
media_ref.removeprefix("base64://"),
|
||||
error_message="invalid base64 media payload",
|
||||
)
|
||||
target_path = _temp_media_path(media_type, suffix)
|
||||
mime_type = None
|
||||
target_suffix = suffix
|
||||
if media_type == "image":
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
media_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
target_suffix = _extension_from_mime_type(mime_type) or suffix
|
||||
target_path = _temp_media_path(media_type, target_suffix)
|
||||
cleanup_paths.append(target_path)
|
||||
try:
|
||||
target_path.write_bytes(media_bytes)
|
||||
await asyncio.to_thread(target_path.write_bytes, media_bytes)
|
||||
except Exception:
|
||||
_cleanup_paths(cleanup_paths)
|
||||
raise
|
||||
return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths)
|
||||
return _LocalMediaFile(
|
||||
path=target_path,
|
||||
mime_type=mime_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
)
|
||||
|
||||
path = Path(media_ref)
|
||||
path_exists = False
|
||||
@@ -487,14 +553,26 @@ async def _materialize_media_ref(
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
target_path = _temp_media_path(media_type, suffix)
|
||||
mime_type = None
|
||||
target_suffix = suffix
|
||||
if media_type == "image":
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
media_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
target_suffix = _extension_from_mime_type(mime_type) or suffix
|
||||
target_path = _temp_media_path(media_type, target_suffix)
|
||||
cleanup_paths.append(target_path)
|
||||
try:
|
||||
target_path.write_bytes(media_bytes)
|
||||
await asyncio.to_thread(target_path.write_bytes, media_bytes)
|
||||
except Exception:
|
||||
_cleanup_paths(cleanup_paths)
|
||||
raise
|
||||
return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths)
|
||||
return _LocalMediaFile(
|
||||
path=target_path,
|
||||
mime_type=mime_type,
|
||||
cleanup_paths=cleanup_paths,
|
||||
)
|
||||
|
||||
return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
|
||||
|
||||
@@ -707,13 +785,13 @@ class MediaResolver:
|
||||
if self.media_type == "image":
|
||||
async with self.as_path(target_format=target_format) as resolved:
|
||||
try:
|
||||
media_bytes = resolved.read_bytes()
|
||||
media_bytes = await asyncio.to_thread(resolved.read_bytes)
|
||||
except OSError:
|
||||
if strict:
|
||||
raise
|
||||
return None
|
||||
|
||||
mime_type = detect_image_mime_type(
|
||||
mime_type = await detect_image_mime_type_async(
|
||||
media_bytes,
|
||||
default_mime_type=None,
|
||||
)
|
||||
@@ -1191,7 +1269,7 @@ async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
|
||||
|
||||
|
||||
async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
|
||||
"""Ensure the image path points to a JPEG file.
|
||||
"""Ensure JPEG-compatible still images point to a JPEG file.
|
||||
|
||||
Args:
|
||||
image_path: Local image path to inspect and convert when needed.
|
||||
@@ -1200,7 +1278,9 @@ async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
|
||||
|
||||
Returns:
|
||||
The original path when the source is already a JPEG file with a jpg/jpeg
|
||||
suffix or cannot be found; otherwise the converted JPEG path.
|
||||
suffix, cannot be found, has alpha transparency, or is animated. JPEG
|
||||
files with another suffix are copied without re-encoding; other still
|
||||
images are converted to JPEG.
|
||||
|
||||
Raises:
|
||||
Exception: Raised by Pillow when the source file cannot be opened or saved as
|
||||
@@ -1216,45 +1296,66 @@ async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
|
||||
|
||||
with PILImage.open(source_path) as opened_img:
|
||||
image_format = str(opened_img.format or "").upper()
|
||||
image_has_alpha = opened_img.mode in {"RGBA", "LA"} or (
|
||||
opened_img.mode == "P" and "transparency" in opened_img.info
|
||||
)
|
||||
image_is_animated = (
|
||||
getattr(opened_img, "is_animated", False)
|
||||
or getattr(
|
||||
opened_img,
|
||||
"n_frames",
|
||||
1,
|
||||
)
|
||||
> 1
|
||||
)
|
||||
|
||||
if image_format == "JPEG" and source_path.suffix.lower() in {".jpg", ".jpeg"}:
|
||||
return image_path
|
||||
|
||||
if image_has_alpha or image_is_animated:
|
||||
return image_path
|
||||
|
||||
if output_path is None:
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = str(temp_dir / f"media_image_{uuid.uuid4().hex}.jpg")
|
||||
jpeg_output_path = output_path
|
||||
|
||||
try:
|
||||
if image_format == "JPEG":
|
||||
await asyncio.to_thread(shutil.copyfile, source_path, jpeg_output_path)
|
||||
return jpeg_output_path
|
||||
except Exception:
|
||||
if output_path and os.path.exists(output_path):
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Failed to remove failed image output file: %s",
|
||||
e,
|
||||
)
|
||||
raise
|
||||
|
||||
def convert_image_to_jpeg() -> str:
|
||||
converted_img: PILImage.Image | None = None
|
||||
flattened_img: PILImage.Image | None = None
|
||||
|
||||
with PILImage.open(image_path) as opened_img:
|
||||
try:
|
||||
working_img: PILImage.Image = opened_img
|
||||
if opened_img.mode in {"RGBA", "LA"} or (
|
||||
opened_img.mode == "P" and "transparency" in opened_img.info
|
||||
):
|
||||
flattened = PILImage.new("RGB", opened_img.size, (255, 255, 255))
|
||||
flattened_img = flattened
|
||||
alpha_source = opened_img.convert("RGBA")
|
||||
try:
|
||||
flattened.paste(alpha_source, mask=alpha_source.getchannel("A"))
|
||||
finally:
|
||||
alpha_source.close()
|
||||
working_img = flattened
|
||||
elif opened_img.mode != "RGB":
|
||||
if opened_img.mode != "RGB":
|
||||
converted_img = opened_img.convert("RGB")
|
||||
working_img = converted_img
|
||||
|
||||
working_img.save(jpeg_output_path, "JPEG")
|
||||
working_img.save(
|
||||
jpeg_output_path,
|
||||
"JPEG",
|
||||
quality=IMAGE_COMPRESS_DEFAULT_QUALITY,
|
||||
subsampling=0,
|
||||
)
|
||||
return jpeg_output_path
|
||||
finally:
|
||||
if converted_img is not None:
|
||||
converted_img.close()
|
||||
if flattened_img is not None:
|
||||
flattened_img.close()
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(convert_image_to_jpeg)
|
||||
@@ -1381,19 +1482,46 @@ async def extract_video_cover(
|
||||
|
||||
|
||||
def _compress_image_sync(
|
||||
data: bytes,
|
||||
source: bytes | Path,
|
||||
temp_dir: Path,
|
||||
max_size: int,
|
||||
quality: int,
|
||||
optimize: bool,
|
||||
) -> str:
|
||||
"""Run image compression synchronously via ``asyncio.to_thread``."""
|
||||
with PILImage.open(io.BytesIO(data)) as opened_img:
|
||||
) -> str | None:
|
||||
"""Run image compression synchronously via ``asyncio.to_thread``.
|
||||
|
||||
Args:
|
||||
source: Encoded image bytes or a local path to open inside the worker.
|
||||
temp_dir: Directory where the compressed image should be written.
|
||||
max_size: Longest edge of the compressed image in pixels.
|
||||
quality: JPEG output quality in the range 1-100.
|
||||
optimize: Whether Pillow should optimize the saved image.
|
||||
|
||||
Returns:
|
||||
The compressed image path, or ``None`` when the image should be kept as-is.
|
||||
"""
|
||||
fp = io.BytesIO(source) if isinstance(source, bytes) else source
|
||||
with PILImage.open(fp) as opened_img:
|
||||
converted_img: PILImage.Image | None = None
|
||||
|
||||
try:
|
||||
if (
|
||||
getattr(opened_img, "is_animated", False)
|
||||
or getattr(opened_img, "n_frames", 1) > 1
|
||||
):
|
||||
return None
|
||||
|
||||
working_img = opened_img
|
||||
if opened_img.mode != "RGB":
|
||||
image_has_alpha = opened_img.mode in {"RGBA", "LA"} or (
|
||||
opened_img.mode == "P" and "transparency" in opened_img.info
|
||||
)
|
||||
output_format = "PNG" if image_has_alpha else "JPEG"
|
||||
output_suffix = ".png" if image_has_alpha else ".jpg"
|
||||
|
||||
if image_has_alpha and opened_img.mode != "RGBA":
|
||||
converted_img = opened_img.convert("RGBA")
|
||||
working_img = converted_img
|
||||
elif not image_has_alpha and opened_img.mode != "RGB":
|
||||
converted_img = opened_img.convert("RGB")
|
||||
working_img = converted_img
|
||||
assert working_img is not None
|
||||
@@ -1402,8 +1530,11 @@ def _compress_image_sync(
|
||||
working_img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
|
||||
|
||||
new_uuid = uuid.uuid4().hex
|
||||
save_path = temp_dir / f"compressed_{new_uuid}.jpg"
|
||||
working_img.save(save_path, "JPEG", quality=quality, optimize=optimize)
|
||||
save_path = temp_dir / f"compressed_{new_uuid}{output_suffix}"
|
||||
save_kwargs: dict[str, int | bool] = {"optimize": optimize}
|
||||
if output_format == "JPEG":
|
||||
save_kwargs["quality"] = quality
|
||||
working_img.save(save_path, output_format, **save_kwargs)
|
||||
logger.debug(f"Image compressed successfully: {save_path}")
|
||||
return str(save_path)
|
||||
finally:
|
||||
@@ -1431,7 +1562,7 @@ async def compress_image(
|
||||
quality = min(max(int(quality), 1), 100)
|
||||
optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
|
||||
min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024)
|
||||
data = None
|
||||
image_source: bytes | Path | None = None
|
||||
|
||||
def _exceeds_max_size(source: bytes | Path) -> bool:
|
||||
try:
|
||||
@@ -1446,11 +1577,13 @@ async def compress_image(
|
||||
return url_or_path
|
||||
elif url_or_path.startswith("data:image"):
|
||||
_header, encoded = url_or_path.split(",", 1)
|
||||
data = _decode_base64_payload(
|
||||
image_source = _decode_base64_payload(
|
||||
encoded,
|
||||
error_message="invalid image data URI payload",
|
||||
)
|
||||
if len(data) < min_file_size_bytes and not _exceeds_max_size(data):
|
||||
if len(image_source) < min_file_size_bytes and not _exceeds_max_size(
|
||||
image_source
|
||||
):
|
||||
return url_or_path
|
||||
else:
|
||||
local_path = Path(url_or_path)
|
||||
@@ -1460,21 +1593,21 @@ async def compress_image(
|
||||
local_path
|
||||
):
|
||||
return url_or_path
|
||||
with local_path.open("rb") as f:
|
||||
data = f.read()
|
||||
image_source = local_path
|
||||
|
||||
if not data:
|
||||
if image_source is None:
|
||||
return url_or_path
|
||||
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Offload the blocking image processing task to a thread.
|
||||
return await asyncio.to_thread(
|
||||
compressed_path = await asyncio.to_thread(
|
||||
_compress_image_sync,
|
||||
data,
|
||||
image_source,
|
||||
temp_dir,
|
||||
max_size,
|
||||
quality,
|
||||
optimize,
|
||||
)
|
||||
return compressed_path or url_or_path
|
||||
|
||||
@@ -193,6 +193,14 @@ class DashboardRequest:
|
||||
assert self._files_cache is not None
|
||||
return self._files_cache
|
||||
|
||||
async def get_data(self) -> bytes:
|
||||
"""Return the raw request body as bytes.
|
||||
|
||||
Returns:
|
||||
The raw body bytes of the request.
|
||||
"""
|
||||
return await self._request.body()
|
||||
|
||||
|
||||
class DashboardWebSocket:
|
||||
def __init__(self, websocket: WebSocket) -> None:
|
||||
|
||||
@@ -26,6 +26,10 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queu
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
from astrbot.core.utils.media_utils import (
|
||||
MEDIA_MIME_EXTENSIONS,
|
||||
detect_image_mime_type_async,
|
||||
)
|
||||
|
||||
SSE_HEARTBEAT = ": heartbeat\n\n"
|
||||
|
||||
@@ -584,6 +588,22 @@ class ChatService:
|
||||
raise ChatServiceError("Invalid filename")
|
||||
|
||||
await file.save(str(file_path))
|
||||
if attach_type == "image":
|
||||
detected_mime_type = await detect_image_mime_type_async(
|
||||
file_path,
|
||||
default_mime_type=None,
|
||||
)
|
||||
if detected_mime_type:
|
||||
content_type = detected_mime_type
|
||||
detected_suffix = MEDIA_MIME_EXTENSIONS.get(detected_mime_type)
|
||||
if detected_suffix and file_path.suffix.lower() != detected_suffix:
|
||||
target_path = file_path.with_suffix(detected_suffix)
|
||||
if target_path.exists():
|
||||
target_path = (
|
||||
attachments_dir / f"{uuid.uuid4().hex}{detected_suffix}"
|
||||
)
|
||||
await asyncio.to_thread(file_path.rename, target_path)
|
||||
file_path = target_path
|
||||
attachment = await self.db.insert_attachment(
|
||||
path=str(file_path),
|
||||
type=attach_type,
|
||||
|
||||
30
changelogs/v4.26.1.md
Normal file
30
changelogs/v4.26.1.md
Normal file
@@ -0,0 +1,30 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复插件注册的 LLM Tool 在受保护工具权限处理场景下可能无法正常调用的问题。([#9001](https://github.com/AstrBotDevs/AstrBot/pull/9001))
|
||||
- 修复等待 LLM 请求事件在获取会话锁前未及时处理停止信号的问题。([#8935](https://github.com/AstrBotDevs/AstrBot/pull/8935))
|
||||
- 回滚 QQ 官方平台发送消息时保留 At 组件的相关变更,避免引入兼容性问题。([#9004](https://github.com/AstrBotDevs/AstrBot/pull/9004))
|
||||
|
||||
### 其他
|
||||
|
||||
- 补充 Cloudflare Workers 静态资源相关配置。([commit](https://github.com/AstrBotDevs/AstrBot/commit/44df70d2e), [commit](https://github.com/AstrBotDevs/AstrBot/commit/348fe8172))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed a case where plugin-registered LLM tools could fail to invoke when guarded tool permission handling was applied. ([#9001](https://github.com/AstrBotDevs/AstrBot/pull/9001))
|
||||
- Fixed waiting LLM request events so stop signals are checked before acquiring the session lock. ([#8935](https://github.com/AstrBotDevs/AstrBot/pull/8935))
|
||||
- Reverted the QQ Official platform At component preservation change to avoid compatibility regressions. ([#9004](https://github.com/AstrBotDevs/AstrBot/pull/9004))
|
||||
|
||||
### Other
|
||||
|
||||
- Added Cloudflare Workers static asset configuration. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/44df70d2e), [commit](https://github.com/AstrBotDevs/AstrBot/commit/348fe8172))
|
||||
@@ -181,16 +181,30 @@ const selectedInstalledPlugin = computed(() => {
|
||||
return data.find((plugin) => plugin.name === selectedPluginId.value) || null;
|
||||
});
|
||||
|
||||
const normalizeRepoUrl = (value) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase()
|
||||
.replace(/\.git$/, "");
|
||||
|
||||
const selectedMarketPlugin = computed(() => {
|
||||
const market = Array.isArray(pluginMarketData.value)
|
||||
? pluginMarketData.value
|
||||
: [];
|
||||
const installedPlugin = selectedInstalledPlugin.value;
|
||||
const repo = installedPlugin?.repo?.toLowerCase();
|
||||
const marketNameMatch =
|
||||
market.find((item) => item.name === selectedPluginId.value) || null;
|
||||
|
||||
if (selectedDetailTab.value === "market" || !installedPlugin) {
|
||||
return marketNameMatch;
|
||||
}
|
||||
|
||||
const repo = normalizeRepoUrl(installedPlugin.repo);
|
||||
if (!repo) return null;
|
||||
|
||||
return (
|
||||
market.find((item) => item.name === selectedPluginId.value) ||
|
||||
market.find((item) => repo && item.repo?.toLowerCase() === repo) ||
|
||||
null
|
||||
market.find((item) => normalizeRepoUrl(item?.repo) === repo) || null
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> [!WARNING]
|
||||
> You are deploying this project directly from source code. This tutorial requires you to have some technical background.
|
||||
>
|
||||
> This tutorial assumes Python is already installed on your device with version `>=3.10`
|
||||
> This tutorial assumes Python is already installed on your device with version `>=3.12`
|
||||
|
||||
|
||||
## Download/Clone Repository
|
||||
|
||||
@@ -28,7 +28,7 @@ We still recommend the One-Click Launcher above because it is simpler, more auto
|
||||
The legacy installer is a `PowerShell` script, very small (<20KB). It requires `PowerShell` (usually built in on `Windows 10` and newer).
|
||||
|
||||
> [!WARNING]
|
||||
> `Python 3.10` or later must be installed, and environment variables must be configured.
|
||||
> `Python 3.12` or later must be installed, and environment variables must be configured.
|
||||
|
||||
> [!TIP]
|
||||
> If deployment fails, try Docker deployment or manual deployment instead.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> [!WARNING]
|
||||
> 你正在直接通过源码来部署本项目,该教程需要您具有一定的技术基础。
|
||||
>
|
||||
> 以下教程默认您的设备上已经安装 Python,并且版本 `>=3.10`
|
||||
> 以下教程默认您的设备上已经安装 Python,并且版本 `>=3.12`
|
||||
|
||||
## 下载/克隆仓库
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.0"
|
||||
version = "4.26.1"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -3,23 +3,37 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.computer.booters import local as local_booter
|
||||
from astrbot.core.computer.booters.local import LocalShellComponent
|
||||
|
||||
|
||||
class _FakeCompletedProcess:
|
||||
class _FakePopen:
|
||||
def __init__(self, stdout: bytes, stderr: bytes = b"", returncode: int = 0):
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self.returncode = returncode
|
||||
self.pid = 12345
|
||||
|
||||
def communicate(self, timeout=None):
|
||||
return self._stdout, self._stderr
|
||||
|
||||
def wait(self, timeout=None):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeTaskkillResult:
|
||||
def __init__(self, returncode: int):
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
def test_local_shell_component_decodes_utf8_output(monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return _FakeCompletedProcess(stdout="技能内容".encode())
|
||||
return _FakePopen(stdout="技能内容".encode())
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_run)
|
||||
|
||||
result = asyncio.run(LocalShellComponent().exec("dummy"))
|
||||
|
||||
@@ -33,9 +47,9 @@ def test_local_shell_component_prefers_utf8_before_windows_locale(
|
||||
):
|
||||
def fake_run(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return _FakeCompletedProcess(stdout="技能内容".encode())
|
||||
return _FakePopen(stdout="技能内容".encode())
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_run)
|
||||
monkeypatch.setattr(local_booter.os, "name", "nt", raising=False)
|
||||
monkeypatch.setattr(
|
||||
local_booter.locale,
|
||||
@@ -53,9 +67,9 @@ def test_local_shell_component_prefers_utf8_before_windows_locale(
|
||||
def test_local_shell_component_falls_back_to_gbk_on_windows(monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return _FakeCompletedProcess(stdout="微博热搜".encode("gbk"))
|
||||
return _FakePopen(stdout="微博热搜".encode("gbk"))
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_run)
|
||||
monkeypatch.setattr(local_booter.os, "name", "nt", raising=False)
|
||||
monkeypatch.setattr(
|
||||
local_booter.locale,
|
||||
@@ -73,9 +87,9 @@ def test_local_shell_component_falls_back_to_gbk_on_windows(monkeypatch):
|
||||
def test_local_shell_component_falls_back_to_utf8_replace(monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return _FakeCompletedProcess(stdout=b"\xffabc")
|
||||
return _FakePopen(stdout=b"\xffabc")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_run)
|
||||
monkeypatch.setattr(local_booter.os, "name", "posix", raising=False)
|
||||
monkeypatch.setattr(
|
||||
local_booter.locale,
|
||||
@@ -86,3 +100,37 @@ def test_local_shell_component_falls_back_to_utf8_replace(monkeypatch):
|
||||
result = asyncio.run(LocalShellComponent().exec("dummy"))
|
||||
|
||||
assert result["stdout"] == "\ufffdabc"
|
||||
|
||||
|
||||
def test_local_shell_component_falls_back_when_windows_taskkill_fails(monkeypatch):
|
||||
class TimeoutPopen:
|
||||
pid = 12345
|
||||
|
||||
def __init__(self):
|
||||
self.killed = False
|
||||
self.wait_timeout = None
|
||||
|
||||
def communicate(self, timeout=None):
|
||||
raise subprocess.TimeoutExpired(cmd="dummy", timeout=timeout)
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.wait_timeout = timeout
|
||||
|
||||
proc = TimeoutPopen()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *_args, **_kwargs: proc)
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_args, **_kwargs: _FakeTaskkillResult(returncode=1),
|
||||
)
|
||||
monkeypatch.setattr(local_booter.sys, "platform", "win32")
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
asyncio.run(LocalShellComponent().exec("dummy", timeout=1))
|
||||
|
||||
assert proc.killed
|
||||
assert proc.wait_timeout == 5
|
||||
|
||||
@@ -73,6 +73,54 @@ async def test_media_resolver_to_path_detaches_for_component_lifetimes(
|
||||
Path(image_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_from_base64_uses_detected_image_suffix(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 128)).save(image_buffer, format="PNG")
|
||||
image_base64 = base64.b64encode(image_buffer.getvalue()).decode()
|
||||
|
||||
image_path = await Image.fromBase64(image_base64).convert_to_file_path()
|
||||
|
||||
try:
|
||||
assert Path(image_path).suffix == ".png"
|
||||
with PILImage.open(image_path) as resolved_img:
|
||||
assert resolved_img.format == "PNG"
|
||||
finally:
|
||||
Path(image_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_image_without_suffix_uses_detected_image_suffix(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 128)).save(image_buffer, format="PNG")
|
||||
|
||||
async def fake_download_file(_url: str, target_path: str) -> None:
|
||||
Path(target_path).write_bytes(image_buffer.getvalue())
|
||||
|
||||
monkeypatch.setattr(media_utils, "download_file", fake_download_file)
|
||||
|
||||
image_path = await media_utils.MediaResolver(
|
||||
"https://example.com/image?id=123",
|
||||
media_type="image",
|
||||
).to_path()
|
||||
|
||||
try:
|
||||
assert Path(image_path).suffix == ".png"
|
||||
with PILImage.open(image_path) as resolved_img:
|
||||
assert resolved_img.format == "PNG"
|
||||
finally:
|
||||
Path(image_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_audio_ref_to_base64_data_decodes_base64_scheme(
|
||||
tmp_path, monkeypatch
|
||||
@@ -146,6 +194,18 @@ async def test_resolve_image_ref_to_base64_data_detects_png(tmp_path):
|
||||
assert resolved.to_data_url().startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_detect_image_mime_type_accepts_path(tmp_path):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
image_path = tmp_path / "image.png"
|
||||
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
|
||||
|
||||
assert (
|
||||
media_utils.detect_image_mime_type(image_path, default_mime_type=None)
|
||||
== "image/png"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_image_ref_to_base64_data_decodes_data_uri(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
@@ -174,7 +234,7 @@ async def test_ensure_jpeg_converts_png_to_temp_jpg(tmp_path, monkeypatch):
|
||||
temp_dir = tmp_path / "temp"
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
|
||||
image_path = tmp_path / "image.png"
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(image_path)
|
||||
PILImage.new("RGB", (2, 2), (255, 0, 0)).save(image_path)
|
||||
|
||||
converted_path = Path(await media_utils.ensure_jpeg(str(image_path)))
|
||||
|
||||
@@ -185,6 +245,43 @@ async def test_ensure_jpeg_converts_png_to_temp_jpg(tmp_path, monkeypatch):
|
||||
assert converted_img.format == "JPEG"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_jpeg_keeps_alpha_png(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
temp_dir = tmp_path / "temp"
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
|
||||
image_path = tmp_path / "transparent.png"
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(image_path)
|
||||
|
||||
converted_path = await media_utils.ensure_jpeg(str(image_path))
|
||||
|
||||
assert converted_path == str(image_path)
|
||||
assert not temp_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_jpeg_keeps_animated_gif(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
temp_dir = tmp_path / "temp"
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
|
||||
image_path = tmp_path / "animated.gif"
|
||||
PILImage.new("RGB", (2, 2), (255, 0, 0)).save(
|
||||
image_path,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=[PILImage.new("RGB", (2, 2), (0, 0, 255))],
|
||||
duration=100,
|
||||
loop=0,
|
||||
)
|
||||
|
||||
converted_path = await media_utils.ensure_jpeg(str(image_path))
|
||||
|
||||
assert converted_path == str(image_path)
|
||||
assert not temp_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_jpeg_keeps_existing_jpg(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
@@ -200,6 +297,54 @@ async def test_ensure_jpeg_keeps_existing_jpg(tmp_path, monkeypatch):
|
||||
assert not temp_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_image_preserves_alpha_png(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
temp_dir = tmp_path / "temp"
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
|
||||
image_path = tmp_path / "transparent.png"
|
||||
PILImage.new("RGBA", (8, 8), (255, 0, 0, 128)).save(image_path)
|
||||
|
||||
compressed_path = Path(
|
||||
await media_utils.compress_image(str(image_path), max_size=2)
|
||||
)
|
||||
|
||||
try:
|
||||
assert compressed_path != image_path
|
||||
assert compressed_path.suffix == ".png"
|
||||
assert compressed_path.parent == temp_dir
|
||||
with PILImage.open(compressed_path) as compressed_img:
|
||||
assert compressed_img.format == "PNG"
|
||||
assert compressed_img.mode == "RGBA"
|
||||
assert max(compressed_img.size) <= 2
|
||||
assert compressed_img.getpixel((0, 0))[3] == 128
|
||||
finally:
|
||||
compressed_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_image_keeps_animated_gif(tmp_path, monkeypatch):
|
||||
from PIL import Image as PILImage
|
||||
|
||||
temp_dir = tmp_path / "temp"
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(temp_dir))
|
||||
image_path = tmp_path / "animated.gif"
|
||||
PILImage.new("RGB", (8, 8), (255, 0, 0)).save(
|
||||
image_path,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=[PILImage.new("RGB", (8, 8), (0, 0, 255))],
|
||||
duration=100,
|
||||
loop=0,
|
||||
)
|
||||
|
||||
compressed_path = await media_utils.compress_image(str(image_path), max_size=2)
|
||||
|
||||
assert compressed_path == str(image_path)
|
||||
assert not list(temp_dir.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_image_ref_to_base64_data_keeps_base64_scheme_fallback(
|
||||
tmp_path, monkeypatch
|
||||
|
||||
121
tests/test_platform_image_format_preservation.py
Normal file
121
tests/test_platform_image_format_preservation.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Image
|
||||
from astrbot.core.platform.sources.mattermost.client import MattermostClient
|
||||
from astrbot.core.platform.sources.satori.satori_event import SatoriPlatformEvent
|
||||
from astrbot.core.platform.sources.slack.slack_event import SlackMessageEvent
|
||||
from astrbot.core.platform.sources.webchat import webchat_event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_satori_image_data_url_preserves_png_mime_type():
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(
|
||||
image_buffer,
|
||||
format="PNG",
|
||||
)
|
||||
image_ref = (
|
||||
"data:image/png;base64," + base64.b64encode(image_buffer.getvalue()).decode()
|
||||
)
|
||||
|
||||
result = await SatoriPlatformEvent._convert_component_to_satori_static(
|
||||
Image(file=image_ref),
|
||||
)
|
||||
|
||||
assert result.startswith('<img src="data:image/png;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_satori_image_data_url_preserves_jpeg_mime_type():
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGB", (2, 2), (0, 255, 0)).save(
|
||||
image_buffer,
|
||||
format="JPEG",
|
||||
)
|
||||
image_ref = (
|
||||
"data:image/jpeg;base64," + base64.b64encode(image_buffer.getvalue()).decode()
|
||||
)
|
||||
|
||||
result = await SatoriPlatformEvent._convert_component_to_satori_static(
|
||||
Image(file=image_ref),
|
||||
)
|
||||
|
||||
assert result.startswith('<img src="data:image/jpeg;base64,')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webchat_image_attachment_uses_detected_extension(tmp_path, monkeypatch):
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(
|
||||
image_buffer,
|
||||
format="PNG",
|
||||
)
|
||||
image_ref = (
|
||||
"data:image/png;base64," + base64.b64encode(image_buffer.getvalue()).decode()
|
||||
)
|
||||
queue = asyncio.Queue()
|
||||
monkeypatch.setattr(webchat_event, "attachments_dir", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
webchat_event.webchat_queue_mgr,
|
||||
"get_or_create_back_queue",
|
||||
lambda *_args: queue,
|
||||
)
|
||||
|
||||
await webchat_event.WebChatMessageEvent._send(
|
||||
"message-1",
|
||||
MessageChain([Image(file=image_ref)]),
|
||||
"webchat!user!conversation-1",
|
||||
)
|
||||
|
||||
payload = await queue.get()
|
||||
filename = payload["data"].removeprefix("[IMAGE]")
|
||||
assert filename.endswith(".png")
|
||||
assert (tmp_path / filename).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_image_upload_uses_resolved_filename(tmp_path):
|
||||
image_path = tmp_path / "transparent.png"
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(image_path)
|
||||
web_client = AsyncMock()
|
||||
web_client.files_upload_v2.return_value = {
|
||||
"ok": True,
|
||||
"files": [{"url_private": "https://slack.example/files/transparent.png"}],
|
||||
}
|
||||
|
||||
result = await SlackMessageEvent._from_segment_to_slack_block(
|
||||
Image(file=str(image_path)),
|
||||
web_client,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert web_client.files_upload_v2.await_args.kwargs["filename"] == "transparent.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mattermost_image_upload_uses_detected_mime_type(tmp_path):
|
||||
image_path = tmp_path / "transparent.bin"
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(image_path, format="PNG")
|
||||
client = MattermostClient("https://chat.example.com", "test_token")
|
||||
client.upload_file = AsyncMock(return_value="file-id")
|
||||
client.create_post = AsyncMock(return_value={"id": "post-1"})
|
||||
|
||||
await client.send_message_chain(
|
||||
"channel-1",
|
||||
MessageChain([Image(file=str(image_path))]),
|
||||
)
|
||||
|
||||
assert client.upload_file.await_args.args == (
|
||||
"channel-1",
|
||||
Path(image_path).read_bytes(),
|
||||
"transparent.bin",
|
||||
"image/png",
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -27,7 +29,7 @@ class FakeEvent:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocess_converts_images_to_jpeg_and_tracks_temp_files(
|
||||
async def test_preprocess_preserves_image_formats_and_tracks_temp_files(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from PIL import Image as PILImage
|
||||
@@ -39,15 +41,34 @@ async def test_preprocess_converts_images_to_jpeg_and_tracks_temp_files(
|
||||
"get_astrbot_temp_path",
|
||||
lambda: str(temp_dir),
|
||||
)
|
||||
main_image_path = tmp_path / "main.png"
|
||||
reply_image_path = tmp_path / "reply.webp"
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(main_image_path)
|
||||
PILImage.new("RGB", (2, 2), (0, 255, 0)).save(reply_image_path)
|
||||
main_image_buffer = BytesIO()
|
||||
PILImage.new("RGBA", (2, 2), (255, 0, 0, 128)).save(
|
||||
main_image_buffer,
|
||||
format="PNG",
|
||||
)
|
||||
main_image_ref = (
|
||||
"data:image/png;base64,"
|
||||
+ base64.b64encode(main_image_buffer.getvalue()).decode()
|
||||
)
|
||||
|
||||
reply_image = Image(file=str(reply_image_path))
|
||||
reply_image_buffer = BytesIO()
|
||||
PILImage.new("RGB", (2, 2), (0, 255, 0)).save(
|
||||
reply_image_buffer,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=[PILImage.new("RGB", (2, 2), (0, 0, 255))],
|
||||
duration=100,
|
||||
loop=0,
|
||||
)
|
||||
reply_image_ref = (
|
||||
"data:image/gif;base64,"
|
||||
+ base64.b64encode(reply_image_buffer.getvalue()).decode()
|
||||
)
|
||||
|
||||
reply_image = Image(file=reply_image_ref)
|
||||
event = FakeEvent(
|
||||
[
|
||||
Image(file=str(main_image_path)),
|
||||
Image(file=main_image_ref),
|
||||
Reply(
|
||||
id="reply-1",
|
||||
chain=[Plain(text="quoted"), reply_image],
|
||||
@@ -66,16 +87,19 @@ async def test_preprocess_converts_images_to_jpeg_and_tracks_temp_files(
|
||||
main_image = event.get_messages()[0]
|
||||
assert isinstance(main_image, Image)
|
||||
assert main_image.file == main_image.path == main_image.url
|
||||
assert main_image.file.endswith(".jpg")
|
||||
assert main_image.file.endswith(".png")
|
||||
assert main_image.file in event.temporary_local_files
|
||||
with PILImage.open(main_image.file) as converted_img:
|
||||
assert converted_img.format == "JPEG"
|
||||
with PILImage.open(main_image.file) as processed_img:
|
||||
assert processed_img.format == "PNG"
|
||||
assert processed_img.getpixel((0, 0))[3] == 128
|
||||
|
||||
assert reply_image.file == reply_image.path == reply_image.url
|
||||
assert reply_image.file.endswith(".jpg")
|
||||
assert reply_image.file.endswith(".gif")
|
||||
assert reply_image.file in event.temporary_local_files
|
||||
with PILImage.open(reply_image.file) as converted_img:
|
||||
assert converted_img.format == "JPEG"
|
||||
with PILImage.open(reply_image.file) as processed_img:
|
||||
assert processed_img.format == "GIF"
|
||||
assert processed_img.is_animated
|
||||
assert processed_img.n_frames == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -291,7 +291,7 @@ async def test_plugin_updator_install_prefers_download_url(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_prefers_hosted_core_package(
|
||||
async def test_astrbot_updator_uses_release_zipball_for_core_package(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -307,7 +307,7 @@ async def test_astrbot_updator_prefers_hosted_core_package(
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"body": "core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
@@ -315,51 +315,6 @@ async def test_astrbot_updator_prefers_hosted_core_package(
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AstrBot-v99.0.0/README.md", "hosted-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(updator, "_download_file", fake_download_file)
|
||||
|
||||
zip_path = await updator.download_update_package(
|
||||
latest=False,
|
||||
version="v99.0.0",
|
||||
path=tmp_path / "core.zip",
|
||||
)
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
assert calls == ["https://cdn.example/core/v99.0.0/source.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_falls_back_when_hosted_core_package_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
|
||||
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
|
||||
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
|
||||
|
||||
updator = AstrBotUpdator()
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
|
||||
raise RuntimeError("404")
|
||||
Path(path).write_bytes(b"github-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
@@ -373,59 +328,7 @@ async def test_astrbot_updator_falls_back_when_hosted_core_package_fails(
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zip_path.read_bytes() == b"github-core"
|
||||
assert calls == [
|
||||
"https://cdn.example/core/v99.0.0/source.zip",
|
||||
"https://github.example/archive.zip",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_falls_back_when_hosted_core_package_is_not_zip(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
|
||||
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
|
||||
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
|
||||
|
||||
updator = AstrBotUpdator()
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
|
||||
Path(path).write_bytes(b"not a zip")
|
||||
return
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AstrBot-v99.0.0/README.md", "github-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(updator, "_download_file", fake_download_file)
|
||||
|
||||
zip_path = await updator.download_update_package(
|
||||
latest=False,
|
||||
version="v99.0.0",
|
||||
path=tmp_path / "core.zip",
|
||||
)
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
assert calls == [
|
||||
"https://cdn.example/core/v99.0.0/source.zip",
|
||||
"https://github.example/archive.zip",
|
||||
]
|
||||
assert calls == ["https://github.example/archive.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -174,16 +174,19 @@ async def test_check_permission_passes_for_member_when_configured_member():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_tool_delegates_when_permission_passes():
|
||||
async def test_guarded_tool_delegates_handler_with_event_when_permission_passes():
|
||||
_clear_tool_permissions()
|
||||
mgr = FunctionToolManager()
|
||||
|
||||
called = False
|
||||
received_event = None
|
||||
|
||||
async def handler(ctx, **kw):
|
||||
async def handler(event, **kw):
|
||||
nonlocal called
|
||||
nonlocal received_event
|
||||
called = True
|
||||
return "ok"
|
||||
received_event = event
|
||||
return f"ok:{event.get_sender_id()}:{kw['value']}"
|
||||
|
||||
wrapped = FunctionTool(
|
||||
name="delegated",
|
||||
@@ -194,9 +197,10 @@ async def test_guarded_tool_delegates_when_permission_passes():
|
||||
guarded = _PermissionGuardedTool(wrapped, mgr)
|
||||
context = _make_context(role="member")
|
||||
|
||||
result = await guarded.call(context)
|
||||
result = await guarded.call(context, value="sentinel")
|
||||
assert called
|
||||
assert result == "ok"
|
||||
assert received_event is context.context.event
|
||||
assert result == "ok:user_123:sentinel"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -280,7 +284,8 @@ async def test_guarded_tool_handles_async_generator_handler():
|
||||
_clear_tool_permissions()
|
||||
mgr = FunctionToolManager()
|
||||
|
||||
async def gen_handler(ctx, **kw): # type: ignore[misc]
|
||||
async def gen_handler(event, **kw): # type: ignore[misc]
|
||||
assert event is context.context.event
|
||||
yield "A"
|
||||
yield "B"
|
||||
yield "C"
|
||||
|
||||
46
tests/unit/test_webchat_upload_image_format.py
Normal file
46
tests/unit/test_webchat_upload_image_format.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from astrbot.dashboard.services.chat_service import ChatService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webchat_upload_uses_detected_image_type(tmp_path):
|
||||
image_buffer = BytesIO()
|
||||
PILImage.new("RGB", (2, 2), (255, 0, 0)).save(image_buffer, format="JPEG")
|
||||
|
||||
class FakeUploadFile:
|
||||
filename = "pasted.png"
|
||||
content_type = "image/png"
|
||||
|
||||
async def save(self, destination):
|
||||
with open(destination, "wb") as output:
|
||||
output.write(image_buffer.getvalue())
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self):
|
||||
self.inserted = None
|
||||
|
||||
async def insert_attachment(self, path, type, mime_type):
|
||||
self.inserted = {
|
||||
"path": path,
|
||||
"type": type,
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
return SimpleNamespace(attachment_id="attachment-1", path=path)
|
||||
|
||||
fake_db = FakeDatabase()
|
||||
service = ChatService.__new__(ChatService)
|
||||
service.db = fake_db
|
||||
service.attachments_dir = str(tmp_path)
|
||||
|
||||
result = await service.save_uploaded_file(FakeUploadFile())
|
||||
|
||||
assert result["filename"].endswith(".jpg")
|
||||
assert fake_db.inserted["mime_type"] == "image/jpeg"
|
||||
assert fake_db.inserted["type"] == "image"
|
||||
assert (tmp_path / result["filename"]).exists()
|
||||
assert not (tmp_path / "pasted.png").exists()
|
||||
Reference in New Issue
Block a user