Release beta 1.1.0 scan and duplicate fixes

This commit is contained in:
JWeBot Contributors
2026-10-16 17:14:41 +08:00
parent b1d8e4e8e7
commit 35ef82f14f
23 changed files with 217 additions and 37 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## beta 1.1.0.1016 - 2026-10-16 17:04:25
- 修复 OCR 偶发重复识别消耗型命令导致重复回复的问题,并完善摩拉/扫码榜展示。
- Extend OCR semantic duplicate suppression for resource-mutating local
commands so a visible command bubble re-detected after the short 8 second
window is still ignored.
- Keep ordinary local commands on the short duplicate window to avoid blocking
intentional repeated game/query commands.
- Show scan-boost coupon count in `/摩拉`, and make `/扫码榜` list every
configured scan candidate plus next-hour scan weights.
- This major settlement grants each existing account both 200 Mora and
1 primogem.
## beta 1.0.5.1016 - 2026-10-16 16:30:44 ## beta 1.0.5.1016 - 2026-10-16 16:30:44
- 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。 - 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。
+7 -5
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.5.1016`,更新时间:2026-10-16 16:30:44。 当前版本:`beta 1.1.0.1016`,更新时间:2026-10-16 17:04:25。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
@@ -193,7 +193,8 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告; 发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
本次 `beta 1.0.5.1016` 额外发放 1 原石。 `beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石;
后续普通小版本不额外发放原石。
旧 OCR 服务保留安装,但保持禁用。 旧 OCR 服务保留安装,但保持禁用。
### 仓库结构 ### 仓库结构
@@ -268,10 +269,10 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.5.1016 beta 1.1.0.1016
``` ```
Updated: 2026-10-16 16:30:44. Updated: 2026-10-16 17:04:25.
The 1.0 Beta baseline consolidates the stable local command set, person and The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games, context indexes, Steam presence/playtime queries, virtual coin games,
@@ -460,7 +461,8 @@ SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
remain installed but disabled. After cutover, version-idempotent release remain installed but disabled. After cutover, version-idempotent release
settlement grants 200 Mora to every existing account, records the movement, settlement grants 200 Mora to every existing account, records the movement,
and sends the first changelog bullet as the guarded group announcement. and sends the first changelog bullet as the guarded group announcement.
For `beta 1.0.5.1016`, the settlement also grants 1 primogem once. For `beta 1.0.5.1016` and `beta 1.1.0.1016`, the settlement also grants
1 primogem once; ordinary later patch releases do not grant extra primogems.
## Repository Map ## Repository Map
+1 -1
View File
@@ -1 +1 @@
beta 1.0.5.1016 beta 1.1.0.1016
+2 -2
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.5.1016`. Current baseline: `beta 1.1.0.1016`.
This catalog records the reviewed commands shipped with the built-in local This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated extensions. Runtime ownership is frozen at startup from the generated
@@ -33,7 +33,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| Command | Alias | Purpose | | Command | Alias | Purpose |
|---|---|---| |---|---|---|
| `/扫码排行` | - | Show scan ranking for the current group. | | `/扫码排行` | `/扫码榜`, `/今日扫码` | Show all scan candidates, today's counts, and next-hour scan weights for the current group. |
| `/复读榜` | - | Show repeat ranking for the current group. | | `/复读榜` | - | Show repeat ranking for the current group. |
| `/查记录 关键词` | - | Search local group history by keyword. | | `/查记录 关键词` | - | Search local group history by keyword. |
+1
View File
@@ -34,6 +34,7 @@ from jwebot_official_extensions.player_games import ( # noqa: E402
COMPENSATION_AMOUNT = 200 COMPENSATION_AMOUNT = 200
PRIMOGEM_COMPENSATION_RELEASES = { PRIMOGEM_COMPENSATION_RELEASES = {
"beta 1.0.5.1016": 1, "beta 1.0.5.1016": 1,
"beta 1.1.0.1016": 1,
} }
MAX_BALANCE = 1_000_000_000 MAX_BALANCE = 1_000_000_000
MAX_SUMMARY_CHARS = 300 MAX_SUMMARY_CHARS = 300
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.5b1+1016" version = "1.1.0b1+1016"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "1.0.5b1+1016" __version__ = "1.1.0b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "1.0.5b1+1016" version = "1.1.0b1+1016"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.5b1+1016", "jwebot-sdk==1.1.0b1+1016",
"jwebot-kernel==1.0.5b1+1016", "jwebot-kernel==1.1.0b1+1016",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.5b1+1016" __version__ = "1.1.0b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.5b1+1016" version = "1.1.0b1+1016"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==1.0.5b1+1016", "jwebot-config==1.1.0b1+1016",
"jwebot-sdk==1.0.5b1+1016", "jwebot-sdk==1.1.0b1+1016",
"jwebot-official-extensions==1.0.5b1+1016", "jwebot-official-extensions==1.1.0b1+1016",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "1.0.5b1+1016" __version__ = "1.1.0b1+1016"
@@ -104,6 +104,18 @@ MAX_EVENT_TEXT = 5_000
MAX_REPLY_CHARS = 900 MAX_REPLY_CHARS = 900
MAX_CONTEXT_TURNS = 6 MAX_CONTEXT_TURNS = 6
OCR_EVENT_SOURCES = frozenset({"ocr", "rapidocr-local"}) OCR_EVENT_SOURCES = frozenset({"ocr", "rapidocr-local"})
OCR_SEMANTIC_DUPLICATE_SECONDS = 8.0
OCR_RESOURCE_MUTATION_DUPLICATE_SECONDS = 75.0
OCR_RESOURCE_MUTATION_REASONS = frozenset(
{
"local_loan",
"local_primogem_wish",
"local_repay",
"local_scan_boost",
"local_taffy_say",
"local_transfer",
}
)
MAX_OUTBOX_ATTEMPTS = 3 MAX_OUTBOX_ATTEMPTS = 3
DIRECT_CHAT_GENERATION_WORKERS = 2 DIRECT_CHAT_GENERATION_WORKERS = 2
DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0 DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0
@@ -2985,6 +2997,85 @@ class Ledger:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def clock_scan_ranking_all(
self,
group_fingerprint: str,
local_date: str,
) -> list[dict[str, Any]]:
group = bounded_text(group_fingerprint, limit=256)
people: dict[str, dict[str, Any]] = {}
for person in self.person_directory(group):
people[str(person["person_key"])] = {
"person_key": str(person["person_key"]),
"display_name": str(person["display_name"]),
"scan_count": 0,
}
with self.lock:
rows = self.connection.execute(
"""
SELECT person_key, display_name, scan_count
FROM clock_scan_scores
WHERE group_fingerprint = ? AND local_date = ?
""",
(group, local_date),
).fetchall()
for row in rows:
person_key = str(row["person_key"])
people[person_key] = {
"person_key": person_key,
"display_name": str(row["display_name"]),
"scan_count": int(row["scan_count"]),
}
people_list = list(people.values())
people_list.sort(
key=lambda item: (
-int(item["scan_count"]),
str(item["display_name"]).casefold(),
)
)
return people_list
def next_clock_scan_weights(
self,
group_fingerprint: str,
) -> list[dict[str, Any]]:
group = bounded_text(group_fingerprint, limit=256)
weights: dict[str, dict[str, Any]] = {}
for person in self.person_directory(group):
weights[str(person["person_key"])] = {
"person_key": str(person["person_key"]),
"display_name": str(person["display_name"]),
"weight": 1,
}
for canonical_name, scan_label in CLOCK_SCAN_TARGETS:
person_key = person_scope_key(group, canonical_name)
weights.setdefault(
person_key,
{
"person_key": person_key,
"display_name": scan_label,
"weight": 1,
},
)
for person_key, boost in self.active_scan_boost_targets(group).items():
entry = weights.setdefault(
person_key,
{
"person_key": person_key,
"display_name": str(boost["display_name"]),
"weight": 1,
},
)
entry["weight"] += int(boost.get("extra_weight", 0))
weight_list = list(weights.values())
weight_list.sort(
key=lambda item: (
-int(item["weight"]),
str(item["display_name"]).casefold(),
)
)
return weight_list
def record_dice_challenge_achievement( def record_dice_challenge_achievement(
self, self,
*, *,
@@ -6979,12 +7070,18 @@ class EdgeApplication:
existing_row = self.ledger.row(event_id) if event_id else None existing_row = self.ledger.row(event_id) if event_id else None
semantic_duplicate = None semantic_duplicate = None
if existing_row is None and is_ocr_event(event): if existing_row is None and is_ocr_event(event):
duplicate_window = (
OCR_RESOURCE_MUTATION_DUPLICATE_SECONDS
if reason in OCR_RESOURCE_MUTATION_REASONS
else OCR_SEMANTIC_DUPLICATE_SECONDS
)
semantic_duplicate = self.ledger.recent_semantic_duplicate( semantic_duplicate = self.ledger.recent_semantic_duplicate(
group_fingerprint=group_id, group_fingerprint=group_id,
sender=str(event.get("sender", "")), sender=str(event.get("sender", "")),
question=question, question=question,
route=route, route=route,
route_reason=reason, route_reason=reason,
within_seconds=duplicate_window,
) )
if semantic_duplicate is not None: if semantic_duplicate is not None:
self.telemetry.elapsed( self.telemetry.elapsed(
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "1.0.5b1+1016" version = "1.1.0b1+1016"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.5b1+1016", "jwebot-sdk==1.1.0b1+1016",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "1.0.5b1+1016" __version__ = "1.1.0b1+1016"
@@ -246,17 +246,25 @@ class LocalCommandEngine:
def scan_ranking(self, row: dict[str, Any]) -> str: def scan_ranking(self, row: dict[str, Any]) -> str:
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat() local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
ranking = self.ledger.clock_scan_ranking( group = str(row["group_fingerprint"])
str(row["group_fingerprint"]), ranking = self.ledger.clock_scan_ranking_all(
group,
local_date, local_date,
) )
weights = self.ledger.next_clock_scan_weights(
str(row["group_fingerprint"]),
)
if not ranking: if not ranking:
return "北京时间今天还没有已确认送达的扫码记录" return "北京时间今天还没有可展示的扫码候选"
listing = ";".join( listing = ";".join(
f"{index}. {entry['display_name']} {entry['scan_count']} 次" f"{index}. {entry['display_name']} {entry['scan_count']} 次"
for index, entry in enumerate(ranking, start=1) for index, entry in enumerate(ranking, start=1)
) )
return f"北京时间今日扫码排行:{listing}" weight_listing = ";".join(
f"{entry['display_name']} {entry['weight']}"
for entry in weights
)
return f"北京时间今日扫码排行:{listing}\n下一小时扫码权重:{weight_listing}"
def repeat_ranking(self, row: dict[str, Any]) -> str: def repeat_ranking(self, row: dict[str, Any]) -> str:
self.ledger.bootstrap_repeat_history() self.ledger.bootstrap_repeat_history()
@@ -692,7 +692,9 @@ class PlayerGames:
lines = [ lines = [
( (
f"{player_label}:{expired}当前摩拉 {account['balance']};" f"{player_label}:{expired}当前摩拉 {account['balance']};"
f"原石 {account['primogems']} 个;{loan_text}" f"原石 {account['primogems']} 个;"
f"扫码概率券 {account['scan_boost_tickets']} 张;"
f"{loan_text}"
), ),
( (
f"21点 {state};已结算胜 {account['wins']} / " f"21点 {state};已结算胜 {account['wins']} / "
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "1.0.5b1+1016" version = "1.1.0b1+1016"
description = "Typed, capability-limited extension API for JWeBot plugins" description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [] dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext", "SingleGroupContext",
] ]
__version__ = "1.0.5b1+1016" __version__ = "1.1.0b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 1.0.5.1016" release = "beta 1.1.0.1016"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+45 -3
View File
@@ -698,6 +698,42 @@ class LedgerAndApplicationTests(unittest.TestCase):
).fetchone()[0] ).fetchone()[0]
self.assertEqual(count, 1) self.assertEqual(count, 1)
def test_resource_mutation_semantic_duplicate_uses_longer_window(self):
first = self.application.accept(
{
**event("event-1", "@JWeBot /原石祈愿"),
"source": "rapidocr-local",
}
)
old_timestamp = (
MODULE.datetime.now(MODULE.timezone.utc)
- MODULE.timedelta(seconds=30)
).isoformat(timespec="milliseconds")
with self.application.ledger.lock, self.application.ledger.connection:
self.application.ledger.connection.execute(
"""
UPDATE events
SET created_at = ?, updated_at = ?
WHERE event_id = ?
""",
(old_timestamp, old_timestamp, "event-1"),
)
duplicate = self.application.accept(
{
**event("event-2", "@JWeBot /原石祈愿"),
"source": "rapidocr-local",
}
)
self.assertEqual(first["status"], "reply")
self.assertEqual(duplicate["status"], "ignored")
self.assertTrue(duplicate["duplicate"])
with self.application.ledger.lock:
count = self.application.ledger.connection.execute(
"SELECT COUNT(*) FROM events"
).fetchone()[0]
self.assertEqual(count, 1)
def test_failed_fast_ack_never_delivers_the_generated_reply(self): def test_failed_fast_ack_never_delivers_the_generated_reply(self):
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵") self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
self.application.deliver = mock.Mock() self.application.deliver = mock.Mock()
@@ -888,9 +924,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local") self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"]) self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"]) self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.5.1016", version["text"]) self.assertIn("最新版本:beta 1.1.0.1016", version["text"])
self.assertIn("更新时间:2026-10-16 16:30:44", version["text"]) self.assertIn("更新时间:2026-10-16 17:04:25", version["text"])
self.assertIn("更新摘要:新增原石系统", version["text"]) self.assertIn("更新摘要:修复 OCR 偶发重复识别", version["text"])
self.assertEqual(debug["route"], "local") self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"]) self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local") self.assertEqual(balance["route"], "local")
@@ -1155,6 +1191,12 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(response["route"], "local") self.assertEqual(response["route"], "local")
self.assertIn("北京时间今日扫码排行", response["text"]) self.assertIn("北京时间今日扫码排行", response["text"])
self.assertIn("bob 1 次", response["text"]) self.assertIn("bob 1 次", response["text"])
self.assertIn("alice 0 次", response["text"])
self.assertIn("carol 0 次", response["text"])
self.assertIn("下一小时扫码权重:", response["text"])
self.assertIn("alice 1", response["text"])
self.assertIn("bob 1", response["text"])
self.assertIn("carol 1", response["text"])
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self): def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
ledger = self.application.ledger ledger = self.application.ledger
+8 -2
View File
@@ -270,11 +270,17 @@ class Release063GameTests(unittest.TestCase):
sender="Bob", sender="Bob",
event_id="wallet-after-transfer-b", event_id="wallet-after-transfer-b",
) )
self.assertIn("alice:当前摩拉 750;原石 0 个;无贷款;", alice_wallet) self.assertIn(
"alice:当前摩拉 750;原石 0 个;扫码概率券 0 张;无贷款;",
alice_wallet,
)
self.assertIn("最近10次账户变动:", alice_wallet) self.assertIn("最近10次账户变动:", alice_wallet)
self.assertIn("-250:转账给 bob", alice_wallet) self.assertIn("-250:转账给 bob", alice_wallet)
self.assertIn("+1000:首次初始化", alice_wallet) self.assertIn("+1000:首次初始化", alice_wallet)
self.assertIn("bob:当前摩拉 1250;原石 0 个;无贷款;", bob_wallet) self.assertIn(
"bob:当前摩拉 1250;原石 0 个;扫码概率券 0 张;无贷款;",
bob_wallet,
)
self.assertIn("+250:转账 来自 alice", bob_wallet) self.assertIn("+250:转账 来自 alice", bob_wallet)
def test_transfer_rejects_invalid_self_and_insufficient_cases(self): def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
+3
View File
@@ -130,6 +130,9 @@ class Release105PrimogemTests(unittest.TestCase):
boost_targets = self.engine.ledger.active_scan_boost_targets("group-a") boost_targets = self.engine.ledger.active_scan_boost_targets("group-a")
bob_key = self.engine.ledger.resolve_person("group-a", "Bob")["person_key"] bob_key = self.engine.ledger.resolve_person("group-a", "Bob")["person_key"]
self.assertIn(bob_key, boost_targets) self.assertIn(bob_key, boost_targets)
ranking = self.command("/扫码榜", event_id="scan-ranking-after-boost")
self.assertIn("下一小时扫码权重:", ranking)
self.assertIn("bob 5", ranking)
speech = self.command("/Taffy发话 今天也要加班吗") speech = self.command("/Taffy发话 今天也要加班吗")
self.assertEqual(speech, "Taffy:今天也要加班吗喵") self.assertEqual(speech, "Taffy:今天也要加班吗喵")
+8 -2
View File
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase): class ReleaseUpdateTests(unittest.TestCase):
release = "beta 1.0.5.1016" release = "beta 1.1.0.1016"
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
@@ -114,8 +114,14 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement) self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
self.assertNotIn("第二条", announcement) self.assertNotIn("第二条", announcement)
def test_policy_release_announces_primogem(self):
release = "beta 1.0.5.1016"
self.assertEqual(MODULE.primogem_compensation_amount(release), 1)
announcement = MODULE.build_announcement(release, "原石系统上线。")
self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
def test_patch_release_without_policy_does_not_announce_primogem(self): def test_patch_release_without_policy_does_not_announce_primogem(self):
release = "beta 1.0.6.1016" release = "beta 1.1.1.1016"
self.assertEqual(MODULE.primogem_compensation_amount(release), 0) self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
announcement = MODULE.build_announcement(release, "普通小版本更新。") announcement = MODULE.build_announcement(release, "普通小版本更新。")
self.assertIn("更新补偿:摩拉 x200", announcement) self.assertIn("更新补偿:摩拉 x200", announcement)