Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
18a99a2
fix(platform): parse QQ official face messages to readable text (#6355)
ccsang Mar 15, 2026
d41ccb7
fix: replace npm registry URLs with jsdelivr CDN for provider icons (…
Xial-kotori Mar 15, 2026
6d055e8
fix: GIF sent as static image in Telegram adapter (#6329)
Trainingcqy Mar 15, 2026
da520e5
feat(provider): add MiniMax (#6318)
xwsjjctz Mar 15, 2026
3ccd70c
Fix: AI fails to send media files when tool-calling mode is set to "s…
LovieCode Mar 15, 2026
20efaa5
fix: revise link to model service configuration (#6296)
simon300000 Mar 15, 2026
b0e10cf
fix: add null check for delta in streaming mode to prevent AttributeE…
rin259 Mar 15, 2026
2f51916
fix: deduplicate repeated QQ webhook retry callbacks (#6320)
KBVsent Mar 15, 2026
d87cf89
Fix TypeError when API returns null choices (#6313)
he-yufeng Mar 15, 2026
420d82d
chore: ruff format
Soulter Mar 15, 2026
64e0183
fix: drop Groq reasoning_content from assistant history (#6065)
stablegenius49 Mar 15, 2026
d936bb0
Refactor checklist items in PR template
LIghtJUNction Mar 15, 2026
aa60467
Merge pull request #6399 from AstrBotDevs/LIghtJUNction-patch-1
LIghtJUNction Mar 15, 2026
a3fa8a5
Initial plan
Copilot Mar 15, 2026
dd89a4b
feat: add PR checklist enforcement workflow
Copilot Mar 15, 2026
2bbca88
Refine PR checklist validation and closure message
LIghtJUNction Mar 15, 2026
11c8409
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
6b3868b
Update pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
9909d77
Merge pull request #6400 from AstrBotDevs/copilot/implement-modificat…
LIghtJUNction Mar 15, 2026
84e880a
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
ceb32dc
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
7e3c32b
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
bc3b5e5
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
b795f80
更新 pr-checklist-check.yml
LIghtJUNction Mar 15, 2026
92c3119
perf: enhance umo processing compatibility (#5996)
stevessr Mar 16, 2026
65decfb
chore: remove unused scripts for closing duplicate plugin publish iss…
Soulter Mar 16, 2026
2c49ac0
Refactor _extract_session_id for chat type handling (#5775)
lppsuixn Mar 16, 2026
e77500f
fix(provider): sync providers_config after creating new provider (#6388)
ccsang Mar 16, 2026
3fbd16b
docs: update rainyun.md with backup access instructions (#6427)
QuietRise Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,23 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->

- [ ] 😊 如果 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.
45 changes: 45 additions & 0 deletions .github/workflows/pr-checklist-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: PR Checklist Check

on:
pull_request_target:
types: [opened, edited, reopened, synchronize]

jobs:
check:
runs-on: ubuntu-latest

permissions:
pull-requests: write
issues: write

steps:
- name: Check checklist
id: check
uses: actions/github-script@v7
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: steps.check.outputs.bad == 'true'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
});

await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
7 changes: 6 additions & 1 deletion astrbot/core/astr_main_agent_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,12 @@ async def call(
@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: {
Expand Down
5 changes: 4 additions & 1 deletion astrbot/core/computer/tools/neo_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ 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."
Expand Down
12 changes: 12 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,18 @@ class ChatProviderTemplate(TypedDict):
"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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fix MiniMax default API base domain typo

The new MiniMax provider preset sets api_base to https://api.minimaxi.com/v1, which has an extra i in the hostname; users who rely on this default will fail to connect (DNS/connection errors) until they manually override it. This looks unintended because the MiniMax TTS preset in the same config uses the api.minimax.chat domain, so the chat preset should use a valid MiniMax endpoint as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,47 @@ def _append_attachments(
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:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">

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)
Comment on lines +414 to +423

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Narrow the exception handling when decoding the ext field to avoid masking unrelated errors.

Catching Exception here hides all parsing and programming errors, making real bugs hard to detect and debug. Limit this to the specific, expected failures (e.g. binascii.Error, json.JSONDecodeError, UnicodeDecodeError) so unexpected issues surface instead of always falling back to [表情].

Suggested implementation:

        import base64
        import binascii
        import json
        import re
                except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError):
                    # Fallback to generic placeholder on expected decoding/parsing errors
                    pass

emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
Comment on lines +419 to +428

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The except Exception: is too broad and can hide unexpected errors. It's better to catch specific exceptions that you expect to occur, such as json.JSONDecodeError, ValueError (for base64 decoding issues), or UnicodeDecodeError.

Also, the import statements for base64, json, and re should be moved to the top of the file.

Suggested change
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
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 (json.JSONDecodeError, ValueError, UnicodeDecodeError):
pass
References
  1. Import statements should be placed at the top of the file, one import per line, as per PEP 8 guidelines. (link)

# Fallback if parsing fails
Comment on lines +426 to +429
return "[表情]"

# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)

@staticmethod
def _parse_from_qqofficial(
message: botpy.message.Message
Expand All @@ -416,7 +457,10 @@ def _parse_from_qqofficial(
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))
Expand All @@ -432,10 +476,12 @@ def _parse_from_qqofficial(
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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import logging
import time
from typing import cast

import quart
Expand Down Expand Up @@ -39,6 +40,9 @@ def __init__(
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 官方机器人...")
Expand Down Expand Up @@ -106,6 +110,22 @@ async def handle_callback(self, request) -> dict:
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

Comment on lines +115 to +128
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
Expand Down
32 changes: 28 additions & 4 deletions astrbot/core/platform/sources/telegram/tg_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
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
Comment on lines +29 to +35


class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
Expand Down Expand Up @@ -291,7 +301,13 @@ async def send_with_client(
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)
Expand Down Expand Up @@ -406,12 +422,20 @@ async def _process_chain_items(
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):
Expand Down
13 changes: 10 additions & 3 deletions astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,16 @@ async def _send_long_connection_respond_msg(
)

def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
"""从消息数据中提取会话ID
群聊使用 chatid,单聊使用 userid
"""
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)

async def _enqueue_message(
self,
Expand Down
2 changes: 2 additions & 0 deletions astrbot/core/provider/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,8 @@ async def create_provider(self, new_config: dict) -> None:
config.save_config()
# load instance
await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]

async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
Expand Down
8 changes: 8 additions & 0 deletions astrbot/core/provider/sources/groq_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@ def __init__(
) -> 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)
8 changes: 4 additions & 4 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ async def _query_stream(
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}")
Expand All @@ -322,7 +322,7 @@ async def _query_stream(
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(
Expand All @@ -345,7 +345,7 @@ def _extract_reasoning_content(
) -> 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]
Expand Down Expand Up @@ -468,7 +468,7 @@ async def _parse_openai_completion(
"""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]

Expand Down
Loading
Loading