From 18a99a25c2add219cfbe9d5779d260730379f768 Mon Sep 17 00:00:00 2001 From: qingyun Date: Sun, 15 Mar 2026 21:05:47 +0800 Subject: [PATCH 01/24] fix(platform): parse QQ official face messages to readable text (#6355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6294 QQ official bot receives emoji/sticker messages as raw XML-like tags: `` This made the LLM unable to understand the emoji content. Changes: - Added `_parse_face_message()` method to parse face message format - Decode base64 `ext` field to get emoji description text - Replace face tags with `[表情:描述]` format for readability Example: - Input: `` - Output: `[表情:[满头问号]]` Co-authored-by: ccsang --- .../qqofficial/qqofficial_platform_adapter.py | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 436be70db..7e31536a1 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -391,6 +391,47 @@ class QQOfficialPlatformAdapter(Platform): else: msg.append(File(name=filename, file=url, url=url)) + @staticmethod + def _parse_face_message(content: str) -> str: + """Parse QQ official face message format and convert to readable text. + + QQ official face message format: + + + The ext field contains base64-encoded JSON with a 'text' field + describing the emoji (e.g., '[满头问号]'). + + Args: + content: The message content that may contain face tags. + + Returns: + Content with face tags replaced by readable emoji descriptions. + """ + import base64 + import json + import re + + def replace_face(match): + face_tag = match.group(0) + # Extract ext field from the face tag + ext_match = re.search(r'ext="([^"]*)"', face_tag) + if ext_match: + try: + ext_encoded = ext_match.group(1) + # Decode base64 and parse JSON + ext_decoded = base64.b64decode(ext_encoded).decode("utf-8") + ext_data = json.loads(ext_decoded) + emoji_text = ext_data.get("text", "") + if emoji_text: + return f"[表情:{emoji_text}]" + except Exception: + pass + # Fallback if parsing fails + return "[表情]" + + # Match face tags: + return re.sub(r"]*>", replace_face, content) + @staticmethod def _parse_from_qqofficial( message: botpy.message.Message @@ -416,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform): abm.group_id = message.group_openid else: abm.sender = MessageMember(message.author.user_openid, "") - abm.message_str = message.content.strip() + # Parse face messages to readable text + abm.message_str = QQOfficialPlatformAdapter._parse_face_message( + message.content.strip() + ) abm.self_id = "unknown_selfid" msg.append(At(qq="qq_official")) msg.append(Plain(abm.message_str)) @@ -432,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform): else: abm.self_id = "" - plain_content = message.content.replace( - "<@!" + str(abm.self_id) + ">", - "", - ).strip() + plain_content = QQOfficialPlatformAdapter._parse_face_message( + message.content.replace( + "<@!" + str(abm.self_id) + ">", + "", + ).strip() + ) QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) abm.message = msg From d41ccb70c53b7d299c98b2a55fafcd19c9d3506f Mon Sep 17 00:00:00 2001 From: Xial <450326581@qq.com> Date: Sun, 15 Mar 2026 06:15:04 -0700 Subject: [PATCH 02/24] fix: replace npm registry URLs with jsdelivr CDN for provider icons (#6340) --- dashboard/src/utils/providerUtils.js | 52 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/dashboard/src/utils/providerUtils.js b/dashboard/src/utils/providerUtils.js index d35941f6f..4bfe3ea6e 100644 --- a/dashboard/src/utils/providerUtils.js +++ b/dashboard/src/utils/providerUtils.js @@ -9,33 +9,33 @@ */ export function getProviderIcon(type) { const icons = { - 'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg', - 'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg', - 'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg', - 'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg', - 'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg', - 'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg', - 'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg', - 'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg', - 'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg', - 'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg', - 'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg', - 'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg', - 'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg', - 'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg', - "coze": "https://registry.npmmirror.com/@lobehub/icons-static-svg/1.66.0/files/icons/coze.svg", - 'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg', + 'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg', + 'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg', + 'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg', + 'anthropic': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/anthropic.svg', + 'ollama': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ollama.svg', + 'google': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/gemini-color.svg', + 'deepseek': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/deepseek.svg', + 'modelscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/modelscope.svg', + 'zhipu': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/zhipu.svg', + 'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg', + 'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg', + 'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', + 'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg', + 'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg', + "coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg", + 'dashscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/alibabacloud-color.svg', 'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg', - 'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg', - 'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg', - 'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg', - 'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg', - '302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg', - 'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg', - 'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg', - 'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg', - 'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg', - 'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg', + 'fastgpt': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fastgpt-color.svg', + 'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg', + 'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg', + 'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg', + '302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg', + 'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg', + 'vllm': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/vllm.svg', + 'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg', + 'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg', + 'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg', "tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png", "compshare": "https://compshare.cn/favicon.ico" }; From 6d055e81e95fb2ad527183f6bdb3b199a70e3b14 Mon Sep 17 00:00:00 2001 From: Trainingcqy Date: Sun, 15 Mar 2026 21:33:30 +0800 Subject: [PATCH 03/24] fix: GIF sent as static image in Telegram adapter (#6329) * fix(telegram): route GIF files to send_animation instead of send_photo * fix: narrow exception in _is_gif to OSError Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * refactor: simplify image send dispatch in send_with_client Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * refactor: simplify image dispatch in _process_chain_items * ruff format --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> --- .../platform/sources/telegram/tg_event.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index e75fb9214..f963969b7 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata from astrbot.core.utils.metrics import Metric +def _is_gif(path: str) -> bool: + if path.lower().endswith(".gif"): + return True + try: + with open(path, "rb") as f: + return f.read(6) in (b"GIF87a", b"GIF89a") + except OSError: + return False + + class TelegramPlatformEvent(AstrMessageEvent): # Telegram 的最大消息长度限制 MAX_MESSAGE_LENGTH = 4096 @@ -291,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent): await client.send_message(text=chunk, **cast(Any, payload)) elif isinstance(i, Image): image_path = await i.convert_to_file_path() - await client.send_photo(photo=image_path, **cast(Any, payload)) + if _is_gif(image_path): + send_coro = client.send_animation + media_kwarg = {"animation": image_path} + else: + send_coro = client.send_photo + media_kwarg = {"photo": image_path} + await send_coro(**media_kwarg, **cast(Any, payload)) elif isinstance(i, File): path = await i.get_file() name = i.name or os.path.basename(path) @@ -406,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent): on_text(i.text) elif isinstance(i, Image): image_path = await i.convert_to_file_path() + if _is_gif(image_path): + action = ChatAction.UPLOAD_VIDEO + send_coro = self.client.send_animation + media_kwarg = {"animation": image_path} + else: + action = ChatAction.UPLOAD_PHOTO + send_coro = self.client.send_photo + media_kwarg = {"photo": image_path} await self._send_media_with_action( self.client, - ChatAction.UPLOAD_PHOTO, - self.client.send_photo, + action, + send_coro, user_name=user_name, - photo=image_path, + **media_kwarg, **cast(Any, payload), ) elif isinstance(i, File): From da520e573a2d2eb5a5b23768c6ed078941fa907d Mon Sep 17 00:00:00 2001 From: xwsjjctz <2251658569@qq.com> Date: Sun, 15 Mar 2026 21:37:44 +0800 Subject: [PATCH 04/24] feat(provider): add MiniMax (#6318) * feat(provider): add MiniMax * feat(provider): reintroduce MiniMax provider configuration and remove deprecated source --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/config/default.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 16d7e89e3..41d09c45d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1132,6 +1132,18 @@ CONFIG_METADATA_2 = { "proxy": "", "custom_headers": {}, }, + "MiniMax": { + "id": "minimax", + "provider": "minimax", + "type": "openai_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://api.minimaxi.com/v1", + "timeout": 120, + "proxy": "", + "custom_headers": {}, + }, "xAI": { "id": "xai", "provider": "xai", From 3ccd70cd4ee54fb61a39ef435fbe98d6de7566f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=9B=E8=96=87Lovie?= <127327672+LovieCode@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:46:01 +0800 Subject: [PATCH 05/24] Fix: AI fails to send media files when tool-calling mode is set to "skills-like". (#6317) * fix: improve send_message_to_user tool description for skills_like mode * fix: enhance description for send_message_to_user tool to clarify usage --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/astr_main_agent_resources.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index b8eaf41d7..d0ef33b81 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]): @dataclass class SendMessageToUserTool(FunctionTool[AstrAgentContext]): name: str = "send_message_to_user" - description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation." + description: str = ( + "Send message to the user. " + "Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. " + "Use this tool to send media files (`image`, `record`, `video`, `file`), " + "or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly." + ) parameters: dict = Field( default_factory=lambda: { From 20efaa5320d1e8dcd6c6d7c8170a8411fa844bc7 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 15 Mar 2026 15:03:52 +0100 Subject: [PATCH 06/24] fix: revise link to model service configuration (#6296) --- docs/zh/what-is-astrbot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/what-is-astrbot.md b/docs/zh/what-is-astrbot.md index 349bbbfb3..cad1411b5 100644 --- a/docs/zh/what-is-astrbot.md +++ b/docs/zh/what-is-astrbot.md @@ -23,7 +23,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 - 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。 - 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。 -- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/config/providers/start) +- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/providers/start) ## 它是如何实现的? From b0e10cf4799e870b8647d57c424bd382fb728b28 Mon Sep 17 00:00:00 2001 From: Rin <144466399+rin259@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:17:12 +0800 Subject: [PATCH 07/24] fix: add null check for delta in streaming mode to prevent AttributeError when tool calls are returned (#6365) --- astrbot/core/provider/sources/openai_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index c40234ed4..982118619 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider): if reasoning: llm_response.reasoning_content = reasoning _y = True - if delta.content: + if delta and delta.content: # Don't strip streaming chunks to preserve spaces between words completion_text = self._normalize_content(delta.content, strip=False) llm_response.result_chain = MessageChain( From 2f51916a73b4054ce4867a0b46827067952e33fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=A3=B9?= <137363396+KBVsent@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:18:37 +0900 Subject: [PATCH 08/24] fix: deduplicate repeated QQ webhook retry callbacks (#6320) --- .../qqofficial_webhook/qo_webhook_server.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index bcd05faf1..7af066020 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from typing import cast import quart @@ -39,6 +40,9 @@ class QQOfficialWebhook: self.client = botpy_client self.event_queue = event_queue self.shutdown_event = asyncio.Event() + # Deduplication cache for webhook retry callbacks. + self._seen_event_ids: dict[str, float] = {} + self._dedup_ttl: int = 60 # seconds async def initialize(self) -> None: logger.info("正在登录到 QQ 官方机器人...") @@ -106,6 +110,22 @@ class QQOfficialWebhook: print(signed) return signed + event_id = msg.get("id") + if event_id: + now = time.monotonic() + # Lazily evict expired entries to prevent unbounded growth. + expired = [ + k + for k, ts in self._seen_event_ids.items() + if now - ts > self._dedup_ttl + ] + for k in expired: + del self._seen_event_ids[k] + if event_id in self._seen_event_ids: + logger.debug(f"Duplicate webhook event {event_id!r}, skipping.") + return {"opcode": 12} + self._seen_event_ids[event_id] = now + if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: event = msg["t"].lower() try: From d87cf897da50d5787444f53beb07172d8d71b59d Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:28:26 +0800 Subject: [PATCH 09/24] Fix TypeError when API returns null choices (#6313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix CreateSkillPayloadTool array schema missing items field The payload parameter's anyOf array variant lacked an items field, causing Gemini API to reject the tool declaration with 400 Bad Request: 'parameters.properties[payload].any_of[1].items: missing field.' Add items: {type: object} to the array variant to satisfy the Gemini API requirement for array type schemas. Fixes #6279 * Fix TypeError when OpenAI-compatible API returns null choices Some providers (e.g. OpenRouter) may return a completion where choices is None rather than an empty list — for instance on rate limiting, content filtering, or transient errors. The existing code used len(completion.choices) which throws TypeError on None. Replace all len(...choices) == 0 checks with 'not ... .choices' which handles both None and empty list. Affects _query_stream, _parse_openai_completion, and _extract_reasoning_content. Fixes #6252 --- astrbot/core/computer/tools/neo_skills.py | 2 +- astrbot/core/provider/sources/openai_source.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py index 492b6e45e..7dbbb6df6 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/computer/tools/neo_skills.py @@ -164,7 +164,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase): "type": "object", "properties": { "payload": { - "anyOf": [{"type": "object"}, {"type": "array"}], + "anyOf": [{"type": "object"}, {"type": "array", "items": {"type": "object"}}], "description": ( "Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " "This only stores content and returns payload_ref; it does not create a candidate or release." diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 982118619..2fae94e1a 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider): state.handle_chunk(chunk) except Exception as e: logger.warning("Saving chunk state error: " + str(e)) - if len(chunk.choices) == 0: + if not chunk.choices: continue delta = chunk.choices[0].delta # logger.debug(f"chunk delta: {delta}") @@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider): ) -> str: """Extract reasoning content from OpenAI ChatCompletion if available.""" reasoning_text = "" - if len(completion.choices) == 0: + if not completion.choices: return reasoning_text if isinstance(completion, ChatCompletion): choice = completion.choices[0] @@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider): """Parse OpenAI ChatCompletion into LLMResponse""" llm_response = LLMResponse("assistant") - if len(completion.choices) == 0: + if not completion.choices: raise Exception("API 返回的 completion 为空。") choice = completion.choices[0] From 420d82df11e7c0af3355a1334eaed59761c0b853 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 15 Mar 2026 22:43:29 +0800 Subject: [PATCH 10/24] chore: ruff format --- astrbot/core/computer/tools/neo_skills.py | 5 +- .../close_duplicate_plugin_publish_issues.py | 196 ++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 scripts/close_duplicate_plugin_publish_issues.py diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py index 7dbbb6df6..e60648144 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/computer/tools/neo_skills.py @@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase): "type": "object", "properties": { "payload": { - "anyOf": [{"type": "object"}, {"type": "array", "items": {"type": "object"}}], + "anyOf": [ + {"type": "object"}, + {"type": "array", "items": {"type": "object"}}, + ], "description": ( "Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " "This only stores content and returns payload_ref; it does not create a candidate or release." diff --git a/scripts/close_duplicate_plugin_publish_issues.py b/scripts/close_duplicate_plugin_publish_issues.py new file mode 100644 index 000000000..b0d1852cc --- /dev/null +++ b/scripts/close_duplicate_plugin_publish_issues.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class Issue: + number: int + title: str + created_at: datetime + url: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Close duplicate open plugin-publish issues while keeping the latest one." + ) + ) + parser.add_argument( + "--repo", + default="AstrBotDevs/AstrBot", + help="GitHub repository in owner/name format.", + ) + parser.add_argument( + "--label", + default="plugin-publish", + help="Issue label to target.", + ) + parser.add_argument( + "--limit", + type=int, + default=1000, + help="Maximum number of open issues to inspect.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Actually close duplicate issues. Defaults to dry-run.", + ) + return parser.parse_args() + + +def run_gh_command(args: list[str]) -> str: + try: + completed = subprocess.run( + args, + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise RuntimeError("GitHub CLI `gh` is not installed or not in PATH.") from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() + stdout = exc.stdout.strip() + details = stderr or stdout or str(exc) + raise RuntimeError(f"`{' '.join(args)}` failed: {details}") from exc + return completed.stdout + + +def load_open_issues(repo: str, label: str, limit: int) -> list[Issue]: + output = run_gh_command( + [ + "gh", + "issue", + "list", + "--repo", + repo, + "--label", + label, + "--state", + "open", + "--limit", + str(limit), + "--json", + "number,title,createdAt,url", + ] + ) + items = json.loads(output) + return [ + Issue( + number=item["number"], + title=item["title"], + created_at=datetime.fromisoformat(item["createdAt"].replace("Z", "+00:00")), + url=item["url"], + ) + for item in items + ] + + +def normalize_title(title: str) -> str: + return " ".join(title.split()).strip() + + +def find_duplicates( + issues: list[Issue], +) -> list[tuple[Issue, list[Issue]]]: + grouped: dict[str, list[Issue]] = defaultdict(list) + for issue in issues: + grouped[normalize_title(issue.title)].append(issue) + + duplicate_groups: list[tuple[Issue, list[Issue]]] = [] + for group in grouped.values(): + if len(group) < 2: + continue + ordered = sorted( + group, + key=lambda issue: (issue.created_at, issue.number), + reverse=True, + ) + keep = ordered[0] + close_candidates = ordered[1:] + duplicate_groups.append((keep, close_candidates)) + + duplicate_groups.sort( + key=lambda item: (item[0].created_at, item[0].number), + reverse=True, + ) + return duplicate_groups + + +def print_plan(duplicate_groups: list[tuple[Issue, list[Issue]]], apply: bool) -> None: + action = "Will close" if apply else "Would close" + if not duplicate_groups: + print("No duplicate open issues found.") + return + + total_to_close = sum(len(close_list) for _, close_list in duplicate_groups) + print(f"Found {len(duplicate_groups)} duplicate title groups.") + print( + f"{action} {total_to_close} issues and keep {len(duplicate_groups)} latest issues." + ) + + for keep, close_list in duplicate_groups: + print() + print(f'Keep #{keep.number} [{keep.created_at.isoformat()}] "{keep.title}"') + print(f" {keep.url}") + for issue in close_list: + print( + f'Close #{issue.number} [{issue.created_at.isoformat()}] "{issue.title}"' + ) + print(f" {issue.url}") + + +def close_duplicates( + repo: str, duplicate_groups: list[tuple[Issue, list[Issue]]] +) -> None: + for keep, close_list in duplicate_groups: + reason = ( + f"Closing as duplicate of #{keep.number}. " + "Keeping the latest open issue with this title." + ) + for issue in close_list: + print(f"Closing #{issue.number} as duplicate of #{keep.number}...") + run_gh_command( + [ + "gh", + "issue", + "close", + str(issue.number), + "--repo", + repo, + "--comment", + reason, + ] + ) + + +def main() -> int: + args = parse_args() + try: + issues = load_open_issues(args.repo, args.label, args.limit) + duplicate_groups = find_duplicates(issues) + print_plan(duplicate_groups, apply=args.apply) + if args.apply and duplicate_groups: + print() + close_duplicates(args.repo, duplicate_groups) + print("Done.") + elif not args.apply: + print() + print("Dry-run only. Re-run with `--apply` to close the duplicates.") + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 64e0183b55dab4319f7f8b4f43696d840bee03a6 Mon Sep 17 00:00:00 2001 From: Stable Genius Date: Sun, 15 Mar 2026 07:51:52 -0700 Subject: [PATCH 11/24] fix: drop Groq reasoning_content from assistant history (#6065) Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> --- astrbot/core/provider/sources/groq_source.py | 8 +++ tests/test_openai_source.py | 67 ++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/astrbot/core/provider/sources/groq_source.py b/astrbot/core/provider/sources/groq_source.py index fcc8f238f..af4029f67 100644 --- a/astrbot/core/provider/sources/groq_source.py +++ b/astrbot/core/provider/sources/groq_source.py @@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial): ) -> None: super().__init__(provider_config, provider_settings) self.reasoning_key = "reasoning" + + def _finally_convert_payload(self, payloads: dict) -> None: + """Groq rejects assistant history items that include reasoning_content.""" + super()._finally_convert_payload(payloads) + for message in payloads.get("messages", []): + if message.get("role") == "assistant": + message.pop("reasoning_content", None) + message.pop("reasoning", None) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 3172097c7..2eea15d10 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -2,6 +2,7 @@ from types import SimpleNamespace import pytest +from astrbot.core.provider.sources.groq_source import ProviderGroq from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial @@ -32,6 +33,21 @@ def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial: ) +def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq: + provider_config = { + "id": "test-groq", + "type": "groq_chat_completion", + "model": "qwen/qwen3-32b", + "key": ["test-key"], + } + if overrides: + provider_config.update(overrides) + return ProviderGroq( + provider_config=provider_config, + provider_settings={}, + ) + + @pytest.mark.asyncio async def test_handle_api_error_content_moderated_removes_images(): provider = _make_provider( @@ -198,6 +214,57 @@ def test_extract_error_text_candidates_truncates_long_response_text(): ) +@pytest.mark.asyncio +async def test_openai_payload_keeps_reasoning_content_in_assistant_history(): + provider = _make_provider() + try: + payloads = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "think", "think": "step 1"}, + {"type": "text", "text": "final answer"}, + ], + } + ] + } + + provider._finally_convert_payload(payloads) + + assistant_message = payloads["messages"][0] + assert assistant_message["content"] == [{"type": "text", "text": "final answer"}] + assert assistant_message["reasoning_content"] == "step 1" + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_groq_payload_drops_reasoning_content_from_assistant_history(): + provider = _make_groq_provider() + try: + payloads = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "think", "think": "step 1"}, + {"type": "text", "text": "final answer"}, + ], + } + ] + } + + provider._finally_convert_payload(payloads) + + assistant_message = payloads["messages"][0] + assert assistant_message["content"] == [{"type": "text", "text": "final answer"}] + assert "reasoning_content" not in assistant_message + assert "reasoning" not in assistant_message + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_handle_api_error_content_moderated_without_images_raises(): provider = _make_provider( From d936bb0a101bb904d8da4e2d124dd3d16b8f0f34 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 01:23:51 +0800 Subject: [PATCH 12/24] Refactor checklist items in PR template Duplicated checklist items in the pull request template for clarity and emphasis. --- .github/PULL_REQUEST_TEMPLATE.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 70bb8f30c..341581157 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,7 +21,23 @@ -- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc. -- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**. -- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`. -- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code. +- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。 + / If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc. + +- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。 + / My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**. + +- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。 + / I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`. + +- [ ] 😮 我的更改没有引入恶意代码。 + / My changes do not introduce malicious code. + +- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。 + / I have read and understood all the above and confirm this PR follows the rules. + +- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。 + / I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch. + +- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。 + / I **did not** read the above carefully before submitting. From a3fa8a5a7ccd03f01a5ef5c51fc93e324d9cedc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:28:39 +0000 Subject: [PATCH 13/24] Initial plan From dd89a4b33418a6b86e28e3f859ca63be60d69973 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:30:29 +0000 Subject: [PATCH 14/24] feat: add PR checklist enforcement workflow Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com> --- .github/workflows/pr-checklist-check.yml | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/pr-checklist-check.yml diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml new file mode 100644 index 000000000..ce57c8d7d --- /dev/null +++ b/.github/workflows/pr-checklist-check.yml @@ -0,0 +1,69 @@ +# This workflow checks whether the PR author checked the "I did NOT read" item +# in the PR checklist. If so, it posts a reminder comment and closes the PR. +name: PR Checklist Check + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + check-checklist: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Check if the "did not read" item is checked + id: check + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request.body || ''; + // Match the last checklist item being checked (- [x] ⚠️ 我**没有**认真阅读) + const violationPattern = /- \[x\] ⚠️ 我\*\*没有\*\*认真阅读以上内容,直接提交。/; + return violationPattern.test(body); + result-encoding: string + + - name: Comment and close PR if checklist violated + if: steps.check.outputs.result == 'true' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `👋 @${author} + + 您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 + + 请按照以下规则重新提交 PR: + + 1. 请仔细阅读 PR 模板中的所有说明。 + 2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 + 3. 完成上述检查后,请重新发起 PR。 + + 本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 + + --- + + Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. + + Please follow these rules and reopen a new PR: + + 1. Read all the instructions in the PR template carefully. + 2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). + 3. Once you have reviewed everything, please open a new PR. + + This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏`, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed', + }); From 2bbca887cea1f7a08172adfd92ac9260d32fcd1f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 01:46:07 +0800 Subject: [PATCH 15/24] Refine PR checklist validation and closure message Updated the checklist validation script and modified the comment for PR closure. --- .github/workflows/pr-checklist-check.yml | 40 +++++------------------- 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index ce57c8d7d..2f5a8eab8 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -13,15 +13,18 @@ jobs: pull-requests: write steps: - - name: Check if the "did not read" item is checked + - name: Check if user checked "我没有认真阅读" id: check uses: actions/github-script@v7 with: script: | const body = context.payload.pull_request.body || ''; - // Match the last checklist item being checked (- [x] ⚠️ 我**没有**认真阅读) - const violationPattern = /- \[x\] ⚠️ 我\*\*没有\*\*认真阅读以上内容,直接提交。/; - return violationPattern.test(body); + + // 只匹配:整行是 - [x] + 包含“没有认真阅读” + const regex = /-\s*\[\s*x\s*\].*我\*\*没有\*\*认真阅读以上内容/im; + const isBad = regex.test(body); + + return isBad; result-encoding: string - name: Comment and close PR if checklist violated @@ -38,32 +41,3 @@ jobs: issue_number: prNumber, body: `👋 @${author} - 您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 - - 请按照以下规则重新提交 PR: - - 1. 请仔细阅读 PR 模板中的所有说明。 - 2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 - 3. 完成上述检查后,请重新发起 PR。 - - 本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 - - --- - - Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. - - Please follow these rules and reopen a new PR: - - 1. Read all the instructions in the PR template carefully. - 2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). - 3. Once you have reviewed everything, please open a new PR. - - This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏`, - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', - }); From 11c840953aa11001d0d0745dcf740f4291cf8605 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 01:49:49 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index 2f5a8eab8..78342f063 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -41,3 +41,32 @@ jobs: issue_number: prNumber, body: `👋 @${author} +您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 + +请按照以下规则重新提交 PR: + +1. 请仔细阅读 PR 模板中的所有说明。 +2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 +3. 完成上述检查后,请重新发起 PR。 + +本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 + +--- + +Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. + +Please follow these rules and reopen a new PR: + +1. Read all the instructions in the PR template carefully. +2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). +3. Once you have reviewed everything, please open a new PR. + +This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏` + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); From 6b3868b4be24399f2e4125365a27055593aca1a3 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:11:15 +0800 Subject: [PATCH 17/24] Update pr-checklist-check.yml --- .github/workflows/pr-checklist-check.yml | 36 +++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index 78342f063..6346ef4e0 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -3,7 +3,7 @@ name: PR Checklist Check on: - pull_request: + pull_request_target: types: [opened, edited, reopened, synchronize] jobs: @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + issues: write steps: - name: Check if user checked "我没有认真阅读" @@ -20,8 +21,8 @@ jobs: script: | const body = context.payload.pull_request.body || ''; - // 只匹配:整行是 - [x] + 包含“没有认真阅读” - const regex = /-\s*\[\s*x\s*\].*我\*\*没有\*\*认真阅读以上内容/im; + // 精确匹配完整的 checklist 项:- [x] 或 - [X] 后跟完整句子,忽略大小写和多余空格 + const regex = /-+\s*\[\s*[xX]\s*\]\s*⚠️\s*我\*\*没有\*\*认真阅读以上内容,直接提交。/im; const isBad = regex.test(body); return isBad; @@ -39,29 +40,30 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `👋 @${author} + body: | + 👋 @${author} -您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 + 您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 -请按照以下规则重新提交 PR: + 请按照以下规则重新提交 PR: -1. 请仔细阅读 PR 模板中的所有说明。 -2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 -3. 完成上述检查后,请重新发起 PR。 + 1. 请仔细阅读 PR 模板中的所有说明。 + 2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 + 3. 完成上述检查后,请重新发起 PR。 -本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 + 本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 ---- + --- -Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. + Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. -Please follow these rules and reopen a new PR: + Please follow these rules and reopen a new PR: -1. Read all the instructions in the PR template carefully. -2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). -3. Once you have reviewed everything, please open a new PR. + 1. Read all the instructions in the PR template carefully. + 2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). + 3. Once you have reviewed everything, please open a new PR. -This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏` + This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏 }); await github.rest.pulls.update({ From 84e880af5f3861652d7c1a448adef4c70b9235ff Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:21:05 +0800 Subject: [PATCH 18/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 91 ++++++++++++++---------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index 6346ef4e0..01c97f4cb 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -1,5 +1,3 @@ -# This workflow checks whether the PR author checked the "I did NOT read" item -# in the PR checklist. If so, it posts a reminder comment and closes the PR. name: PR Checklist Check on: @@ -9,6 +7,7 @@ on: jobs: check-checklist: runs-on: ubuntu-latest + permissions: pull-requests: write issues: write @@ -21,54 +20,70 @@ jobs: script: | const body = context.payload.pull_request.body || ''; - // 精确匹配完整的 checklist 项:- [x] 或 - [X] 后跟完整句子,忽略大小写和多余空格 - const regex = /-+\s*\[\s*[xX]\s*\]\s*⚠️\s*我\*\*没有\*\*认真阅读以上内容,直接提交。/im; + // 宽松匹配 checklist + const regex = /-\s*\[\s*x\s*\]\s*.*我\s*\*?\*?没有\s*\*?\*?认真阅读/im; + const isBad = regex.test(body); - return isBad; - result-encoding: string - + core.setOutput("bad", isBad); + - name: Comment and close PR if checklist violated - if: steps.check.outputs.result == 'true' + if: steps.check.outputs.bad == 'true' uses: actions/github-script@v7 with: script: | - const prNumber = context.payload.pull_request.number; - const author = context.payload.pull_request.user.login; + const pr = context.payload.pull_request; + const prNumber = pr.number; + const author = pr.user.login; - await github.rest.issues.createComment({ + // 防止重复评论 + const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, - body: | - 👋 @${author} - - 您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 - - 请按照以下规则重新提交 PR: - - 1. 请仔细阅读 PR 模板中的所有说明。 - 2. 本次开发请**基于 \`dev\` 分支**进行,并将 PR 目标分支设置为**开发分支(\`dev\`)**(除非极其紧急的情况才允许合并到主分支)。 - 3. 完成上述检查后,请重新发起 PR。 - - 本 PR 已自动关闭,请按规范重新拉起。感谢您的贡献!🙏 - - --- - - Your PR failed the checklist validation — the item "I **did not** read the above carefully before submitting" was checked. - - Please follow these rules and reopen a new PR: - - 1. Read all the instructions in the PR template carefully. - 2. Make sure your development is **based on the \`dev\` branch**, and set the PR target branch to the **development branch (\`dev\`)** (only merge to main if extremely urgent). - 3. Once you have reviewed everything, please open a new PR. - - This PR has been automatically closed. Please reopen a correct one. Thank you for your contribution! 🙏 + issue_number: prNumber }); + const already = comments.data.some(c => + c.body.includes("PR 未通过检查清单校验") + ); + + if (!already) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: ` +👋 @${author} + +您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 + +请按照以下规则重新提交 PR: + +1. 请仔细阅读 PR 模板中的所有说明。 +2. 本次开发请 **基于 \`dev\` 分支** 进行,并将 PR 目标分支设置为 **开发分支(\`dev\`)**。 +3. 完成检查后重新提交 PR。 + +本 PR 已自动关闭,请按规范重新拉起。感谢贡献! 🙏 + +--- + +Your PR failed the checklist validation — the item +"I **did not** read the instructions carefully before submitting" was checked. + +Please: + +1. Read the PR template carefully +2. Base your work on the **\`dev\` branch** +3. Open a new PR after fixing the checklist + +This PR has been automatically closed. Thank you! 🙏 +` + }); + } + await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, - state: 'closed' - }); + state: "closed" + }); \ No newline at end of file From ceb32dce9f193f8acd98dc10f16a1f2b04864114 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:24:01 +0800 Subject: [PATCH 19/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 69 ++++++------------------ 1 file changed, 17 insertions(+), 52 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index 01c97f4cb..fb3fdff3a 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -13,77 +13,42 @@ jobs: issues: write steps: - - name: Check if user checked "我没有认真阅读" + - name: Check checklist id: check uses: actions/github-script@v7 with: script: | - const body = context.payload.pull_request.body || ''; + const body = context.payload.pull_request.body || ""; - // 宽松匹配 checklist - const regex = /-\s*\[\s*x\s*\]\s*.*我\s*\*?\*?没有\s*\*?\*?认真阅读/im; + const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i; - const isBad = regex.test(body); + const bad = regex.test(body); - core.setOutput("bad", isBad); - - - name: Comment and close PR if checklist violated + core.setOutput("bad", bad); + + - name: Close PR if violated if: steps.check.outputs.bad == 'true' uses: actions/github-script@v7 with: script: | const pr = context.payload.pull_request; - const prNumber = pr.number; - const author = pr.user.login; - // 防止重复评论 - const comments = await github.rest.issues.listComments({ + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber + issue_number: pr.number, + body: `👋 @${pr.user.login} + +检测到你勾选了 **“我没有认真阅读”**。 + +请重新阅读 PR 模板并重新提交 PR。 + +This PR has been automatically closed.` }); - const already = comments.data.some(c => - c.body.includes("PR 未通过检查清单校验") - ); - - if (!already) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: ` -👋 @${author} - -您的 PR 未通过检查清单校验 —— 检测到您勾选了「我**没有**认真阅读以上内容,直接提交」。 - -请按照以下规则重新提交 PR: - -1. 请仔细阅读 PR 模板中的所有说明。 -2. 本次开发请 **基于 \`dev\` 分支** 进行,并将 PR 目标分支设置为 **开发分支(\`dev\`)**。 -3. 完成检查后重新提交 PR。 - -本 PR 已自动关闭,请按规范重新拉起。感谢贡献! 🙏 - ---- - -Your PR failed the checklist validation — the item -"I **did not** read the instructions carefully before submitting" was checked. - -Please: - -1. Read the PR template carefully -2. Base your work on the **\`dev\` branch** -3. Open a new PR after fixing the checklist - -This PR has been automatically closed. Thank you! 🙏 -` - }); - } - await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: prNumber, + pull_number: pr.number, state: "closed" }); \ No newline at end of file From 7e3c32b82878ba1c48451cf3d6b62a0cde2df0e6 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:29:33 +0800 Subject: [PATCH 20/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index fb3fdff3a..f93eac126 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -5,7 +5,7 @@ on: types: [opened, edited, reopened, synchronize] jobs: - check-checklist: + check: runs-on: ubuntu-latest permissions: @@ -19,14 +19,11 @@ jobs: with: script: | const body = context.payload.pull_request.body || ""; - const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i; - const bad = regex.test(body); - core.setOutput("bad", bad); - - name: Close PR if violated + - name: Close PR if: steps.check.outputs.bad == 'true' uses: actions/github-script@v7 with: @@ -37,13 +34,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: `👋 @${pr.user.login} - -检测到你勾选了 **“我没有认真阅读”**。 - -请重新阅读 PR 模板并重新提交 PR。 - -This PR has been automatically closed.` + body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。` }); await github.rest.pulls.update({ From bc3b5e58a417d145f355b76160d4d1201ce4132e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:44:05 +0800 Subject: [PATCH 21/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index f93eac126..26b145a05 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -23,7 +23,7 @@ jobs: const bad = regex.test(body); core.setOutput("bad", bad); - - name: Close PR + - name: Close PR if checklist violated if: steps.check.outputs.bad == 'true' uses: actions/github-script@v7 with: @@ -42,4 +42,42 @@ jobs: repo: context.repo.repo, pull_number: pr.number, state: "closed" - }); \ No newline at end of file + }); + + - name: Check target branch + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const base = pr.base.ref; + + if (base !== "dev") { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `⚠️ 当前 PR 的 **目标分支是 \`${base}\`**。 + +建议将 PR 合并到 **\`dev\` 分支(开发分支)**,这样通常可以 **更快被合并**。 + +除非你的 PR **非常紧急**,否则请不要直接提交到 **\`master\` 分支**。` + }); + } + + - name: Check source branch + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const head = pr.head.ref; + + if (head !== "dev") { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `💡 当前 PR 的 **源分支是 \`${head}\`**。 + +建议 **基于 \`dev\` 分支进行开发**,并及时从 \`dev\` 拉取更新,这样可以显著减少 **合并冲突的可能性**。` + }); + } \ No newline at end of file From b795f804a75c0aa9e49b66b2bafe6e6c9baa4b3f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 16 Mar 2026 02:51:39 +0800 Subject: [PATCH 22/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20pr-checklist-check.y?= =?UTF-8?q?ml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checklist-check.yml | 42 ++---------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/.github/workflows/pr-checklist-check.yml b/.github/workflows/pr-checklist-check.yml index 26b145a05..f93eac126 100644 --- a/.github/workflows/pr-checklist-check.yml +++ b/.github/workflows/pr-checklist-check.yml @@ -23,7 +23,7 @@ jobs: const bad = regex.test(body); core.setOutput("bad", bad); - - name: Close PR if checklist violated + - name: Close PR if: steps.check.outputs.bad == 'true' uses: actions/github-script@v7 with: @@ -42,42 +42,4 @@ jobs: repo: context.repo.repo, pull_number: pr.number, state: "closed" - }); - - - name: Check target branch - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - const base = pr.base.ref; - - if (base !== "dev") { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: `⚠️ 当前 PR 的 **目标分支是 \`${base}\`**。 - -建议将 PR 合并到 **\`dev\` 分支(开发分支)**,这样通常可以 **更快被合并**。 - -除非你的 PR **非常紧急**,否则请不要直接提交到 **\`master\` 分支**。` - }); - } - - - name: Check source branch - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - const head = pr.head.ref; - - if (head !== "dev") { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: `💡 当前 PR 的 **源分支是 \`${head}\`**。 - -建议 **基于 \`dev\` 分支进行开发**,并及时从 \`dev\` 拉取更新,这样可以显著减少 **合并冲突的可能性**。` - }); - } \ No newline at end of file + }); \ No newline at end of file From 92c31192de85e64a9939b2d8098cffd8677eaff5 Mon Sep 17 00:00:00 2001 From: stevessr <89645372+stevessr@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:34:21 +0800 Subject: [PATCH 23/24] perf: enhance umo processing compatibility (#5996) --- astrbot/core/umop_config_router.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/astrbot/core/umop_config_router.py b/astrbot/core/umop_config_router.py index d8b010d50..c2588e6c2 100644 --- a/astrbot/core/umop_config_router.py +++ b/astrbot/core/umop_config_router.py @@ -25,12 +25,22 @@ class UmopConfigRouter: ) self.umop_to_conf_id = sp_data + @staticmethod + def _split_umo(umo: str) -> tuple[str, str, str] | None: + """将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'""" + if not isinstance(umo, str): + return None + parts = umo.split(":", 2) + if len(parts) != 3: + return None + return parts[0], parts[1], parts[2] + def _is_umo_match(self, p1: str, p2: str) -> bool: """判断 p2 umo 是否逻辑包含于 p1 umo""" - p1_ls = p1.split(":") - p2_ls = p2.split(":") + p1_ls = self._split_umo(p1) + p2_ls = self._split_umo(p2) - if len(p1_ls) != 3 or len(p2_ls) != 3: + if p1_ls is None or p2_ls is None: return False # 非法格式 return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls)) @@ -62,7 +72,7 @@ class UmopConfigRouter: """ for part in new_routing: - if not isinstance(part, str) or len(part.split(":")) != 3: + if self._split_umo(part) is None: raise ValueError( "umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", ) @@ -81,7 +91,7 @@ class UmopConfigRouter: ValueError: 如果 umo 格式不正确 """ - if not isinstance(umo, str) or len(umo.split(":")) != 3: + if self._split_umo(umo) is None: raise ValueError( "umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", ) @@ -99,7 +109,7 @@ class UmopConfigRouter: ValueError: 当 umo 格式不正确时抛出 """ - if not isinstance(umo, str) or len(umo.split(":")) != 3: + if self._split_umo(umo) is None: raise ValueError( "umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", ) From 65decfbe87effba84fb66b334958219a2492a4fa Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 16 Mar 2026 12:39:39 +0800 Subject: [PATCH 24/24] chore: remove unused scripts for closing duplicate plugin publish issues and generating changelog --- .../close_duplicate_plugin_publish_issues.py | 196 -------------- scripts/generate_changelog.py | 253 ------------------ 2 files changed, 449 deletions(-) delete mode 100644 scripts/close_duplicate_plugin_publish_issues.py delete mode 100755 scripts/generate_changelog.py diff --git a/scripts/close_duplicate_plugin_publish_issues.py b/scripts/close_duplicate_plugin_publish_issues.py deleted file mode 100644 index b0d1852cc..000000000 --- a/scripts/close_duplicate_plugin_publish_issues.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from collections import defaultdict -from dataclasses import dataclass -from datetime import datetime - - -@dataclass(frozen=True) -class Issue: - number: int - title: str - created_at: datetime - url: str - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Close duplicate open plugin-publish issues while keeping the latest one." - ) - ) - parser.add_argument( - "--repo", - default="AstrBotDevs/AstrBot", - help="GitHub repository in owner/name format.", - ) - parser.add_argument( - "--label", - default="plugin-publish", - help="Issue label to target.", - ) - parser.add_argument( - "--limit", - type=int, - default=1000, - help="Maximum number of open issues to inspect.", - ) - parser.add_argument( - "--apply", - action="store_true", - help="Actually close duplicate issues. Defaults to dry-run.", - ) - return parser.parse_args() - - -def run_gh_command(args: list[str]) -> str: - try: - completed = subprocess.run( - args, - check=True, - capture_output=True, - text=True, - ) - except FileNotFoundError as exc: - raise RuntimeError("GitHub CLI `gh` is not installed or not in PATH.") from exc - except subprocess.CalledProcessError as exc: - stderr = exc.stderr.strip() - stdout = exc.stdout.strip() - details = stderr or stdout or str(exc) - raise RuntimeError(f"`{' '.join(args)}` failed: {details}") from exc - return completed.stdout - - -def load_open_issues(repo: str, label: str, limit: int) -> list[Issue]: - output = run_gh_command( - [ - "gh", - "issue", - "list", - "--repo", - repo, - "--label", - label, - "--state", - "open", - "--limit", - str(limit), - "--json", - "number,title,createdAt,url", - ] - ) - items = json.loads(output) - return [ - Issue( - number=item["number"], - title=item["title"], - created_at=datetime.fromisoformat(item["createdAt"].replace("Z", "+00:00")), - url=item["url"], - ) - for item in items - ] - - -def normalize_title(title: str) -> str: - return " ".join(title.split()).strip() - - -def find_duplicates( - issues: list[Issue], -) -> list[tuple[Issue, list[Issue]]]: - grouped: dict[str, list[Issue]] = defaultdict(list) - for issue in issues: - grouped[normalize_title(issue.title)].append(issue) - - duplicate_groups: list[tuple[Issue, list[Issue]]] = [] - for group in grouped.values(): - if len(group) < 2: - continue - ordered = sorted( - group, - key=lambda issue: (issue.created_at, issue.number), - reverse=True, - ) - keep = ordered[0] - close_candidates = ordered[1:] - duplicate_groups.append((keep, close_candidates)) - - duplicate_groups.sort( - key=lambda item: (item[0].created_at, item[0].number), - reverse=True, - ) - return duplicate_groups - - -def print_plan(duplicate_groups: list[tuple[Issue, list[Issue]]], apply: bool) -> None: - action = "Will close" if apply else "Would close" - if not duplicate_groups: - print("No duplicate open issues found.") - return - - total_to_close = sum(len(close_list) for _, close_list in duplicate_groups) - print(f"Found {len(duplicate_groups)} duplicate title groups.") - print( - f"{action} {total_to_close} issues and keep {len(duplicate_groups)} latest issues." - ) - - for keep, close_list in duplicate_groups: - print() - print(f'Keep #{keep.number} [{keep.created_at.isoformat()}] "{keep.title}"') - print(f" {keep.url}") - for issue in close_list: - print( - f'Close #{issue.number} [{issue.created_at.isoformat()}] "{issue.title}"' - ) - print(f" {issue.url}") - - -def close_duplicates( - repo: str, duplicate_groups: list[tuple[Issue, list[Issue]]] -) -> None: - for keep, close_list in duplicate_groups: - reason = ( - f"Closing as duplicate of #{keep.number}. " - "Keeping the latest open issue with this title." - ) - for issue in close_list: - print(f"Closing #{issue.number} as duplicate of #{keep.number}...") - run_gh_command( - [ - "gh", - "issue", - "close", - str(issue.number), - "--repo", - repo, - "--comment", - reason, - ] - ) - - -def main() -> int: - args = parse_args() - try: - issues = load_open_issues(args.repo, args.label, args.limit) - duplicate_groups = find_duplicates(issues) - print_plan(duplicate_groups, apply=args.apply) - if args.apply and duplicate_groups: - print() - close_duplicates(args.repo, duplicate_groups) - print("Done.") - elif not args.apply: - print() - print("Dry-run only. Re-run with `--apply` to close the duplicates.") - except RuntimeError as exc: - print(str(exc), file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py deleted file mode 100755 index 75b6ca88c..000000000 --- a/scripts/generate_changelog.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -""" -Auto-generate changelog from git commits using LLM. -Usage: python scripts/generate_changelog.py [--version VERSION] -""" - -import argparse -import os -import re -import subprocess -import sys -from pathlib import Path - - -def get_latest_tag(): - """Get the latest git tag.""" - result = subprocess.run( - ["git", "describe", "--tags", "--abbrev=0"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - - -def get_commits_since_tag(tag): - """Get all commit messages since the specified tag.""" - result = subprocess.run( - ["git", "log", f"{tag}..HEAD", "--pretty=format:%H|%s|%b"], - capture_output=True, - text=True, - check=True, - ) - commits = [] - for line in result.stdout.strip().split("\n"): - if not line: - continue - parts = line.split("|", 2) - if len(parts) >= 2: - commit_hash = parts[0] - subject = parts[1] - body = parts[2] if len(parts) > 2 else "" - commits.append({"hash": commit_hash[:7], "subject": subject, "body": body}) - return commits - - -def extract_issue_number(text): - """Extract issue number from commit message.""" - # Match #1234 or (#1234) - match = re.search(r"#(\d+)", text) - return match.group(1) if match else None - - -def call_llm_for_changelog(commits, version): - """Call LLM to generate changelog from commits.""" - try: - # Try to use OpenAI API or other LLM providers - import openai - - # Build prompt - commits_text = "\n".join([f"- {c['subject']}" for c in commits]) - - prompt = f"""Based on the following git commit messages, generate a changelog document in BOTH Chinese and English. - -Commit messages: -{commits_text} - -Please organize the changes into these categories: -- 新增 (New Features) -- 修复 (Bug Fixes) -- 优化 (Improvements) -- 其他 (Others) - -Format requirements: -1. Start with Chinese version under "## What's Changed" -2. Follow with English version under "## What's Changed (EN)" -3. Use markdown format with proper bullet points -4. Keep descriptions concise and user-friendly -5. If a commit mentions an issue number (#1234), include it in the format ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234)) - -Example format: -## What's Changed - -### 新增 -- 支持某某功能 ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234)) - -### 修复 -- 修复某某问题 - -## What's Changed (EN) - -### New Features -- Add support for something ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234)) - -### Bug Fixes -- Fix something -""" - - client = openai.OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), - ) - - response = client.chat.completions.create( - model=os.getenv("OPENAI_MODEL", "gpt-4"), - messages=[ - { - "role": "system", - "content": "You are a helpful assistant that generates well-structured changelogs.", - }, - {"role": "user", "content": prompt}, - ], - temperature=0.3, - ) - - return response.choices[0].message.content - - except ImportError: - print( - "Warning: openai package not installed. Install it with: pip install openai" - ) - return generate_simple_changelog(commits) - except Exception as e: - print(f"Warning: Failed to call LLM API: {e}") - print("Falling back to simple changelog generation...") - return generate_simple_changelog(commits) - - -def generate_simple_changelog(commits): - """Generate a simple changelog without LLM.""" - sections = { - "feat": ("新增", "New Features", []), - "fix": ("修复", "Bug Fixes", []), - "perf": ("优化", "Improvements", []), - "docs": ("文档", "Documentation", []), - "refactor": ("重构", "Refactoring", []), - "test": ("测试", "Tests", []), - "chore": ("其他", "Chore", []), - "other": ("其他", "Others", []), - } - - # Categorize commits by conventional commit type - for commit in commits: - subject = commit["subject"] - issue_num = extract_issue_number(subject) - issue_link = ( - f" ([#{issue_num}](https://github.com/AstrBotDevs/AstrBot/issues/{issue_num}))" - if issue_num - else "" - ) - - # Detect conventional commit type - matched = False - for prefix in ["feat", "fix", "perf", "docs", "refactor", "test", "chore"]: - if subject.lower().startswith(f"{prefix}:") or subject.lower().startswith( - f"{prefix}(" - ): - # Remove prefix for display - clean_subject = re.sub( - r"^[a-z]+(\([^)]+\))?:\s*", "", subject, flags=re.IGNORECASE - ) - sections[prefix][2].append(f"- {clean_subject}{issue_link}") - matched = True - break - - if not matched: - sections["other"][2].append(f"- {subject}{issue_link}") - - # Build Chinese version - changelog_zh = "## What's Changed\n\n" - for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]: - zh_title, _, items = sections[section_key] - if items: - changelog_zh += f"### {zh_title}\n\n" - changelog_zh += "\n".join(items) + "\n\n" - - # Build English version - changelog_en = "## What's Changed (EN)\n\n" - for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]: - _, en_title, items = sections[section_key] - if items: - changelog_en += f"### {en_title}\n\n" - changelog_en += "\n".join(items) + "\n\n" - - return changelog_zh + changelog_en - - -def main() -> None: - parser = argparse.ArgumentParser(description="Generate changelog from git commits") - parser.add_argument( - "--version", help="Version number for the changelog (e.g., v4.13.3)" - ) - parser.add_argument( - "--use-llm", - action="store_true", - help="Use LLM to generate changelog (requires OpenAI API key)", - ) - args = parser.parse_args() - - # Get latest tag - try: - latest_tag = get_latest_tag() - print(f"Latest tag: {latest_tag}") - except subprocess.CalledProcessError: - print("Error: No tags found in repository") - sys.exit(1) - - # Get commits since tag - commits = get_commits_since_tag(latest_tag) - if not commits: - print(f"No commits found since {latest_tag}") - sys.exit(0) - - print(f"Found {len(commits)} commits since {latest_tag}") - - # Determine version - if args.version: - version = args.version - else: - # Auto-increment patch version - match = re.match(r"v(\d+)\.(\d+)\.(\d+)", latest_tag) - if match: - major, minor, patch = map(int, match.groups()) - version = f"v{major}.{minor}.{patch + 1}" - else: - print(f"Warning: Could not parse version from tag {latest_tag}") - version = "vX.X.X" - - print(f"Generating changelog for {version}...") - - # Generate changelog - if args.use_llm: - changelog_content = call_llm_for_changelog(commits, version) - else: - changelog_content = generate_simple_changelog(commits) - - # Save to file - changelog_dir = Path(__file__).parent.parent / "changelogs" - changelog_dir.mkdir(exist_ok=True) - changelog_file = changelog_dir / f"{version}.md" - - with open(changelog_file, "w", encoding="utf-8") as f: - f.write(changelog_content) - - print(f"\n✓ Changelog generated: {changelog_file}") - print("\nPreview:") - print("=" * 80) - print(changelog_content) - print("=" * 80) - - -if __name__ == "__main__": - main()