Skip to content

Commit cbe1f44

Browse files
authored
Merge branch 'AstrBotDevs:master' into feat/i18n-lazy-load
2 parents 1b5dce5 + 85cfd62 commit cbe1f44

File tree

29 files changed

+553
-370
lines changed

29 files changed

+553
-370
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
### Modifications / 改动点
55

6-
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
76
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
7+
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
88

99
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
1010
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
@@ -21,7 +21,14 @@
2121
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
2222
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
2323

24-
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
25-
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
26-
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `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`.
27-
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
24+
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
25+
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
26+
27+
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
28+
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**
29+
30+
- [ ] 🤓 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`.
31+
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt``pyproject.toml` 文件相应位置。
32+
33+
- [ ] 😮 My changes do not introduce malicious code.
34+
/ 我的更改没有引入恶意代码。
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: PR Title Check
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, edited, reopened, synchronize]
6+
7+
jobs:
8+
title-format:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
pull-requests: write
12+
issues: write
13+
14+
steps:
15+
- name: Validate PR title
16+
uses: actions/github-script@v7
17+
with:
18+
script: |
19+
const title = (context.payload.pull_request.title || "").trim();
20+
// allow only:
21+
// feat: xxx
22+
// feat(scope): xxx
23+
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
24+
const isValid = pattern.test(title);
25+
const isSameRepo =
26+
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
27+
28+
if (!isValid) {
29+
if (isSameRepo) {
30+
try {
31+
await github.rest.issues.createComment({
32+
owner: context.repo.owner,
33+
repo: context.repo.repo,
34+
issue_number: context.payload.pull_request.number,
35+
body: [
36+
"⚠️ PR title format check failed.",
37+
"Required formats:",
38+
"- `feat: xxx`",
39+
"- `feat(scope): xxx`",
40+
"Please update your PR title and push again."
41+
].join("\n")
42+
});
43+
} catch (e) {
44+
core.warning(`Failed to post PR title comment: ${e.message}`);
45+
}
46+
} else {
47+
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
48+
}
49+
}
50+
51+
if (!isValid) {
52+
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
53+
}

astrbot/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.20.0"
1+
__version__ = "4.20.1"

astrbot/core/astr_main_agent_resources.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,12 @@ async def call(
188188
@dataclass
189189
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
190190
name: str = "send_message_to_user"
191-
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."
191+
description: str = (
192+
"Send message to the user. "
193+
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
194+
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
195+
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
196+
)
192197

193198
parameters: dict = Field(
194199
default_factory=lambda: {

astrbot/core/computer/tools/neo_skills.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
164164
"type": "object",
165165
"properties": {
166166
"payload": {
167-
"anyOf": [{"type": "object"}, {"type": "array"}],
167+
"anyOf": [
168+
{"type": "object"},
169+
{"type": "array", "items": {"type": "object"}},
170+
],
168171
"description": (
169172
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
170173
"This only stores content and returns payload_ref; it does not create a candidate or release."

astrbot/core/config/default.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
77

8-
VERSION = "4.20.0"
8+
VERSION = "4.20.1"
99
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
1010

1111
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -1132,6 +1132,18 @@ class ChatProviderTemplate(TypedDict):
11321132
"proxy": "",
11331133
"custom_headers": {},
11341134
},
1135+
"MiniMax": {
1136+
"id": "minimax",
1137+
"provider": "minimax",
1138+
"type": "openai_chat_completion",
1139+
"provider_type": "chat_completion",
1140+
"enable": True,
1141+
"key": [],
1142+
"api_base": "https://api.minimaxi.com/v1",
1143+
"timeout": 120,
1144+
"proxy": "",
1145+
"custom_headers": {},
1146+
},
11351147
"xAI": {
11361148
"id": "xai",
11371149
"provider": "xai",

astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,47 @@ def _append_attachments(
391391
else:
392392
msg.append(File(name=filename, file=url, url=url))
393393

394+
@staticmethod
395+
def _parse_face_message(content: str) -> str:
396+
"""Parse QQ official face message format and convert to readable text.
397+
398+
QQ official face message format:
399+
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
400+
401+
The ext field contains base64-encoded JSON with a 'text' field
402+
describing the emoji (e.g., '[满头问号]').
403+
404+
Args:
405+
content: The message content that may contain face tags.
406+
407+
Returns:
408+
Content with face tags replaced by readable emoji descriptions.
409+
"""
410+
import base64
411+
import json
412+
import re
413+
414+
def replace_face(match):
415+
face_tag = match.group(0)
416+
# Extract ext field from the face tag
417+
ext_match = re.search(r'ext="([^"]*)"', face_tag)
418+
if ext_match:
419+
try:
420+
ext_encoded = ext_match.group(1)
421+
# Decode base64 and parse JSON
422+
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
423+
ext_data = json.loads(ext_decoded)
424+
emoji_text = ext_data.get("text", "")
425+
if emoji_text:
426+
return f"[表情:{emoji_text}]"
427+
except Exception:
428+
pass
429+
# Fallback if parsing fails
430+
return "[表情]"
431+
432+
# Match face tags: <faceType=...>
433+
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
434+
394435
@staticmethod
395436
def _parse_from_qqofficial(
396437
message: botpy.message.Message
@@ -416,7 +457,10 @@ def _parse_from_qqofficial(
416457
abm.group_id = message.group_openid
417458
else:
418459
abm.sender = MessageMember(message.author.user_openid, "")
419-
abm.message_str = message.content.strip()
460+
# Parse face messages to readable text
461+
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
462+
message.content.strip()
463+
)
420464
abm.self_id = "unknown_selfid"
421465
msg.append(At(qq="qq_official"))
422466
msg.append(Plain(abm.message_str))
@@ -432,10 +476,12 @@ def _parse_from_qqofficial(
432476
else:
433477
abm.self_id = ""
434478

435-
plain_content = message.content.replace(
436-
"<@!" + str(abm.self_id) + ">",
437-
"",
438-
).strip()
479+
plain_content = QQOfficialPlatformAdapter._parse_face_message(
480+
message.content.replace(
481+
"<@!" + str(abm.self_id) + ">",
482+
"",
483+
).strip()
484+
)
439485

440486
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
441487
abm.message = msg

astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import time
34
from typing import cast
45

56
import quart
@@ -39,6 +40,9 @@ def __init__(
3940
self.client = botpy_client
4041
self.event_queue = event_queue
4142
self.shutdown_event = asyncio.Event()
43+
# Deduplication cache for webhook retry callbacks.
44+
self._seen_event_ids: dict[str, float] = {}
45+
self._dedup_ttl: int = 60 # seconds
4246

4347
async def initialize(self) -> None:
4448
logger.info("正在登录到 QQ 官方机器人...")
@@ -106,6 +110,22 @@ async def handle_callback(self, request) -> dict:
106110
print(signed)
107111
return signed
108112

113+
event_id = msg.get("id")
114+
if event_id:
115+
now = time.monotonic()
116+
# Lazily evict expired entries to prevent unbounded growth.
117+
expired = [
118+
k
119+
for k, ts in self._seen_event_ids.items()
120+
if now - ts > self._dedup_ttl
121+
]
122+
for k in expired:
123+
del self._seen_event_ids[k]
124+
if event_id in self._seen_event_ids:
125+
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
126+
return {"opcode": 12}
127+
self._seen_event_ids[event_id] = now
128+
109129
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
110130
event = msg["t"].lower()
111131
try:

astrbot/core/platform/sources/telegram/tg_event.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
from astrbot.core.utils.metrics import Metric
2626

2727

28+
def _is_gif(path: str) -> bool:
29+
if path.lower().endswith(".gif"):
30+
return True
31+
try:
32+
with open(path, "rb") as f:
33+
return f.read(6) in (b"GIF87a", b"GIF89a")
34+
except OSError:
35+
return False
36+
37+
2838
class TelegramPlatformEvent(AstrMessageEvent):
2939
# Telegram 的最大消息长度限制
3040
MAX_MESSAGE_LENGTH = 4096
@@ -291,7 +301,13 @@ async def send_with_client(
291301
await client.send_message(text=chunk, **cast(Any, payload))
292302
elif isinstance(i, Image):
293303
image_path = await i.convert_to_file_path()
294-
await client.send_photo(photo=image_path, **cast(Any, payload))
304+
if _is_gif(image_path):
305+
send_coro = client.send_animation
306+
media_kwarg = {"animation": image_path}
307+
else:
308+
send_coro = client.send_photo
309+
media_kwarg = {"photo": image_path}
310+
await send_coro(**media_kwarg, **cast(Any, payload))
295311
elif isinstance(i, File):
296312
path = await i.get_file()
297313
name = i.name or os.path.basename(path)
@@ -406,12 +422,20 @@ async def _process_chain_items(
406422
on_text(i.text)
407423
elif isinstance(i, Image):
408424
image_path = await i.convert_to_file_path()
425+
if _is_gif(image_path):
426+
action = ChatAction.UPLOAD_VIDEO
427+
send_coro = self.client.send_animation
428+
media_kwarg = {"animation": image_path}
429+
else:
430+
action = ChatAction.UPLOAD_PHOTO
431+
send_coro = self.client.send_photo
432+
media_kwarg = {"photo": image_path}
409433
await self._send_media_with_action(
410434
self.client,
411-
ChatAction.UPLOAD_PHOTO,
412-
self.client.send_photo,
435+
action,
436+
send_coro,
413437
user_name=user_name,
414-
photo=image_path,
438+
**media_kwarg,
415439
**cast(Any, payload),
416440
)
417441
elif isinstance(i, File):

astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,16 @@ async def _send_long_connection_respond_msg(
440440
)
441441

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

447454
async def _enqueue_message(
448455
self,

0 commit comments

Comments
 (0)