diff --git a/CHANGELOG.md b/CHANGELOG.md index 1836c83..383f3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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发话和原石榜。 diff --git a/README.md b/README.md index ee4be98..cbd1921 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index ccd83fa..3ceaa2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -beta 1.0.5.1016 +beta 1.1.0.1016 diff --git a/docs/command-catalog.md b/docs/command-catalog.md index 4c885ff..ea8dc0e 100644 --- a/docs/command-catalog.md +++ b/docs/command-catalog.md @@ -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. | diff --git a/jwebot_release_update.py b/jwebot_release_update.py index 1903d87..04b6cac 100644 --- a/jwebot_release_update.py +++ b/jwebot_release_update.py @@ -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 diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index e08f2af..491b810 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -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 = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index 2ee22ba..cceb044 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "1.0.5b1+1016" +__version__ = "1.1.0b1+1016" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 0dbba3b..0b1efb1 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -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] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index 102b72c..21e8b33 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "1.0.5b1+1016" +__version__ = "1.1.0b1+1016" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 74950c6..daa36ad 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -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'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 9bf01e0..8884222 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "1.0.5b1+1016" +__version__ = "1.1.0b1+1016" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 702aae1..6231444 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -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( diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 3104a25..381d28d 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -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] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index 12ff0f4..057c71f 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "1.0.5b1+1016" +__version__ = "1.1.0b1+1016" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py index bab8cb9..85f67d4 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py @@ -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() diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py index c1ea893..e7266e1 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -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']} / " diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index d2f3e6f..cdfdee3 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -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 = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index 01a249f..c58d83e 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -38,4 +38,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "1.0.5b1+1016" +__version__ = "1.1.0b1+1016" diff --git a/pyproject.toml b/pyproject.toml index 2349ab9..922816b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index bba1337..d122a52 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -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 diff --git a/tests/test_release_063.py b/tests/test_release_063.py index 64b42d2..c6583f2 100644 --- a/tests/test_release_063.py +++ b/tests/test_release_063.py @@ -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): diff --git a/tests/test_release_105.py b/tests/test_release_105.py index aa5c81d..0886fcf 100644 --- a/tests/test_release_105.py +++ b/tests/test_release_105.py @@ -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:今天也要加班吗喵") diff --git a/tests/test_release_update.py b/tests/test_release_update.py index 2d41eca..64f7772 100644 --- a/tests/test_release_update.py +++ b/tests/test_release_update.py @@ -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)