Release beta 1.1.0 scan and duplicate fixes
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
# 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
|
||||
|
||||
- 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。
|
||||
|
||||
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||
并通过带视觉确认的 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 收口了当前稳定功能:本地命令插件、人物与上下文索引、
|
||||
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
|
||||
@@ -193,7 +193,8 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
|
||||
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
||||
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
|
||||
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
|
||||
本次 `beta 1.0.5.1016` 额外发放 1 原石。
|
||||
`beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石;
|
||||
后续普通小版本不额外发放原石。
|
||||
旧 OCR 服务保留安装,但保持禁用。
|
||||
|
||||
### 仓库结构
|
||||
@@ -268,10 +269,10 @@ visually confirmed GUI bridge.
|
||||
Current version:
|
||||
|
||||
```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
|
||||
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
|
||||
settlement grants 200 Mora to every existing account, records the movement,
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `/扫码排行` | - | 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. |
|
||||
| `/查记录 关键词` | - | Search local group history by keyword. |
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from jwebot_official_extensions.player_games import ( # noqa: E402
|
||||
COMPENSATION_AMOUNT = 200
|
||||
PRIMOGEM_COMPENSATION_RELEASES = {
|
||||
"beta 1.0.5.1016": 1,
|
||||
"beta 1.1.0.1016": 1,
|
||||
}
|
||||
MAX_BALANCE = 1_000_000_000
|
||||
MAX_SUMMARY_CHARS = 300
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "1.0.5b1+1016"
|
||||
version = "1.1.0b1+1016"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "1.0.5b1+1016"
|
||||
__version__ = "1.1.0b1+1016"
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "1.0.5b1+1016"
|
||||
version = "1.1.0b1+1016"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==1.0.5b1+1016",
|
||||
"jwebot-kernel==1.0.5b1+1016",
|
||||
"jwebot-sdk==1.1.0b1+1016",
|
||||
"jwebot-kernel==1.1.0b1+1016",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "1.0.5b1+1016"
|
||||
__version__ = "1.1.0b1+1016"
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "1.0.5b1+1016"
|
||||
version = "1.1.0b1+1016"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==1.0.5b1+1016",
|
||||
"jwebot-sdk==1.0.5b1+1016",
|
||||
"jwebot-official-extensions==1.0.5b1+1016",
|
||||
"jwebot-config==1.1.0b1+1016",
|
||||
"jwebot-sdk==1.1.0b1+1016",
|
||||
"jwebot-official-extensions==1.1.0b1+1016",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"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_CONTEXT_TURNS = 6
|
||||
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
|
||||
DIRECT_CHAT_GENERATION_WORKERS = 2
|
||||
DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0
|
||||
@@ -2985,6 +2997,85 @@ class Ledger:
|
||||
).fetchall()
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -6979,12 +7070,18 @@ class EdgeApplication:
|
||||
existing_row = self.ledger.row(event_id) if event_id else None
|
||||
semantic_duplicate = None
|
||||
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(
|
||||
group_fingerprint=group_id,
|
||||
sender=str(event.get("sender", "")),
|
||||
question=question,
|
||||
route=route,
|
||||
route_reason=reason,
|
||||
within_seconds=duplicate_window,
|
||||
)
|
||||
if semantic_duplicate is not None:
|
||||
self.telemetry.elapsed(
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "1.0.5b1+1016"
|
||||
version = "1.1.0b1+1016"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==1.0.5b1+1016",
|
||||
"jwebot-sdk==1.1.0b1+1016",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -90,4 +90,4 @@ __all__ = [
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "1.0.5b1+1016"
|
||||
__version__ = "1.1.0b1+1016"
|
||||
|
||||
+12
-4
@@ -246,17 +246,25 @@ class LocalCommandEngine:
|
||||
|
||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.clock_scan_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
group = str(row["group_fingerprint"])
|
||||
ranking = self.ledger.clock_scan_ranking_all(
|
||||
group,
|
||||
local_date,
|
||||
)
|
||||
weights = self.ledger.next_clock_scan_weights(
|
||||
str(row["group_fingerprint"]),
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有已确认送达的扫码记录"
|
||||
return "北京时间今天还没有可展示的扫码候选"
|
||||
listing = ";".join(
|
||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||
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:
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
|
||||
@@ -692,7 +692,9 @@ class PlayerGames:
|
||||
lines = [
|
||||
(
|
||||
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']} / "
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "1.0.5b1+1016"
|
||||
version = "1.1.0b1+1016"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
@@ -38,4 +38,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "1.0.5b1+1016"
|
||||
__version__ = "1.1.0b1+1016"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.jwebot]
|
||||
release = "beta 1.0.5.1016"
|
||||
release = "beta 1.1.0.1016"
|
||||
production_deployments_enabled_by_default = false
|
||||
|
||||
[tool.jwebot.workspace]
|
||||
|
||||
@@ -698,6 +698,42 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
).fetchone()[0]
|
||||
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):
|
||||
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
||||
self.application.deliver = mock.Mock()
|
||||
@@ -888,9 +924,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
self.assertEqual(version["route"], "local")
|
||||
self.assertIn("JWeBot 版本", version["text"])
|
||||
self.assertIn("kernel", version["text"])
|
||||
self.assertIn("最新版本:beta 1.0.5.1016", version["text"])
|
||||
self.assertIn("更新时间:2026-10-16 16:30:44", version["text"])
|
||||
self.assertIn("更新摘要:新增原石系统", version["text"])
|
||||
self.assertIn("最新版本:beta 1.1.0.1016", version["text"])
|
||||
self.assertIn("更新时间:2026-10-16 17:04:25", version["text"])
|
||||
self.assertIn("更新摘要:修复 OCR 偶发重复识别", version["text"])
|
||||
self.assertEqual(debug["route"], "local")
|
||||
self.assertIn("调试埋点", debug["text"])
|
||||
self.assertEqual(balance["route"], "local")
|
||||
@@ -1155,6 +1191,12 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
self.assertEqual(response["route"], "local")
|
||||
self.assertIn("北京时间今日扫码排行", 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):
|
||||
ledger = self.application.ledger
|
||||
|
||||
@@ -270,11 +270,17 @@ class Release063GameTests(unittest.TestCase):
|
||||
sender="Bob",
|
||||
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("-250:转账给 bob", 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)
|
||||
|
||||
def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
|
||||
|
||||
@@ -130,6 +130,9 @@ class Release105PrimogemTests(unittest.TestCase):
|
||||
boost_targets = self.engine.ledger.active_scan_boost_targets("group-a")
|
||||
bob_key = self.engine.ledger.resolve_person("group-a", "Bob")["person_key"]
|
||||
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发话 今天也要加班吗")
|
||||
self.assertEqual(speech, "Taffy:今天也要加班吗喵")
|
||||
|
||||
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class ReleaseUpdateTests(unittest.TestCase):
|
||||
release = "beta 1.0.5.1016"
|
||||
release = "beta 1.1.0.1016"
|
||||
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
@@ -114,8 +114,14 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
self.assertIn("更新补偿:摩拉 x200,原石 x1", 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):
|
||||
release = "beta 1.0.6.1016"
|
||||
release = "beta 1.1.1.1016"
|
||||
self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
|
||||
announcement = MODULE.build_announcement(release, "普通小版本更新。")
|
||||
self.assertIn("更新补偿:摩拉 x200", announcement)
|
||||
|
||||
Reference in New Issue
Block a user