Compare commits

...

13 Commits

Author SHA1 Message Date
LIghtJUNction
f2241c8d74 fix: remove unauthenticated hosted core updates 2026-06-30 13:46:35 +08:00
MUHAMED FAZAL PS
b5e29511ac fix: reliably kill shell process tree on Windows timeout (#8822)
* fix: reliably kill shell process tree on Windows timeout

Fixes #8809

* fix: remove redundant import and wrap taskkill in try/except

- Remove 'import subprocess as _sp' (subprocess already imported at top)
- Use subprocess.run directly with DEVNULL for stdout/stderr
- Wrap taskkill in try/except to avoid masking original TimeoutExpired
- If taskkill fails, cleanup failures don't prevent proc.wait() or re-raise

https://buymeacoffee.com/muhamedfazalps

* style: apply ruff formatting to local.py

* test: fix shell component tests to match Popen-based implementation

The tests were monkeypatching subprocess.run but the implementation
now uses subprocess.Popen + communicate() for timeout handling.
Updated tests to mock Popen instead.

Fixes CI Unit Tests failure

* fix: harden windows shell timeout cleanup

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-06-27 00:10:59 +08:00
Weilong Liao
d6738a03f3 fix: preserve jpeg quality during conversion (#9031) 2026-06-26 22:43:15 +08:00
FuShang114
6dd5e1e080 fix: prevent plugin detail marketplace mismatches (#9028) 2026-06-26 22:12:29 +08:00
NayukiChiba
c93bedf04d fix: unify handling of whitespace in streamed message segments and ensure trailing buffers are stripped before sending. (#9029)
* perf(streaming): 将流式消息分段发送的空白过滤逻辑前移至核心方法

- 在 AstrMessageEvent 的 process_buffer 中统一进行 strip 并跳过空段,移除各平台子类的重复处理
- aiocqhttp 平台回退尾部 buffer 直接使用核心逻辑过滤,避免自身再次判断空白
- 简化流式消息发送链路,提升代码可维护性与执行效率

* test(strip-stream): 添加流式消息分段空白过滤的单元测试

- 新增 CollectingMessageEvent 与 CollectingAiocqhttpMessageEvent 辅助类,模拟消息发送与断言
- 覆盖 AstrMessageEvent.process_buffer 对段落前导空白行的 strip 和空段跳过逻辑
- 覆盖 AiocqhttpMessageEvent.send_streaming 回退缓冲区的空白过滤,确保跨平台行为一致

* fix(streaming): 调整流式消息分段发送限速等待位置

- 将限速等待(sleep)从循环末尾移动至消息发送成功后立即执行
- 仅在成功发送文本片段时才进行等待,避免无实际发送时的无谓延迟
- 修复因等待时机不当可能导致的流式消息发送频率异常问题

* test(strip-stream): 添加空白分段时跳过睡眠的单元测试
- 新增测试用例,验证当分段为空时不调用睡眠函数
- 确保处理缓冲区时正确剥离空白字符
- 测试通过验证发送消息的行为

* Delete tests/test_streaming_segment_strip.py

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
2026-06-26 22:11:31 +08:00
Lovely Moe Moli
110cc8736c fix: 'DashboardRequest' object has no attribute 'get_data' (#9021) (#9023) 2026-06-26 22:04:29 +08:00
Light
ce05ac0db6 docs: add spanish readme (#9020)
* docs(i18n): add Spanish (es) translation for README

* docs(i18n): add Spanish (es) translation for README

* fix(i18n): 修复了导航栏的分隔符与多余字符
2026-06-26 21:52:46 +08:00
Jia
9ae33e9344 docs: update Python requirement to 3.12 (#9022) 2026-06-26 19:43:50 +08:00
Weilong Liao
c6b2c65b04 fix: preserve image formats in media handling (#9019)
* fix: preserve image formats in media handling

* fix: address image format review feedback

* fix: avoid tainted image temp path rename
2026-06-26 11:27:32 +08:00
Weilong Liao
00c50b3b92 chore: bump version to 4.26.1 2026-06-25 23:12:10 +08:00
Weilong Liao
090f9008b6 chore: revert "fix: preserve At components when sending messages on qq_official plat…" (#9004)
This reverts commit bc117038fb.
2026-06-25 17:07:08 +08:00
Weilong Liao
473377f340 feat: update permission handling to delegate event context in guarded tools (#9001) 2026-06-25 16:18:04 +08:00
Foolllll
9daf8f0a84 fix: check OnWaitingLLMRequestEvent stop signal before acquiring session lock (#8935) 2026-06-25 16:17:07 +08:00
37 changed files with 1123 additions and 297 deletions

View File

@@ -1,4 +1,4 @@
![astrbot-github-banner-v2-light-0405_副本](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<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
View File

@@ -0,0 +1,289 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<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="FeaturedHelloGitHub" 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.
![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## 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 ☁️:
[![Desplegar en RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](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.
[![Ejecutar en Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](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">
[![Gráfico de historial de estrellas](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.0"
__version__ = "4.26.1"
logger = logging.getLogger("astrbot")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)):

View File

@@ -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 (

View File

@@ -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}")

View File

@@ -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']}")

View File

@@ -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(
{

View File

@@ -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(

View File

@@ -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))

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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:

View File

@@ -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
View 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))

View File

@@ -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
);
});

View File

@@ -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

View File

@@ -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.

View File

@@ -3,7 +3,7 @@
> [!WARNING]
> 你正在直接通过源码来部署本项目,该教程需要您具有一定的技术基础。
>
> 以下教程默认您的设备上已经安装 Python并且版本 `>=3.10`
> 以下教程默认您的设备上已经安装 Python并且版本 `>=3.12`
## 下载/克隆仓库

View File

@@ -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" }

View File

@@ -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

View File

@@ -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

View 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",
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View 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()