2484 lines
91 KiB
Python
2484 lines
91 KiB
Python
#!/usr/bin/env python3
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
os.environ["JWEBOT_CONFIG_ROOT"] = str(
|
||
PROJECT_ROOT / ".test-config-does-not-exist"
|
||
)
|
||
sys.path[:0] = [
|
||
str(PROJECT_ROOT / "packages" / "jwebot-config" / "src"),
|
||
str(PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"),
|
||
str(PROJECT_ROOT / "packages" / "jwebot-official-extensions" / "src"),
|
||
str(PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"),
|
||
]
|
||
|
||
from jwebot_kernel import edge_runtime as MODULE # noqa: E402
|
||
|
||
|
||
TEST_PRIVATE_CONTEXT = {
|
||
"bot": {
|
||
"mention": "JWeBot",
|
||
"identity_note": "You are JWeBot, an isolated test assistant.",
|
||
},
|
||
"people": [
|
||
{
|
||
"canonical_name": "Alice",
|
||
"aliases": ["alice", "a1", "alice-ocr"],
|
||
"prefixes": ["alice-"],
|
||
"identity_fact": "Alice is a configured test profile.",
|
||
"display_id": "alice",
|
||
},
|
||
{
|
||
"canonical_name": "Bob",
|
||
"aliases": ["bob", "b1", "bob-ocr"],
|
||
"prefixes": ["bob-"],
|
||
"identity_fact": "Bob is a configured test profile.",
|
||
"display_id": "bob",
|
||
},
|
||
{
|
||
"canonical_name": "Carol",
|
||
"aliases": ["carol", "c1", "carol-ocr"],
|
||
"prefixes": [],
|
||
"identity_fact": "Carol is a configured test profile.",
|
||
"display_id": "carol",
|
||
},
|
||
],
|
||
"clock_scan_targets": [
|
||
{"canonical_name": "Alice", "display_id": "alice"},
|
||
{"canonical_name": "Bob", "display_id": "bob"},
|
||
{"canonical_name": "Carol", "display_id": "carol"},
|
||
],
|
||
}
|
||
|
||
|
||
def config(
|
||
directory: Path,
|
||
*,
|
||
api_key: str = "",
|
||
model: str = "deepseek-flash",
|
||
steam_api_key: str = "",
|
||
steam_id64: str = "",
|
||
steam_roster: tuple[dict[str, object], ...] = (),
|
||
local_extensions_enabled: bool = True,
|
||
extension_mode: str = "in-process",
|
||
extension_transport: str = "unix",
|
||
extension_state_root: Path | None = None,
|
||
extension_allowed_signers: Path | None = None,
|
||
extension_publisher: str = "",
|
||
extension_fallback_enabled: bool = True,
|
||
):
|
||
MODULE.apply_private_context(TEST_PRIVATE_CONTEXT)
|
||
return MODULE.EdgeConfig(
|
||
socket_path=directory / "edge.sock",
|
||
database_path=directory / "edge.sqlite3",
|
||
cloud_host="cloud.example",
|
||
cloud_agent="wechat-responder",
|
||
deepseek_key=api_key,
|
||
deepseek_base_url="https://api.deepseek.com",
|
||
deepseek_model=model,
|
||
memory_remote_path="/home/node/.openclaw/workspace/MEMORY.md",
|
||
delivery_enabled=True,
|
||
steam_api_key=steam_api_key,
|
||
steam_id64=steam_id64,
|
||
steam_proxy_url=MODULE.DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
||
steam_roster=steam_roster,
|
||
local_extensions_enabled=local_extensions_enabled,
|
||
extension_mode=extension_mode,
|
||
extension_transport=extension_transport,
|
||
extension_state_root=(
|
||
extension_state_root
|
||
if extension_state_root is not None
|
||
else directory / "extensions"
|
||
),
|
||
extension_allowed_signers=(
|
||
extension_allowed_signers
|
||
if extension_allowed_signers is not None
|
||
else directory / "allowed_signers"
|
||
),
|
||
extension_publisher=extension_publisher,
|
||
extension_fallback_enabled=extension_fallback_enabled,
|
||
)
|
||
|
||
|
||
def event(
|
||
event_id: str = "event-1",
|
||
text: str = "@JWeBot 你好",
|
||
*,
|
||
sender: str = "Alice",
|
||
group: str = "group-a",
|
||
) -> dict[str, str]:
|
||
return {
|
||
"event_id": event_id,
|
||
"group_fingerprint": group,
|
||
"sender": sender,
|
||
"text": text,
|
||
}
|
||
|
||
|
||
class RoutingTests(unittest.TestCase):
|
||
def test_hourly_scan_pool_covers_all_configured_group_ids(self):
|
||
MODULE.apply_private_context(TEST_PRIVATE_CONTEXT)
|
||
self.assertEqual(
|
||
{label for _, label in MODULE.CLOCK_SCAN_TARGETS},
|
||
{"alice", "bob", "carol"},
|
||
)
|
||
|
||
def test_private_steam_roster_does_not_change_alias_migration_version(self):
|
||
MODULE.apply_private_context(TEST_PRIVATE_CONTEXT)
|
||
alias_version = MODULE.PERSON_ALIAS_MIGRATION_VERSION
|
||
private_context = json.loads(json.dumps(TEST_PRIVATE_CONTEXT))
|
||
private_context["people"][0].update(
|
||
{
|
||
"steam_id64": "10000000000000001",
|
||
"steam_aliases": ["alice-steam"],
|
||
}
|
||
)
|
||
try:
|
||
MODULE.apply_private_context(private_context)
|
||
self.assertEqual(MODULE.PERSON_ALIAS_MIGRATION_VERSION, alias_version)
|
||
self.assertEqual(
|
||
MODULE.STEAM_GROUP_ROSTER,
|
||
(
|
||
{
|
||
"steam_id": "10000000000000001",
|
||
"label": "alice",
|
||
"selectors": (
|
||
"Alice",
|
||
"alice",
|
||
"a1",
|
||
"alice-ocr",
|
||
"alice-",
|
||
"alice-steam",
|
||
),
|
||
},
|
||
),
|
||
)
|
||
finally:
|
||
MODULE.apply_private_context(TEST_PRIVATE_CONTEXT)
|
||
|
||
def test_hash_requests_route_to_cloud(self):
|
||
self.assertEqual(MODULE.classify_question("#status"), ("cloud", "hash_command"))
|
||
|
||
def test_status_and_version_are_local_commands(self):
|
||
self.assertEqual(
|
||
MODULE.classify_question("/status"),
|
||
("local", "local_server_status"),
|
||
)
|
||
self.assertEqual(
|
||
MODULE.classify_question("/version"),
|
||
("local", "local_jwebot_version"),
|
||
)
|
||
|
||
def test_unknown_slash_command_stays_local(self):
|
||
self.assertEqual(
|
||
MODULE.classify_question("/unknown"),
|
||
("local", "local_unknown"),
|
||
)
|
||
|
||
def test_ordinary_chat_stays_direct(self):
|
||
self.assertEqual(MODULE.classify_question("今天怎么样"), ("chat", "direct_chat"))
|
||
|
||
def test_local_game_commands_stay_on_the_edge(self):
|
||
self.assertEqual(
|
||
MODULE.classify_question("/丢骰子 2d6"),
|
||
("local", "local_dice"),
|
||
)
|
||
self.assertEqual(
|
||
MODULE.classify_question("/功能列表"),
|
||
("local", "local_feature_list"),
|
||
)
|
||
self.assertEqual(
|
||
MODULE.classify_question("/丢骰子5D20"),
|
||
("local", "local_dice"),
|
||
)
|
||
self.assertEqual(
|
||
MODULE.classify_question("/steam 在线"),
|
||
("local", "local_steam_friends"),
|
||
)
|
||
|
||
def test_reply_has_one_suffix_within_delivery_limit(self):
|
||
self.assertEqual(MODULE.clean_reply("收到。"), "收到喵")
|
||
self.assertEqual(MODULE.clean_reply("收到喵~"), "收到喵")
|
||
self.assertEqual(MODULE.clean_reply("第一行\n第二行"), "第一行\n第二行喵")
|
||
self.assertEqual(len(MODULE.clean_reply("好" * 900)), 900)
|
||
self.assertEqual(MODULE.FAST_ACKNOWLEDGEMENT, "喵......")
|
||
|
||
def test_profile_memory_rejects_bare_versions_but_keeps_audio_context(self):
|
||
self.assertFalse(MODULE.profile_fact_is_safe("5.1"))
|
||
self.assertTrue(MODULE.profile_fact_is_safe("最近在研究 5.1 音响配置"))
|
||
|
||
def test_local_extension_flag_accepts_legacy_environment_alias(self):
|
||
with mock.patch.object(
|
||
MODULE,
|
||
"load_environment_file",
|
||
), mock.patch.dict(
|
||
os.environ,
|
||
{"JWEBOT_OFFICIAL_EXTENSIONS_ENABLED": "0"},
|
||
clear=True,
|
||
):
|
||
edge_config = MODULE.EdgeConfig.from_environment()
|
||
self.assertFalse(edge_config.local_extensions_enabled)
|
||
|
||
def test_local_extension_flag_prefers_canonical_environment_key(self):
|
||
with mock.patch.object(
|
||
MODULE,
|
||
"load_environment_file",
|
||
), mock.patch.dict(
|
||
os.environ,
|
||
{
|
||
"JWEBOT_LOCAL_EXTENSIONS_ENABLED": "1",
|
||
"JWEBOT_OFFICIAL_EXTENSIONS_ENABLED": "0",
|
||
},
|
||
clear=True,
|
||
):
|
||
edge_config = MODULE.EdgeConfig.from_environment()
|
||
self.assertTrue(edge_config.local_extensions_enabled)
|
||
|
||
|
||
class LedgerAndApplicationTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temporary = tempfile.TemporaryDirectory()
|
||
self.config = config(Path(self.temporary.name))
|
||
self.application = MODULE.EdgeApplication(self.config)
|
||
|
||
def tearDown(self):
|
||
self.application.close()
|
||
self.temporary.cleanup()
|
||
|
||
def test_chat_is_completed_locally_and_context_is_reused(self):
|
||
observed_context = []
|
||
|
||
def complete(**kwargs):
|
||
observed_context.extend(kwargs["context"])
|
||
return "第一条回复喵" if not observed_context else "第二条回复喵"
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = mock.Mock()
|
||
first = self.application.accept(event())
|
||
self.application.process_direct_chat_once()
|
||
second = self.application.accept(event("event-2", "@JWeBot 接着说"))
|
||
self.application.process_direct_chat_once()
|
||
|
||
self.assertEqual(first["text"], MODULE.FAST_ACKNOWLEDGEMENT)
|
||
self.assertEqual(second["text"], MODULE.FAST_ACKNOWLEDGEMENT)
|
||
self.assertEqual(observed_context, [("你好", "第一条回复喵")])
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-2")["response_text"],
|
||
"第二条回复喵",
|
||
)
|
||
|
||
def test_route_and_ack_telemetry_is_privacy_safe(self):
|
||
observed = []
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
telemetry = MODULE.RuntimeTelemetry(
|
||
component="edge",
|
||
observer=observed.append,
|
||
logger=mock.Mock(),
|
||
)
|
||
application = MODULE.EdgeApplication(
|
||
config(Path(directory)),
|
||
telemetry=telemetry,
|
||
)
|
||
try:
|
||
incoming = event(
|
||
"private-event-id",
|
||
"@JWeBot 这是私密消息",
|
||
group="private-group",
|
||
)
|
||
response = application.accept(incoming)
|
||
application.confirm_fast_acknowledgement(
|
||
response["event_id"],
|
||
delivered=True,
|
||
)
|
||
finally:
|
||
application.close()
|
||
|
||
names = [value["name"] for value in observed]
|
||
self.assertIn("edge.route.classified", names)
|
||
self.assertIn("edge.ack.prepared", names)
|
||
self.assertIn("edge.ack.confirmed", names)
|
||
self.assertIn("edge.fast_ack.prepared", names)
|
||
self.assertIn("edge.fast_ack.confirmed", names)
|
||
serialized = json.dumps(
|
||
[dict(value) for value in observed],
|
||
ensure_ascii=False,
|
||
)
|
||
self.assertNotIn("private-event-id", serialized)
|
||
self.assertNotIn("private-group", serialized)
|
||
self.assertNotIn("这是私密消息", serialized)
|
||
classified = next(
|
||
value
|
||
for value in observed
|
||
if value["name"] == "edge.route.classified"
|
||
)
|
||
self.assertEqual(classified["execution_path"], "deepseek")
|
||
|
||
def test_queue_wait_and_end_to_end_delivery_are_measured(self):
|
||
observed = []
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
telemetry = MODULE.RuntimeTelemetry(
|
||
component="edge",
|
||
observer=observed.append,
|
||
logger=mock.Mock(),
|
||
)
|
||
application = MODULE.EdgeApplication(
|
||
config(Path(directory)),
|
||
telemetry=telemetry,
|
||
)
|
||
application.deepseek.complete = mock.Mock(
|
||
return_value="最终回复喵"
|
||
)
|
||
application.deliver = mock.Mock()
|
||
try:
|
||
response = application.accept(event())
|
||
application.confirm_fast_acknowledgement(
|
||
response["event_id"],
|
||
delivered=True,
|
||
)
|
||
self.assertTrue(
|
||
application.process_direct_chat_generation_once()
|
||
)
|
||
application.deliver.assert_not_called()
|
||
self.assertTrue(
|
||
application.process_direct_chat_delivery_once()
|
||
)
|
||
finally:
|
||
application.close()
|
||
|
||
by_name = {
|
||
value["name"]: value
|
||
for value in observed
|
||
if value["name"]
|
||
in {"edge.queue.wait", "edge.delivery.end_to_end"}
|
||
}
|
||
self.assertIn("edge.queue.wait", by_name)
|
||
self.assertIn("edge.delivery.end_to_end", by_name)
|
||
self.assertGreaterEqual(by_name["edge.queue.wait"]["elapsed_ms"], 0)
|
||
self.assertGreaterEqual(
|
||
by_name["edge.delivery.end_to_end"]["elapsed_ms"],
|
||
by_name["edge.queue.wait"]["elapsed_ms"],
|
||
)
|
||
|
||
def test_missing_direct_key_acknowledges_then_retries_without_losing_event(self):
|
||
response = self.application.accept(event())
|
||
self.application.process_direct_chat_once()
|
||
|
||
self.assertEqual(response["text"], MODULE.FAST_ACKNOWLEDGEMENT)
|
||
stored = self.application.ledger.row("event-1")
|
||
self.assertEqual(stored["status"], "chat_retrying")
|
||
|
||
def test_direct_chat_starts_before_ack_but_final_delivery_waits_for_ack(self):
|
||
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
||
self.application.deliver = mock.Mock()
|
||
|
||
response = self.application.accept(event())
|
||
self.assertTrue(response["fast_ack"])
|
||
self.assertTrue(self.application.process_direct_chat_once())
|
||
|
||
with self.application.ledger.lock:
|
||
state = self.application.ledger.connection.execute(
|
||
"SELECT state FROM direct_chat_outbox WHERE event_id = ?",
|
||
("event-1",),
|
||
).fetchone()["state"]
|
||
self.assertEqual(state, "result_waiting_ack")
|
||
self.application.deepseek.complete.assert_called_once()
|
||
self.application.deliver.assert_not_called()
|
||
|
||
self.application.ledger.mark_direct_chat_acknowledged(
|
||
"event-1",
|
||
delivered=True,
|
||
)
|
||
self.assertTrue(self.application.process_direct_chat_once())
|
||
|
||
self.application.deliver.assert_called_once()
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-1")["status"],
|
||
"chat_delivered",
|
||
)
|
||
|
||
def test_two_direct_generations_run_in_parallel(self):
|
||
entered = []
|
||
entered_lock = threading.Lock()
|
||
both_started = threading.Event()
|
||
release = threading.Event()
|
||
outcomes = []
|
||
errors = []
|
||
|
||
def complete(**kwargs):
|
||
with entered_lock:
|
||
entered.append(kwargs["event_id"])
|
||
if len(entered) == 2:
|
||
both_started.set()
|
||
if not release.wait(5):
|
||
raise MODULE.DeepSeekUnavailable("parallel test timed out")
|
||
return f"{kwargs['event_id']} 回复喵"
|
||
|
||
def generate():
|
||
try:
|
||
outcomes.append(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
except BaseException as error:
|
||
errors.append(error)
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = mock.Mock()
|
||
self.application.accept(event("parallel-1", "@JWeBot 第一条"))
|
||
self.application.accept(event("parallel-2", "@JWeBot 第二条"))
|
||
workers = [threading.Thread(target=generate) for _ in range(2)]
|
||
for worker in workers:
|
||
worker.start()
|
||
try:
|
||
self.assertTrue(both_started.wait(3))
|
||
finally:
|
||
release.set()
|
||
for worker in workers:
|
||
worker.join(5)
|
||
|
||
self.assertFalse(errors)
|
||
self.assertEqual(outcomes, [True, True])
|
||
self.assertEqual(set(entered), {"parallel-1", "parallel-2"})
|
||
self.assertTrue(all(not worker.is_alive() for worker in workers))
|
||
self.application.deliver.assert_not_called()
|
||
|
||
def test_out_of_order_generation_still_delivers_in_event_order(self):
|
||
first_started = threading.Event()
|
||
release_first = threading.Event()
|
||
second_finished = threading.Event()
|
||
delivered = []
|
||
errors = []
|
||
|
||
def complete(**kwargs):
|
||
if kwargs["event_id"] == "order-1":
|
||
first_started.set()
|
||
if not release_first.wait(5):
|
||
raise MODULE.DeepSeekUnavailable("ordering test timed out")
|
||
else:
|
||
second_finished.set()
|
||
return f"{kwargs['event_id']} 回复喵"
|
||
|
||
def generate():
|
||
try:
|
||
self.application.process_direct_chat_generation_once()
|
||
except BaseException as error:
|
||
errors.append(error)
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = lambda row: delivered.append(row["event_id"])
|
||
for event_id in ("order-1", "order-2"):
|
||
response = self.application.accept(
|
||
event(event_id, f"@JWeBot {event_id}")
|
||
)
|
||
self.application.confirm_fast_acknowledgement(
|
||
response["event_id"],
|
||
delivered=True,
|
||
)
|
||
|
||
first_worker = threading.Thread(target=generate)
|
||
second_worker = threading.Thread(target=generate)
|
||
first_worker.start()
|
||
try:
|
||
self.assertTrue(first_started.wait(3))
|
||
second_worker.start()
|
||
self.assertTrue(second_finished.wait(3))
|
||
second_worker.join(5)
|
||
self.assertFalse(second_worker.is_alive())
|
||
self.assertFalse(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
finally:
|
||
release_first.set()
|
||
first_worker.join(5)
|
||
if second_worker.ident is not None:
|
||
second_worker.join(5)
|
||
|
||
self.assertFalse(errors)
|
||
self.assertFalse(first_worker.is_alive())
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
self.assertEqual(delivered, ["order-1", "order-2"])
|
||
|
||
def test_failed_ack_during_generation_is_terminal_for_ordering(self):
|
||
first_started = threading.Event()
|
||
release_first = threading.Event()
|
||
delivered = []
|
||
errors = []
|
||
|
||
def complete(**kwargs):
|
||
if kwargs["event_id"] == "terminal-1":
|
||
first_started.set()
|
||
if not release_first.wait(5):
|
||
raise MODULE.DeepSeekUnavailable("ACK race test timed out")
|
||
return f"{kwargs['event_id']} 回复喵"
|
||
|
||
def generate_first():
|
||
try:
|
||
self.application.process_direct_chat_generation_once()
|
||
except BaseException as error:
|
||
errors.append(error)
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = lambda row: delivered.append(row["event_id"])
|
||
first = self.application.accept(
|
||
event("terminal-1", "@JWeBot 第一条")
|
||
)
|
||
second = self.application.accept(
|
||
event("terminal-2", "@JWeBot 第二条")
|
||
)
|
||
self.application.confirm_fast_acknowledgement(
|
||
second["event_id"],
|
||
delivered=True,
|
||
)
|
||
|
||
first_worker = threading.Thread(target=generate_first)
|
||
first_worker.start()
|
||
try:
|
||
self.assertTrue(first_started.wait(3))
|
||
self.application.confirm_fast_acknowledgement(
|
||
first["event_id"],
|
||
delivered=False,
|
||
error_code="RuntimeError",
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
finally:
|
||
release_first.set()
|
||
first_worker.join(5)
|
||
|
||
self.assertFalse(errors)
|
||
self.assertFalse(first_worker.is_alive())
|
||
self.assertEqual(delivered, ["terminal-2"])
|
||
self.assertFalse(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
self.assertEqual(
|
||
self.application.ledger.row("terminal-1")["status"],
|
||
"chat_ack_failed",
|
||
)
|
||
|
||
def test_direct_generation_and_delivery_retries_are_independent(self):
|
||
self.application.deepseek.complete = mock.Mock(
|
||
side_effect=[
|
||
MODULE.DeepSeekUnavailable("temporary provider failure"),
|
||
"最终回复喵",
|
||
]
|
||
)
|
||
self.application.deliver = mock.Mock(
|
||
side_effect=[
|
||
MODULE.EdgeError("temporary GUI failure"),
|
||
None,
|
||
]
|
||
)
|
||
response = self.application.accept(event())
|
||
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
with self.application.ledger.lock, self.application.ledger.connection:
|
||
self.application.ledger.connection.execute(
|
||
"""
|
||
UPDATE direct_chat_outbox
|
||
SET next_attempt_at = 0
|
||
WHERE event_id = ?
|
||
""",
|
||
("event-1",),
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
self.application.confirm_fast_acknowledgement(
|
||
response["event_id"],
|
||
delivered=True,
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
with self.application.ledger.lock, self.application.ledger.connection:
|
||
self.application.ledger.connection.execute(
|
||
"""
|
||
UPDATE direct_chat_outbox
|
||
SET next_attempt_at = 0
|
||
WHERE event_id = ?
|
||
""",
|
||
("event-1",),
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
|
||
with self.application.ledger.lock:
|
||
row = self.application.ledger.connection.execute(
|
||
"""
|
||
SELECT state, generation_attempts, delivery_attempts
|
||
FROM direct_chat_outbox
|
||
WHERE event_id = ?
|
||
""",
|
||
("event-1",),
|
||
).fetchone()
|
||
self.assertEqual(
|
||
(
|
||
row["state"],
|
||
row["generation_attempts"],
|
||
row["delivery_attempts"],
|
||
),
|
||
("delivered", 2, 2),
|
||
)
|
||
self.assertEqual(self.application.deepseek.complete.call_count, 2)
|
||
self.assertEqual(self.application.deliver.call_count, 2)
|
||
|
||
def test_direct_duplicate_generates_and_delivers_exactly_once(self):
|
||
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
||
self.application.deliver = mock.Mock()
|
||
|
||
first = self.application.accept(event())
|
||
duplicate = self.application.accept(event())
|
||
|
||
self.assertTrue(duplicate["duplicate"])
|
||
self.assertEqual(
|
||
self.application.ledger.status()["direct_chat_pending"],
|
||
1,
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
self.assertFalse(
|
||
self.application.process_direct_chat_generation_once()
|
||
)
|
||
self.application.confirm_fast_acknowledgement(
|
||
first["event_id"],
|
||
delivered=True,
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
self.assertFalse(
|
||
self.application.process_direct_chat_delivery_once()
|
||
)
|
||
self.application.deepseek.complete.assert_called_once()
|
||
self.application.deliver.assert_called_once()
|
||
|
||
def test_semantic_duplicate_with_new_event_id_is_ignored(self):
|
||
first = self.application.accept(
|
||
{**event("event-1", "@JWeBot /金币"), "source": "rapidocr-local"}
|
||
)
|
||
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()
|
||
|
||
self.application.accept(event())
|
||
self.assertTrue(self.application.process_direct_chat_once())
|
||
self.application.ledger.mark_direct_chat_acknowledged(
|
||
"event-1",
|
||
delivered=False,
|
||
error_code="RuntimeError",
|
||
)
|
||
|
||
self.assertFalse(self.application.process_direct_chat_once())
|
||
self.application.deliver.assert_not_called()
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-1")["status"],
|
||
"chat_ack_failed",
|
||
)
|
||
|
||
def test_feature_list_is_local_and_does_not_call_a_model(self):
|
||
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
|
||
|
||
response = self.application.accept(event(text="@JWeBot /功能列表"))
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertEqual(response["execution_path"], "local-command")
|
||
self.assertIn("由内核和插件描述自动生成", response["text"])
|
||
self.assertIn("/丢骰子", response["text"])
|
||
self.assertIn("骰子和多人骰子挑战", response["text"])
|
||
self.assertIn("/steam", response["text"])
|
||
self.assertIn("[DeepSeek]", response["text"])
|
||
self.assertIn("[OpenClaw]", response["text"])
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-1")["status"],
|
||
"local_completed",
|
||
)
|
||
self.application.deepseek.complete.assert_not_called()
|
||
|
||
def test_response_contract_names_all_three_execution_paths(self):
|
||
local = self.application.accept(
|
||
event("local-path", "@JWeBot /翻硬币")
|
||
)
|
||
deepseek = self.application.accept(
|
||
event("deepseek-path", "@JWeBot 普通聊天")
|
||
)
|
||
openclaw = self.application.accept(
|
||
event("openclaw-path", "@JWeBot #云端任务")
|
||
)
|
||
|
||
self.assertEqual(local["execution_path"], "local-command")
|
||
self.assertEqual(deepseek["execution_path"], "deepseek")
|
||
self.assertEqual(openclaw["execution_path"], "openclaw")
|
||
|
||
def test_local_adapter_path_is_used_by_default(self):
|
||
original = self.application.extensions.handle
|
||
self.application.extensions.handle = mock.Mock(side_effect=original)
|
||
|
||
response = self.application.accept(event(text="@JWeBot /翻硬币"))
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.application.extensions.handle.assert_called_once()
|
||
|
||
def test_local_adapter_path_has_a_compatibility_rollback(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
application = MODULE.EdgeApplication(
|
||
config(
|
||
Path(directory),
|
||
local_extensions_enabled=False,
|
||
)
|
||
)
|
||
try:
|
||
application.extensions.handle = mock.Mock(
|
||
side_effect=AssertionError
|
||
)
|
||
response = application.accept(
|
||
event(text="@JWeBot /翻硬币")
|
||
)
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertIn("硬币落地", response["text"])
|
||
application.extensions.handle.assert_not_called()
|
||
finally:
|
||
application.close()
|
||
|
||
def test_kernel_commands_bypass_local_extensions(self):
|
||
self.application.extensions.handle = mock.Mock(side_effect=AssertionError)
|
||
self.application.local.balance_provider = mock.Mock(
|
||
return_value=[
|
||
{
|
||
"currency": "CNY",
|
||
"total_balance": "12.50",
|
||
"granted_balance": "2.50",
|
||
"topped_up_balance": "10.00",
|
||
}
|
||
]
|
||
)
|
||
with mock.patch.object(
|
||
MODULE.LocalCommandEngine,
|
||
"service_state",
|
||
return_value="active",
|
||
):
|
||
for event_id, command in (
|
||
("direct-help", "/功能列表"),
|
||
("direct-balance", "/余额"),
|
||
):
|
||
with self.subTest(command=command):
|
||
response = self.application.accept(
|
||
event(event_id, f"@JWeBot {command}")
|
||
)
|
||
self.assertEqual(response["route"], "local")
|
||
self.application.extensions.handle.assert_not_called()
|
||
|
||
def test_steam_acknowledges_then_delivers_without_a_model(self):
|
||
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
|
||
self.application.local.steam = mock.Mock()
|
||
self.application.local.steam.render.return_value = (
|
||
"Steam:在线 1/3,游戏中 1;Alice 正在玩 Dota 2"
|
||
)
|
||
self.application.deliver = mock.Mock()
|
||
|
||
response = self.application.accept(event(text="@JWeBot /steam"))
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertEqual(response["text"], MODULE.STEAM_ACKNOWLEDGEMENT)
|
||
self.assertTrue(response["fast_ack"])
|
||
self.assertTrue(self.application.process_direct_chat_once())
|
||
self.application.local.steam.render.assert_called_once_with("")
|
||
self.application.deepseek.complete.assert_not_called()
|
||
self.application.deliver.assert_not_called()
|
||
with self.application.ledger.lock:
|
||
state = self.application.ledger.connection.execute(
|
||
"""
|
||
SELECT kind, state FROM direct_chat_outbox WHERE event_id = ?
|
||
""",
|
||
("event-1",),
|
||
).fetchone()
|
||
self.assertEqual((state["kind"], state["state"]), ("steam", "result_waiting_ack"))
|
||
|
||
self.application.confirm_fast_acknowledgement(
|
||
"event-1",
|
||
delivered=True,
|
||
)
|
||
self.assertTrue(self.application.process_direct_chat_once())
|
||
|
||
self.application.deliver.assert_called_once()
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-1")["status"],
|
||
"steam_delivered",
|
||
)
|
||
|
||
def test_local_status_and_balance_commands_do_not_call_a_model(self):
|
||
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
|
||
with mock.patch.object(
|
||
MODULE.LocalCommandEngine,
|
||
"service_state",
|
||
return_value="active",
|
||
), mock.patch.object(
|
||
MODULE.DeepSeekClient,
|
||
"balance",
|
||
return_value=[
|
||
{
|
||
"currency": "CNY",
|
||
"total_balance": "12.50",
|
||
"granted_balance": "2.50",
|
||
"topped_up_balance": "10.00",
|
||
}
|
||
],
|
||
):
|
||
status = self.application.accept(
|
||
event("local-status", "@JWeBot /状态")
|
||
)
|
||
server_status = self.application.accept(
|
||
event("server-status", "@JWeBot /status")
|
||
)
|
||
version = self.application.accept(
|
||
event("jwebot-version", "@JWeBot /version")
|
||
)
|
||
debug = self.application.accept(
|
||
event("debug-telemetry", "@JWeBot /debug")
|
||
)
|
||
balance = self.application.accept(
|
||
event("local-balance", "@JWeBot /余额")
|
||
)
|
||
|
||
self.assertEqual(status["route"], "local")
|
||
self.assertIn("Edge active", status["text"])
|
||
self.assertEqual(server_status["route"], "local")
|
||
self.assertIn("Edge active", server_status["text"])
|
||
self.assertEqual(version["route"], "local")
|
||
self.assertIn("JWeBot 版本", version["text"])
|
||
self.assertIn("kernel", version["text"])
|
||
self.assertIn("最新版本:beta 1.0.3.1016", version["text"])
|
||
self.assertIn("更新时间:2026-10-16", version["text"])
|
||
self.assertIn("更新摘要:Rename the virtual currency", version["text"])
|
||
self.assertEqual(debug["route"], "local")
|
||
self.assertIn("调试埋点", debug["text"])
|
||
self.assertEqual(balance["route"], "local")
|
||
self.assertIn("CNY 总 12.50", balance["text"])
|
||
self.application.deepseek.complete.assert_not_called()
|
||
|
||
def test_dice_is_deterministic_and_idempotent(self):
|
||
first = self.application.accept(event(text="@JWeBot /丢骰子 2d6"))
|
||
second = self.application.accept(event(text="@JWeBot /丢骰子 2d6"))
|
||
|
||
self.assertEqual(first["route"], "local")
|
||
self.assertEqual(first["text"], second["text"])
|
||
self.assertTrue(second["duplicate"])
|
||
self.assertIn("骰子 2d6", first["text"])
|
||
|
||
def test_local_slash_commands_accept_parameters_without_spaces(self):
|
||
dice = self.application.accept(event(text="@JWeBot /丢骰子5D20"))
|
||
unknown = self.application.accept(
|
||
event("unknown-local", "@JWeBot /不存在的命令")
|
||
)
|
||
|
||
self.assertEqual(dice["route"], "local")
|
||
self.assertIn("骰子 5d20", dice["text"])
|
||
self.assertEqual(unknown["route"], "local")
|
||
self.assertIn("本地命令不存在", unknown["text"])
|
||
self.assertEqual(
|
||
self.application.ledger.status()["outbox_pending"],
|
||
0,
|
||
)
|
||
|
||
def test_dice_challenge_is_local_and_records_winners_by_canonical_identity(self):
|
||
started = self.application.accept(
|
||
event(
|
||
"challenge-alice",
|
||
"@JWeBot /丢骰子挑战",
|
||
sender="alice",
|
||
)
|
||
)
|
||
joined = self.application.accept(
|
||
event(
|
||
"challenge-gb",
|
||
"@JWeBot /丢骰子挑战",
|
||
sender="Bob",
|
||
)
|
||
)
|
||
state = self.application.ledger.local_game("group-a", "dice_challenge")
|
||
settled = self.application.accept(
|
||
event(
|
||
"challenge-settle",
|
||
"@JWeBot /丢骰子挑战结算",
|
||
sender="Alice",
|
||
)
|
||
)
|
||
|
||
self.assertEqual(started["route"], "local")
|
||
self.assertIn("挑战掷出", started["text"])
|
||
self.assertIn("bob 挑战掷出", joined["text"])
|
||
self.assertEqual(state["dice_count"], 5)
|
||
self.assertEqual(state["faces"], 6)
|
||
self.assertEqual(len(state["entries"]), 2)
|
||
self.assertIn("结算", settled["text"])
|
||
self.assertIn("1.", settled["text"])
|
||
self.assertIn("冠军", settled["text"])
|
||
self.assertIsNone(
|
||
self.application.ledger.local_game("group-a", "dice_challenge")
|
||
)
|
||
achievements = self.application.ledger.dice_challenge_achievements("group-a")
|
||
self.assertTrue(
|
||
any(entry["achievement"] == "总点冠军" for entry in achievements)
|
||
)
|
||
|
||
def test_dice_challenge_accepts_no_space_5d20_specification(self):
|
||
response = self.application.accept(
|
||
event(
|
||
"challenge-5d20",
|
||
"@JWeBot /丢骰子挑战5D20",
|
||
sender="Alice",
|
||
group="group-b",
|
||
)
|
||
)
|
||
state = self.application.ledger.local_game("group-b", "dice_challenge")
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertEqual(state["dice_count"], 5)
|
||
self.assertEqual(state["faces"], 20)
|
||
|
||
def test_dice_challenge_stays_open_for_one_hour_and_reports_timeout(self):
|
||
self.application.accept(
|
||
event(
|
||
"challenge-timeout",
|
||
"@JWeBot /丢骰子挑战",
|
||
sender="Alice",
|
||
)
|
||
)
|
||
state = self.application.ledger.local_game("group-a", "dice_challenge")
|
||
self.assertGreaterEqual(
|
||
float(state["expires_at"]) - MODULE.time.time(),
|
||
MODULE.DICE_CHALLENGE_TIMEOUT_SECONDS - 2,
|
||
)
|
||
state["expires_at"] = MODULE.time.time() - 1
|
||
self.application.ledger.save_local_game("group-a", "dice_challenge", state)
|
||
|
||
response = self.application.accept(
|
||
event(
|
||
"challenge-timeout-settle",
|
||
"@JWeBot /丢骰子挑战结算",
|
||
sender="Alice",
|
||
)
|
||
)
|
||
|
||
self.assertIn("已超时", response["text"])
|
||
|
||
def test_dice_challenge_records_five_straight_immediately(self):
|
||
response = self.application.accept(
|
||
event(
|
||
"straight-2",
|
||
"@JWeBot /丢骰子挑战",
|
||
sender="Bob",
|
||
)
|
||
)
|
||
achievements = self.application.ledger.dice_challenge_achievements("group-a")
|
||
|
||
self.assertIn("五连号达成", response["text"])
|
||
self.assertTrue(
|
||
any(
|
||
entry["display_name"] == "bob"
|
||
and entry["achievement"] == "五连号"
|
||
and "2-3-4-5-6" in entry["details"]
|
||
for entry in achievements
|
||
)
|
||
)
|
||
|
||
def test_local_game_events_stay_out_of_cloud_memory_projection(self):
|
||
self.application.accept(event(text="@JWeBot /丢骰子"))
|
||
|
||
projection = MODULE.MemoryProjector(
|
||
self.application.ledger,
|
||
self.config,
|
||
).render()
|
||
|
||
self.assertNotIn("丢骰子", projection)
|
||
|
||
def test_hourly_clock_broadcast_is_local_and_daily_ranking_uses_deliveries(self):
|
||
ledger = self.application.ledger
|
||
for event_id, sender in (("clock-a", "Alice"), ("clock-b", "Bob")):
|
||
self.assertTrue(
|
||
ledger.record_person_observation(
|
||
event_id=event_id,
|
||
group_fingerprint="group-a",
|
||
sender=sender,
|
||
text="群内聊天记录",
|
||
observed_at=MODULE.utc_now(),
|
||
source="test",
|
||
)
|
||
)
|
||
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
|
||
self.application.deliver = mock.Mock()
|
||
hourly_now = MODULE.datetime(
|
||
2026,
|
||
10,
|
||
13,
|
||
3,
|
||
0,
|
||
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.application.process_clock_once(
|
||
now=hourly_now,
|
||
group_fingerprint="group-a",
|
||
)
|
||
)
|
||
hourly_delivery = self.application.deliver.call_args.args[0]
|
||
self.assertIn("现在是北京时间 3:00", hourly_delivery["result_text"])
|
||
self.assertIn("扫码了", hourly_delivery["result_text"])
|
||
self.assertFalse(
|
||
self.application.process_clock_once(
|
||
now=hourly_now,
|
||
group_fingerprint="group-a",
|
||
)
|
||
)
|
||
ranking = ledger.clock_scan_ranking("group-a", "2026-10-13")
|
||
self.assertEqual(len(ranking), 1)
|
||
self.assertEqual(ranking[0]["scan_count"], 1)
|
||
|
||
midnight = MODULE.datetime(
|
||
2026,
|
||
10,
|
||
14,
|
||
0,
|
||
0,
|
||
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||
)
|
||
self.assertTrue(
|
||
self.application.process_clock_once(
|
||
now=midnight,
|
||
group_fingerprint="group-a",
|
||
)
|
||
)
|
||
daily_delivery = self.application.deliver.call_args.args[0]
|
||
self.assertIn("北京时间 0:00", daily_delivery["result_text"])
|
||
self.assertIn("扫码排行榜", daily_delivery["result_text"])
|
||
self.assertIn(ranking[0]["display_name"], daily_delivery["result_text"])
|
||
self.application.deepseek.complete.assert_not_called()
|
||
|
||
def test_failed_hourly_broadcast_does_not_increment_scan_ranking(self):
|
||
ledger = self.application.ledger
|
||
for event_id, sender in (("clock-c", "Alice"), ("clock-d", "Bob")):
|
||
ledger.record_person_observation(
|
||
event_id=event_id,
|
||
group_fingerprint="group-a",
|
||
sender=sender,
|
||
text="群内聊天记录",
|
||
observed_at=MODULE.utc_now(),
|
||
source="test",
|
||
)
|
||
self.application.deliver = mock.Mock(side_effect=MODULE.EdgeError("chat changed"))
|
||
now = MODULE.datetime(
|
||
2026,
|
||
10,
|
||
13,
|
||
4,
|
||
0,
|
||
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||
)
|
||
|
||
self.assertTrue(
|
||
self.application.process_clock_once(
|
||
now=now,
|
||
group_fingerprint="group-a",
|
||
)
|
||
)
|
||
self.assertEqual(ledger.clock_scan_ranking("group-a", "2026-10-13"), [])
|
||
self.assertEqual(ledger.status()["clock_broadcasts_pending"], 0)
|
||
|
||
def test_scan_ranking_command_uses_current_beijing_date(self):
|
||
ledger = self.application.ledger
|
||
local_date = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).date().isoformat()
|
||
person = ledger.resolve_person("group-a", "b1")
|
||
self.assertTrue(
|
||
ledger.reserve_clock_broadcast(
|
||
group_fingerprint="group-a",
|
||
slot_key=f"test-hourly:{local_date}",
|
||
local_date=local_date,
|
||
kind="hourly",
|
||
message="test",
|
||
scan_person_key=str(person["person_key"]),
|
||
scan_label="bob",
|
||
)
|
||
)
|
||
self.assertTrue(
|
||
ledger.complete_clock_broadcast(
|
||
"group-a",
|
||
f"test-hourly:{local_date}",
|
||
)
|
||
)
|
||
|
||
response = self.application.accept(
|
||
event("scan-ranking", "@JWeBot /扫码排行", sender="Alice")
|
||
)
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertIn("北京时间今日扫码排行", response["text"])
|
||
self.assertIn("bob 1 次", response["text"])
|
||
|
||
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
|
||
ledger = self.application.ledger
|
||
observed_at = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).isoformat()
|
||
for event_id, sender, text in (
|
||
("repeat-origin", "Alice", "我先说一句测试"),
|
||
("repeat-second", "Bob", "我先说一句测试!"),
|
||
("repeat-third", "b1", "我 先说一句测试"),
|
||
):
|
||
self.assertTrue(
|
||
ledger.record_repeat_history(
|
||
event_id=event_id,
|
||
group_fingerprint="group-a",
|
||
sender=sender,
|
||
text=text,
|
||
observed_at=observed_at,
|
||
)
|
||
)
|
||
|
||
response = self.application.accept(
|
||
event("repeat-ranking", "@JWeBot /复读榜", sender="Alice")
|
||
)
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertIn("首发 alice", response["text"])
|
||
self.assertIn("后续复读 2 次", response["text"])
|
||
self.assertIn("bob×2", response["text"])
|
||
|
||
searched = self.application.accept(
|
||
event("history-search", "@JWeBot /查记录 先说", sender="Alice")
|
||
)
|
||
self.assertEqual(searched["route"], "local")
|
||
self.assertIn("本地记录命中“先说”", searched["text"])
|
||
self.assertIn("alice:我先说一句测试", searched["text"])
|
||
|
||
def test_explicit_person_memory_is_local_and_injected_into_reply_context(self):
|
||
remembered = self.application.accept(
|
||
event("remember", "@JWeBot /记住 我更喜欢家庭服务器和技术细节")
|
||
)
|
||
captured = {}
|
||
|
||
def complete(**kwargs):
|
||
captured.update(kwargs)
|
||
return "收到喵"
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = mock.Mock()
|
||
self.application.accept(event("chat-after-remember", "@JWeBot 今天聊什么"))
|
||
self.application.process_direct_chat_once()
|
||
|
||
self.assertEqual(remembered["route"], "local")
|
||
self.assertIn("家庭服务器", captured["person_memory"][0])
|
||
profile = self.application.ledger.person_profile(
|
||
"group-a",
|
||
MODULE.person_scope_key("group-a", "Alice"),
|
||
)
|
||
self.assertEqual(profile["facts"][0]["source"], "explicit")
|
||
|
||
def test_person_memory_can_be_viewed_and_forgotten(self):
|
||
self.application.accept(
|
||
event("remember", "@JWeBot /记住 我喜欢简短回复")
|
||
)
|
||
viewed = self.application.accept(
|
||
event("view", "@JWeBot /人物记忆 Alice")
|
||
)
|
||
forgotten = self.application.accept(
|
||
event("forget", "@JWeBot /忘记 全部")
|
||
)
|
||
|
||
self.assertIn("简短回复", viewed["text"])
|
||
self.assertIn("已清空", forgotten["text"])
|
||
self.assertIsNone(
|
||
self.application.ledger.person_profile(
|
||
"group-a",
|
||
MODULE.person_scope_key("group-a", "Alice"),
|
||
)
|
||
)
|
||
|
||
def test_automatic_profile_requires_evidence_and_filters_unsuitable_fact(self):
|
||
engine = MODULE.PersonMemoryEngine(self.application.ledger)
|
||
for index in range(3):
|
||
incoming = event(
|
||
f"observation-{index}",
|
||
"@JWeBot 我最近在研究家庭服务器和 NAS",
|
||
)
|
||
row, _ = self.application.ledger.record_event(
|
||
incoming,
|
||
question=MODULE.event_question(incoming),
|
||
route="chat",
|
||
route_reason="direct_chat",
|
||
)
|
||
engine.ingest(row, incoming)
|
||
person_key = MODULE.person_scope_key("group-a", "Alice")
|
||
self.application.ledger.queue_profile_job(
|
||
"group-a",
|
||
person_key,
|
||
delay_seconds=0,
|
||
)
|
||
client = mock.Mock()
|
||
client.profile_facts.return_value = [
|
||
{
|
||
"text": "持续研究家庭服务器和 NAS",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
{
|
||
"text": "工资很高",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
{
|
||
"text": "群里今晚八点一起打游戏",
|
||
"kind": "ongoing",
|
||
"evidence_count": 3,
|
||
},
|
||
{
|
||
"text": "打游戏比较菜",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
]
|
||
|
||
self.assertTrue(engine.process_once(client))
|
||
|
||
profile = self.application.ledger.person_profile("group-a", person_key)
|
||
facts = [fact["text"] for fact in profile["facts"]]
|
||
self.assertIn("Alice is a configured test profile.", facts)
|
||
self.assertIn("持续研究家庭服务器和 NAS", facts)
|
||
self.assertNotIn("工资很高", facts)
|
||
self.assertNotIn("群里今晚八点一起打游戏", facts)
|
||
self.assertIn("打游戏比较菜", facts)
|
||
|
||
def test_automatic_profile_collapses_audio_memory_paraphrases(self):
|
||
facts = MODULE.PersonMemoryEngine.merge_facts(
|
||
existing=[],
|
||
generated=[
|
||
{
|
||
"text": "常和群友讨论家庭影院、功放和音响配置,常劝人上 5.1 声道",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
{
|
||
"text": "经常在群里劝人上 5.1 声道家庭影院",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
{
|
||
"text": "对 5.1 声道家庭影院感兴趣,也在考虑升级到 5.1.2 或 7.1",
|
||
"kind": "topic",
|
||
"evidence_count": 3,
|
||
},
|
||
],
|
||
)
|
||
|
||
self.assertEqual(len(facts), 2)
|
||
self.assertTrue(
|
||
any("音响配置" in fact["text"] for fact in facts)
|
||
)
|
||
self.assertTrue(
|
||
any("5.1.2" in fact["text"] for fact in facts)
|
||
)
|
||
|
||
def test_configured_aliases_share_a_group_scoped_profile(self):
|
||
remembered = self.application.accept(
|
||
event(
|
||
"alias-remember",
|
||
"@JWeBot /记住 我喜欢研究家用服务器",
|
||
sender="bob-ocr",
|
||
)
|
||
)
|
||
viewed = self.application.accept(
|
||
event(
|
||
"alias-view",
|
||
"@JWeBot /人物记忆 b1",
|
||
sender="other",
|
||
)
|
||
)
|
||
prefix = self.application.ledger.resolve_person("group-a", "bob-scan")
|
||
ocr_variants = {
|
||
"alice-ocr": "Alice",
|
||
"bob-ocr": "Bob",
|
||
"carol-ocr": "Carol",
|
||
}
|
||
|
||
self.assertIn("同一身份", remembered["text"])
|
||
self.assertIn("Bob 的群内记忆", viewed["text"])
|
||
self.assertIn("喜欢研究家用服务器", viewed["text"])
|
||
self.assertEqual(
|
||
prefix["person_key"],
|
||
MODULE.person_scope_key("group-a", "Bob"),
|
||
)
|
||
for sender, canonical_name in ocr_variants.items():
|
||
self.assertEqual(
|
||
self.application.ledger.resolve_person("group-a", sender)["person_key"],
|
||
MODULE.person_scope_key("group-a", canonical_name),
|
||
)
|
||
carol = self.application.ledger.resolve_person("group-a", "c1")
|
||
self.application.ledger.ensure_person_identity("group-a", carol)
|
||
carol_profile = self.application.ledger.person_profile(
|
||
"group-a",
|
||
str(carol["person_key"]),
|
||
)
|
||
self.assertIn(
|
||
"Carol is a configured test profile.",
|
||
[fact["text"] for fact in carol_profile["facts"]],
|
||
)
|
||
|
||
def test_alias_seed_is_cached_per_group(self):
|
||
self.application.ledger.resolve_person("group-a", "b1")
|
||
changes = self.application.ledger.connection.total_changes
|
||
|
||
self.application.ledger.resolve_person("group-a", "b1")
|
||
|
||
self.assertEqual(
|
||
self.application.ledger.connection.total_changes,
|
||
changes,
|
||
)
|
||
|
||
def test_configured_identity_is_idempotent(self):
|
||
person = self.application.ledger.resolve_person("group-a", "c1")
|
||
self.application.ledger.ensure_person_identity("group-a", person)
|
||
changes = self.application.ledger.connection.total_changes
|
||
|
||
self.application.ledger.ensure_person_identity("group-a", person)
|
||
|
||
self.assertEqual(
|
||
self.application.ledger.connection.total_changes,
|
||
changes,
|
||
)
|
||
|
||
def test_mentioned_configured_alias_is_resolved_locally(self):
|
||
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
|
||
response = self.application.accept(
|
||
event(
|
||
"ask-bob",
|
||
"@JWeBot b1是谁啊",
|
||
sender="Alice",
|
||
)
|
||
)
|
||
|
||
self.assertEqual(response["route"], "local")
|
||
self.assertIn("在这个群里,b1 是Bob", response["text"])
|
||
self.assertIn("Bob is a configured test profile", response["text"])
|
||
self.assertEqual(
|
||
self.application.ledger.row("ask-bob")["status"],
|
||
"alias_resolved",
|
||
)
|
||
self.application.deepseek.complete.assert_not_called()
|
||
|
||
def test_aliases_are_group_scoped_and_identity_is_injected(self):
|
||
captured = {}
|
||
|
||
def complete(**kwargs):
|
||
captured.update(kwargs)
|
||
return "知道了喵"
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = mock.Mock()
|
||
self.application.accept(
|
||
event(
|
||
"alice-identity",
|
||
"@JWeBot 请完整说说你是谁",
|
||
sender="a1",
|
||
)
|
||
)
|
||
self.application.process_direct_chat_once()
|
||
group_a = self.application.ledger.resolve_person("group-a", "alice-ocr")
|
||
group_b = self.application.ledger.resolve_person("group-b", "alice-ocr")
|
||
profile = self.application.ledger.person_profile(
|
||
"group-a",
|
||
str(group_a["person_key"]),
|
||
)
|
||
|
||
self.assertNotEqual(group_a["person_key"], group_b["person_key"])
|
||
self.assertEqual(profile["display_name"], "Alice")
|
||
self.assertTrue(
|
||
any(
|
||
line == "[identity] Alice is a configured test profile."
|
||
for line in captured["person_memory"]
|
||
)
|
||
)
|
||
|
||
def test_sender_alias_is_injected_for_a_short_self_identity_question(self):
|
||
captured = {}
|
||
|
||
def complete(**kwargs):
|
||
captured.update(kwargs)
|
||
return "你是 Alice 喵"
|
||
|
||
self.application.deepseek.complete = complete
|
||
self.application.deliver = mock.Mock()
|
||
response = self.application.accept(
|
||
event(
|
||
"who-am-i",
|
||
"@JWeBot 我是谁",
|
||
sender="alice",
|
||
)
|
||
)
|
||
self.application.process_direct_chat_once()
|
||
|
||
self.assertEqual(response["route"], "chat")
|
||
self.assertEqual(response["text"], MODULE.FAST_ACKNOWLEDGEMENT)
|
||
self.assertEqual(captured["sender"], "alice")
|
||
self.assertTrue(
|
||
any(
|
||
line == "[identity] Alice is a configured test profile."
|
||
for line in captured["person_memory"]
|
||
)
|
||
)
|
||
|
||
def test_alias_version_change_skips_rebuild_when_mapping_is_unchanged(self):
|
||
ledger = self.application.ledger
|
||
person = ledger.resolve_person("group-a", "b1")
|
||
ledger.ensure_person_identity("group-a", person)
|
||
with ledger.lock:
|
||
before = ledger.connection.execute(
|
||
"""
|
||
SELECT facts_json FROM person_profiles
|
||
WHERE group_fingerprint = ? AND person_key = ?
|
||
""",
|
||
("group-a", person["person_key"]),
|
||
).fetchone()["facts_json"]
|
||
ledger.set_metadata("person_alias_registry_version", "legacy-version")
|
||
|
||
migrated = ledger.migrate_person_aliases()
|
||
|
||
with ledger.lock:
|
||
after = ledger.connection.execute(
|
||
"""
|
||
SELECT facts_json FROM person_profiles
|
||
WHERE group_fingerprint = ? AND person_key = ?
|
||
""",
|
||
("group-a", person["person_key"]),
|
||
).fetchone()["facts_json"]
|
||
self.assertEqual(
|
||
migrated,
|
||
{
|
||
"observations_rekeyed": 0,
|
||
"profiles_rebuilt": 0,
|
||
"jobs_queued": 0,
|
||
},
|
||
)
|
||
self.assertEqual(before, after)
|
||
self.assertEqual(
|
||
ledger.metadata("person_alias_registry_version"),
|
||
MODULE.PERSON_ALIAS_MIGRATION_VERSION,
|
||
)
|
||
|
||
def test_alias_migration_rekeys_legacy_observations_and_profiles(self):
|
||
ledger = self.application.ledger
|
||
legacy_key = MODULE.person_scope_key("group-a", "b1")
|
||
expires_at = MODULE.time.time() + 60
|
||
fact = {
|
||
"text": "喜欢研究 NAS 存储",
|
||
"kind": "preference",
|
||
"source": "explicit",
|
||
"evidence_count": 1,
|
||
"created_at": MODULE.utc_now(),
|
||
"updated_at": MODULE.utc_now(),
|
||
"expires_at": expires_at,
|
||
}
|
||
with ledger.lock, ledger.connection:
|
||
ledger.connection.execute(
|
||
"""
|
||
INSERT INTO person_observations (
|
||
event_id, group_fingerprint, person_key, display_name, text,
|
||
source, observed_at, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
"legacy-bob",
|
||
"group-a",
|
||
legacy_key,
|
||
"b1",
|
||
"我喜欢研究 NAS 存储",
|
||
"history",
|
||
MODULE.utc_now(),
|
||
MODULE.utc_now(),
|
||
),
|
||
)
|
||
ledger.connection.execute(
|
||
"""
|
||
INSERT INTO person_profiles (
|
||
group_fingerprint, person_key, display_name, facts_json, updated_at
|
||
) VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
"group-a",
|
||
legacy_key,
|
||
"b1",
|
||
json.dumps([fact], ensure_ascii=False),
|
||
MODULE.utc_now(),
|
||
),
|
||
)
|
||
|
||
migrated = ledger.migrate_person_aliases()
|
||
canonical_key = MODULE.person_scope_key("group-a", "Bob")
|
||
profile = ledger.person_profile("group-a", canonical_key)
|
||
|
||
self.assertEqual(migrated["observations_rekeyed"], 1)
|
||
self.assertEqual(profile["display_name"], "Bob")
|
||
self.assertIn("喜欢研究 NAS 存储", [fact["text"] for fact in profile["facts"]])
|
||
self.assertIsNone(ledger.person_profile("group-a", legacy_key))
|
||
self.assertEqual(
|
||
ledger.migrate_person_aliases(),
|
||
{"observations_rekeyed": 0, "profiles_rebuilt": 0, "jobs_queued": 0},
|
||
)
|
||
|
||
def test_alias_migration_preserves_automatic_facts_by_default(self):
|
||
ledger = self.application.ledger
|
||
expires_at = MODULE.time.time() + 60
|
||
automatic_bob = {
|
||
"text": "经常研究家庭服务器",
|
||
"kind": "topic",
|
||
"source": "automatic",
|
||
"evidence_count": 3,
|
||
"created_at": MODULE.utc_now(),
|
||
"updated_at": MODULE.utc_now(),
|
||
"expires_at": expires_at,
|
||
}
|
||
automatic_carol = {
|
||
"text": "最近研究 5.1 音响配置",
|
||
"kind": "topic",
|
||
"source": "automatic",
|
||
"evidence_count": 3,
|
||
"created_at": MODULE.utc_now(),
|
||
"updated_at": MODULE.utc_now(),
|
||
"expires_at": expires_at,
|
||
}
|
||
for display_name, fact in (
|
||
("b1", automatic_bob),
|
||
("c1", automatic_carol),
|
||
):
|
||
with ledger.lock, ledger.connection:
|
||
ledger.connection.execute(
|
||
"""
|
||
INSERT INTO person_profiles (
|
||
group_fingerprint, person_key, display_name, facts_json, updated_at
|
||
) VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
"group-a",
|
||
MODULE.person_scope_key("group-a", display_name),
|
||
display_name,
|
||
json.dumps([fact], ensure_ascii=False),
|
||
MODULE.utc_now(),
|
||
),
|
||
)
|
||
|
||
ledger.migrate_person_aliases()
|
||
bob = ledger.person_profile(
|
||
"group-a",
|
||
MODULE.person_scope_key("group-a", "Bob"),
|
||
)
|
||
carol = ledger.person_profile(
|
||
"group-a",
|
||
MODULE.person_scope_key("group-a", "Carol"),
|
||
)
|
||
|
||
self.assertEqual(bob["display_name"], "Bob")
|
||
self.assertEqual(carol["display_name"], "Carol")
|
||
self.assertIn(
|
||
"经常研究家庭服务器",
|
||
[fact["text"] for fact in bob["facts"]],
|
||
)
|
||
self.assertIn(
|
||
"最近研究 5.1 音响配置",
|
||
[fact["text"] for fact in carol["facts"]],
|
||
)
|
||
|
||
def test_cloud_projection_contains_profile_summary_not_observations(self):
|
||
self.application.accept(
|
||
event("remember", "@JWeBot /记住 我更喜欢技术细节")
|
||
)
|
||
projection = MODULE.MemoryProjector(
|
||
self.application.ledger,
|
||
self.config,
|
||
).render()
|
||
|
||
self.assertIn("## Person-Scoped Profiles", projection)
|
||
self.assertIn("display:Alice", projection)
|
||
self.assertIn("我更喜欢技术细节", projection)
|
||
|
||
def test_guess_number_can_start_and_finish(self):
|
||
started = self.application.accept(
|
||
event("guess-start", "@JWeBot /猜数字 开始 10")
|
||
)
|
||
state = self.application.ledger.local_game("group-a", "guess_number")
|
||
self.assertIn("范围 1 到 10", started["text"])
|
||
self.assertIsNotNone(state)
|
||
|
||
finished = self.application.accept(
|
||
event(
|
||
"guess-win",
|
||
f"@JWeBot /猜 {state['target']}",
|
||
)
|
||
)
|
||
|
||
self.assertIn("猜中了", finished["text"])
|
||
self.assertIsNone(
|
||
self.application.ledger.local_game("group-a", "guess_number")
|
||
)
|
||
|
||
def test_rps_resolves_after_all_joined_players_choose(self):
|
||
self.application.accept(
|
||
event("rps-start", "@JWeBot /石头剪刀布 开始", sender="A")
|
||
)
|
||
self.application.accept(
|
||
event("rps-join", "@JWeBot /石头剪刀布 加入", sender="B")
|
||
)
|
||
waiting = self.application.accept(
|
||
event("rps-a", "@JWeBot /石头剪刀布 出 石头", sender="A")
|
||
)
|
||
resolved = self.application.accept(
|
||
event("rps-b", "@JWeBot /石头剪刀布 出 剪刀", sender="B")
|
||
)
|
||
|
||
self.assertIn("还差 1 人", waiting["text"])
|
||
self.assertIn("石头 赢了", resolved["text"])
|
||
state = self.application.ledger.local_game("group-a", "rps")
|
||
self.assertEqual(state["choices"], {})
|
||
|
||
def test_cloud_request_has_idempotent_acknowledgement_and_outbox(self):
|
||
first = self.application.accept(event(text="@JWeBot #status"))
|
||
second = self.application.accept(event(text="@JWeBot #status"))
|
||
|
||
self.assertEqual(first["text"], MODULE.ACKNOWLEDGEMENT)
|
||
self.assertTrue(first["fast_ack"])
|
||
self.assertTrue(second["duplicate"])
|
||
self.assertEqual(self.application.ledger.status()["outbox_pending"], 1)
|
||
|
||
def test_outbox_executes_once_and_marks_safe_delivery(self):
|
||
self.application.cloud.complete = mock.Mock(return_value="云端完成喵")
|
||
self.application.deliver = mock.Mock()
|
||
self.application.accept(event(text="@JWeBot #status"))
|
||
|
||
processed = self.application.process_outbox_once()
|
||
|
||
self.assertTrue(processed)
|
||
self.application.cloud.complete.assert_called_once()
|
||
self.application.deliver.assert_not_called()
|
||
|
||
self.application.confirm_fast_acknowledgement(
|
||
"event-1",
|
||
delivered=True,
|
||
)
|
||
self.assertTrue(self.application.process_outbox_once())
|
||
self.application.deliver.assert_called_once()
|
||
self.assertEqual(self.application.ledger.status()["outbox_pending"], 0)
|
||
self.assertEqual(
|
||
self.application.ledger.row("event-1")["status"],
|
||
"cloud_delivered",
|
||
)
|
||
|
||
def test_latest_failed_cloud_task_can_be_explicitly_requeued(self):
|
||
self.application.accept(event(text="@JWeBot #status"))
|
||
with self.application.ledger.lock, self.application.ledger.connection:
|
||
self.application.ledger.connection.execute(
|
||
"""
|
||
UPDATE outbox
|
||
SET state = 'failed', attempts = 3, error_code = 'EdgeError'
|
||
WHERE event_id = ?
|
||
""",
|
||
("event-1",),
|
||
)
|
||
self.application.ledger.set_event(
|
||
"event-1",
|
||
status="cloud_failed",
|
||
error_code="EdgeError",
|
||
)
|
||
|
||
event_id = self.application.ledger.retry_latest_failed_cloud()
|
||
claimed = self.application.ledger.claim_outbox()
|
||
|
||
self.assertEqual(event_id, "event-1")
|
||
self.assertIsNotNone(claimed)
|
||
self.assertEqual(claimed["claimed_state"], "cloud_running_acknowledged")
|
||
|
||
def test_memory_projection_is_stable_and_redacts_secrets(self):
|
||
self.application.deepseek.complete = lambda **_: "已记录 api_key=secret-value 喵"
|
||
self.application.deliver = mock.Mock()
|
||
self.application.accept(event(text="@JWeBot api_key=secret-value"))
|
||
self.application.process_direct_chat_once()
|
||
projector = MODULE.MemoryProjector(self.application.ledger, self.config)
|
||
|
||
first = projector.render()
|
||
second = projector.render()
|
||
|
||
self.assertEqual(first, second)
|
||
self.assertIn("[REDACTED SECRET]", first)
|
||
self.assertNotIn("secret-value", first)
|
||
|
||
|
||
class DirectClientTests(unittest.TestCase):
|
||
def test_openai_compatible_request_is_bounded(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = MODULE.DeepSeekClient(
|
||
config(
|
||
Path(temporary.name),
|
||
api_key="test-key",
|
||
model="deepseek-pro",
|
||
)
|
||
)
|
||
response = mock.MagicMock()
|
||
response.read1.return_value = (
|
||
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
|
||
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
|
||
"data: [DONE]\n\n"
|
||
).encode("utf-8")
|
||
response.__enter__.return_value = response
|
||
response.__exit__.return_value = False
|
||
with mock.patch.object(MODULE.urllib.request, "urlopen", return_value=response) as urlopen:
|
||
reply = client.complete(
|
||
event_id="event-1",
|
||
group_fingerprint="group-a",
|
||
sender="Alice",
|
||
question="你好",
|
||
context=[],
|
||
group_references=("群内昵称映射:b1 -> Bob。",),
|
||
)
|
||
|
||
request = urlopen.call_args.args[0]
|
||
payload = json.loads(request.data)
|
||
self.assertEqual(reply, "正常喵")
|
||
self.assertEqual(payload["model"], "deepseek-flash")
|
||
self.assertTrue(payload["stream"])
|
||
self.assertEqual(payload["max_tokens"], 160)
|
||
self.assertEqual(payload["thinking"], {"type": "disabled"})
|
||
self.assertNotIn("temperature", payload)
|
||
self.assertEqual(request.get_header("Authorization"), "Bearer test-key")
|
||
self.assertIn(MODULE.BOT_IDENTITY_NOTE, payload["messages"][0]["content"])
|
||
self.assertTrue(
|
||
any(
|
||
message["content"].startswith("CURRENT_GROUP_SENDER_LABEL:")
|
||
for message in payload["messages"]
|
||
)
|
||
)
|
||
self.assertIn(
|
||
"isolated test assistant",
|
||
payload["messages"][0]["content"],
|
||
)
|
||
self.assertTrue(
|
||
any(
|
||
message["content"].startswith("CONFIGURED_GROUP_ALIAS_REFERENCES:")
|
||
for message in payload["messages"]
|
||
)
|
||
)
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_stream_reader_stops_at_done_without_waiting_for_eof(self):
|
||
response = mock.MagicMock()
|
||
response.read1.side_effect = [
|
||
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
|
||
b"data: [DO",
|
||
b"NE]\n\n",
|
||
AssertionError("reader continued after the stream completed"),
|
||
]
|
||
|
||
raw = MODULE.read_direct_api_response(response)
|
||
|
||
self.assertEqual(
|
||
raw,
|
||
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
|
||
b"data: [DONE]\n\n",
|
||
)
|
||
self.assertEqual(response.read1.call_count, 2)
|
||
|
||
def test_stream_reader_does_not_treat_content_as_done_marker(self):
|
||
response = mock.MagicMock()
|
||
response.read1.side_effect = [
|
||
b'data: {"choices":[{"delta":{"content":"data: [DONE]"}}]}\n\n',
|
||
b"data: [DONE]\n\n",
|
||
AssertionError("reader continued after the stream completed"),
|
||
]
|
||
|
||
raw = MODULE.read_direct_api_response(response)
|
||
|
||
self.assertIn(b'"content":"data: [DONE]"', raw)
|
||
self.assertTrue(raw.endswith(b"data: [DONE]\n\n"))
|
||
self.assertEqual(response.read1.call_count, 2)
|
||
|
||
def test_stream_reader_enforces_total_wall_time(self):
|
||
response = mock.MagicMock()
|
||
response.read1.return_value = b": keep-alive\n\n"
|
||
with mock.patch.object(
|
||
MODULE.time,
|
||
"monotonic",
|
||
side_effect=[0.0, 0.0, 31.0],
|
||
):
|
||
with self.assertRaisesRegex(
|
||
MODULE.DeepSeekUnavailable,
|
||
"exceeded total timeout",
|
||
):
|
||
MODULE.read_direct_api_response(response)
|
||
|
||
response.fp.raw._sock.settimeout.assert_called_once_with(18.0)
|
||
response.read1.assert_called_once()
|
||
|
||
def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self):
|
||
self.assertEqual(
|
||
MODULE.deepseek_response_content(
|
||
b'{"choices":[{"message":{"content":"fallback"}}]}'
|
||
),
|
||
"fallback",
|
||
)
|
||
|
||
def test_stream_parser_rejects_reasoning_without_a_visible_reply(self):
|
||
with self.assertRaises(MODULE.DeepSeekUnavailable):
|
||
MODULE.deepseek_response_content(
|
||
b'data: {"choices":[{"delta":{"reasoning_content":"hidden"}}]}\n\n'
|
||
b"data: [DONE]\n\n"
|
||
)
|
||
|
||
|
||
class SteamClientTests(unittest.TestCase):
|
||
OWNER = "10000000000000001"
|
||
BOB = "10000000000000002"
|
||
CAROL = "10000000000000003"
|
||
|
||
@staticmethod
|
||
def response(payload):
|
||
result = mock.MagicMock()
|
||
result.read.return_value = json.dumps(payload).encode("utf-8")
|
||
result.__enter__.return_value = result
|
||
result.__exit__.return_value = False
|
||
return result
|
||
|
||
def client(
|
||
self,
|
||
directory: Path,
|
||
*,
|
||
steam_roster: tuple[dict[str, object], ...] = (),
|
||
):
|
||
return MODULE.SteamClient(
|
||
config(
|
||
directory,
|
||
steam_api_key="A" * 32,
|
||
steam_id64=self.OWNER,
|
||
steam_roster=steam_roster,
|
||
)
|
||
)
|
||
|
||
def test_presence_discovers_public_friend_list_and_caches_requests(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = self.client(Path(temporary.name))
|
||
|
||
def request(path, parameters):
|
||
if path.endswith("GetFriendList/v1/"):
|
||
return {
|
||
"friendslist": {
|
||
"friends": [
|
||
{"steamid": self.BOB},
|
||
{"steamid": self.CAROL},
|
||
]
|
||
}
|
||
}
|
||
self.assertTrue(path.endswith("GetPlayerSummaries/v2/"))
|
||
return {
|
||
"response": {
|
||
"players": [
|
||
{
|
||
"steamid": self.OWNER,
|
||
"personaname": "Alice",
|
||
"personastate": 1,
|
||
"gameextrainfo": "Dota 2",
|
||
},
|
||
{
|
||
"steamid": self.BOB,
|
||
"personaname": "Bob",
|
||
"personastate": 2,
|
||
},
|
||
{
|
||
"steamid": self.CAROL,
|
||
"personaname": "Carol",
|
||
"personastate": 0,
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
with mock.patch.object(
|
||
client,
|
||
"request_json",
|
||
side_effect=request,
|
||
) as request_json:
|
||
first = client.render("")
|
||
second = client.render("")
|
||
selected = client.render("Bob")
|
||
|
||
self.assertEqual(request_json.call_count, 2)
|
||
self.assertIn("Steam:在线 2/3", first)
|
||
self.assertIn("Alice 正在玩 Dota 2", first)
|
||
self.assertIn("Bob 忙碌", first)
|
||
self.assertEqual(first, second)
|
||
self.assertEqual(selected, "Steam:Bob 忙碌")
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_private_roster_limits_accounts_and_uses_group_aliases(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
roster = (
|
||
{
|
||
"steam_id": self.OWNER,
|
||
"label": "alice",
|
||
"selectors": ("Alice", "alice-steam"),
|
||
},
|
||
{
|
||
"steam_id": self.BOB,
|
||
"label": "bob",
|
||
"selectors": ("Bob", "bob-steam"),
|
||
},
|
||
)
|
||
client = self.client(
|
||
Path(temporary.name),
|
||
steam_roster=roster,
|
||
)
|
||
summaries = {
|
||
self.OWNER: {
|
||
"steamid": self.OWNER,
|
||
"personaname": "Untrusted Persona One",
|
||
"personastate": 1,
|
||
},
|
||
self.BOB: {
|
||
"steamid": self.BOB,
|
||
"personaname": "Untrusted Persona Two",
|
||
"personastate": 2,
|
||
},
|
||
}
|
||
with mock.patch.object(
|
||
client,
|
||
"fetch_summaries",
|
||
return_value=summaries,
|
||
) as fetch_summaries, mock.patch.object(
|
||
client,
|
||
"request_json",
|
||
) as request_json:
|
||
listing = client.render("")
|
||
selected = client.render("bob-steam")
|
||
|
||
fetch_summaries.assert_called_once_with([self.OWNER, self.BOB])
|
||
request_json.assert_not_called()
|
||
self.assertEqual(client.friend_ids(), [self.OWNER, self.BOB])
|
||
self.assertIn("Steam:在线 2/2", listing)
|
||
self.assertIn("alice 在线", listing)
|
||
self.assertIn("bob 忙碌", listing)
|
||
self.assertNotIn("Untrusted Persona", listing)
|
||
self.assertEqual(selected, "Steam:bob 忙碌")
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_private_roster_playtime_queries_only_scoped_accounts(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
roster = (
|
||
{
|
||
"steam_id": self.OWNER,
|
||
"label": "alice",
|
||
"selectors": ("Alice",),
|
||
},
|
||
{
|
||
"steam_id": self.BOB,
|
||
"label": "bob",
|
||
"selectors": ("Bob",),
|
||
},
|
||
)
|
||
client = self.client(
|
||
Path(temporary.name),
|
||
steam_roster=roster,
|
||
)
|
||
records = [
|
||
{
|
||
"steam_id": self.OWNER,
|
||
"label": "alice",
|
||
"selectors": ("Alice",),
|
||
"scoped": True,
|
||
"state": 1,
|
||
"game": "",
|
||
},
|
||
{
|
||
"steam_id": self.BOB,
|
||
"label": "bob",
|
||
"selectors": ("Bob",),
|
||
"scoped": True,
|
||
"state": 1,
|
||
"game": "",
|
||
},
|
||
]
|
||
|
||
def games(steam_id, *, app_id=None):
|
||
values = {
|
||
self.OWNER: 600,
|
||
self.BOB: 120,
|
||
}
|
||
return [
|
||
{
|
||
"appid": 570,
|
||
"name": "Dota 2",
|
||
"playtime_forever": values[steam_id],
|
||
}
|
||
]
|
||
|
||
with mock.patch.object(
|
||
client,
|
||
"presence",
|
||
return_value=records,
|
||
), mock.patch.object(
|
||
client,
|
||
"fetch_owned_games",
|
||
side_effect=games,
|
||
) as fetch:
|
||
result = client.render("时长Dota2")
|
||
|
||
self.assertIn("已查询 2 个账号", result)
|
||
self.assertEqual(
|
||
{call.args[0] for call in fetch.call_args_list},
|
||
{self.OWNER, self.BOB},
|
||
)
|
||
self.assertNotIn(self.CAROL, {
|
||
call.args[0] for call in fetch.call_args_list
|
||
})
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_request_uses_mihomo_and_never_exposes_api_key(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
api_key = "A" * 32
|
||
client = MODULE.SteamClient(
|
||
config(
|
||
Path(temporary.name),
|
||
steam_api_key=api_key,
|
||
steam_id64=self.OWNER,
|
||
)
|
||
)
|
||
response = self.response({"response": {}})
|
||
opener = mock.Mock()
|
||
opener.open.return_value = response
|
||
with mock.patch.object(
|
||
MODULE.urllib.request,
|
||
"build_opener",
|
||
return_value=opener,
|
||
) as build_opener:
|
||
payload = client.request_json("/test/v1/", {"value": "safe"})
|
||
|
||
proxy_handler = build_opener.call_args.args[0]
|
||
request = opener.open.call_args.args[0]
|
||
query = MODULE.urllib.parse.parse_qs(
|
||
MODULE.urllib.parse.urlsplit(request.full_url).query
|
||
)
|
||
|
||
self.assertEqual(
|
||
proxy_handler.proxies,
|
||
{"https": MODULE.DEFAULT_STEAM_MIHOMO_PROXY_URL},
|
||
)
|
||
self.assertEqual(query["key"], [api_key])
|
||
self.assertEqual(query["value"], ["safe"])
|
||
self.assertEqual(payload, {"response": {}})
|
||
self.assertNotIn(api_key, str(payload))
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_compact_playtime_command_fuzzy_matches_and_queries_every_account(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = self.client(Path(temporary.name))
|
||
records = [
|
||
{"steam_id": self.OWNER, "label": "Alice", "state": 1, "game": ""},
|
||
{"steam_id": self.BOB, "label": "Bob", "state": 1, "game": ""},
|
||
{"steam_id": self.CAROL, "label": "Carol", "state": 0, "game": ""},
|
||
]
|
||
|
||
def games(steam_id, *, app_id=None):
|
||
if steam_id == self.OWNER:
|
||
return [
|
||
{
|
||
"appid": 570,
|
||
"name": "Dota 2",
|
||
"playtime_forever": 600,
|
||
}
|
||
]
|
||
if steam_id == self.BOB:
|
||
return [{"appid": 570, "playtime_forever": 120}]
|
||
return []
|
||
|
||
with mock.patch.object(
|
||
client,
|
||
"presence",
|
||
return_value=records,
|
||
), mock.patch.object(
|
||
client,
|
||
"fetch_owned_games",
|
||
side_effect=games,
|
||
) as fetch:
|
||
result = client.render("时长Dta2")
|
||
|
||
self.assertIn("Steam [■■■] Dota 2", result)
|
||
self.assertIn("已查询 3 个账号", result)
|
||
self.assertIn("Alice 10h", result)
|
||
self.assertIn("Bob 2h", result)
|
||
self.assertEqual(
|
||
{call.args[0] for call in fetch.call_args_list},
|
||
{self.OWNER, self.BOB, self.CAROL},
|
||
)
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_compact_arguments_do_not_require_ocr_spaces(self):
|
||
self.assertEqual(
|
||
MODULE.SteamClient.parse_arguments("时长Counter Strike"),
|
||
("playtime", "Counter Strike", False),
|
||
)
|
||
self.assertEqual(
|
||
MODULE.SteamClient.parse_arguments("游戏"),
|
||
("game", "", False),
|
||
)
|
||
|
||
def test_playtime_person_selector_supports_explicit_ocr_safe_forms(self):
|
||
values = {
|
||
"Dota 2、Bob": ("Dota 2", "Bob"),
|
||
"Dota 2,Bob": ("Dota 2", "Bob"),
|
||
"Dota 2@Bob": ("Dota 2", "Bob"),
|
||
"Dota 2人=Bob": ("Dota 2", "Bob"),
|
||
"Counter Strike": ("Counter Strike", ""),
|
||
}
|
||
for query, expected in values.items():
|
||
with self.subTest(query=query):
|
||
self.assertEqual(
|
||
MODULE.SteamClient.parse_playtime_query(query),
|
||
expected,
|
||
)
|
||
|
||
def test_person_playtime_fuzzy_matches_and_queries_only_that_account(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = self.client(Path(temporary.name))
|
||
records = [
|
||
{"steam_id": self.OWNER, "label": "Alice", "state": 1, "game": ""},
|
||
{"steam_id": self.BOB, "label": "Bobby", "state": 1, "game": ""},
|
||
{"steam_id": self.CAROL, "label": "Carol", "state": 0, "game": ""},
|
||
]
|
||
|
||
def games(steam_id, *, app_id=None):
|
||
self.assertEqual(steam_id, self.BOB)
|
||
return [
|
||
{
|
||
"appid": 570,
|
||
"name": "Dota 2",
|
||
"playtime_forever": 135,
|
||
}
|
||
]
|
||
|
||
with mock.patch.object(
|
||
client,
|
||
"presence",
|
||
return_value=records,
|
||
), mock.patch.object(
|
||
client,
|
||
"fetch_owned_games",
|
||
side_effect=games,
|
||
) as fetch:
|
||
result = client.render("时长Dta2、Boby")
|
||
|
||
self.assertIn("Steam [■■■] Dota 2", result)
|
||
self.assertIn("已查询 1 个账号", result)
|
||
self.assertIn("Bobby 2.2h", result)
|
||
self.assertNotIn("Alice", result)
|
||
self.assertNotIn("Carol", result)
|
||
self.assertEqual(
|
||
[call.args[0] for call in fetch.call_args_list],
|
||
[self.BOB],
|
||
)
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_person_playtime_rejects_ambiguous_or_unknown_selector(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = self.client(Path(temporary.name))
|
||
records = [
|
||
{"steam_id": self.OWNER, "label": "Sam One", "state": 1, "game": ""},
|
||
{"steam_id": self.BOB, "label": "Sam Two", "state": 1, "game": ""},
|
||
]
|
||
with mock.patch.object(
|
||
client,
|
||
"presence",
|
||
return_value=records,
|
||
), mock.patch.object(client, "fetch_owned_games") as fetch:
|
||
ambiguous = client.render("时长Dota2、Sam")
|
||
unknown = client.render("时长Dota2、Nobody")
|
||
|
||
self.assertIn("匹配不唯一", ambiguous)
|
||
self.assertIn("没有匹配到玩家", unknown)
|
||
fetch.assert_not_called()
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_person_playtime_names_target_without_public_hours(self):
|
||
records = [
|
||
{"steam_id": self.BOB, "label": "Bobby", "state": 0, "game": ""},
|
||
]
|
||
for games, expected in (
|
||
(None, "Bobby 资料不可见"),
|
||
([], "Bobby 暂无公开游玩时长"),
|
||
):
|
||
with self.subTest(expected=expected):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = self.client(Path(temporary.name))
|
||
with mock.patch.object(
|
||
client,
|
||
"presence",
|
||
return_value=records,
|
||
), mock.patch.object(
|
||
client,
|
||
"fetch_owned_games",
|
||
return_value=games,
|
||
) as fetch:
|
||
result = client.render("时长Dota2、Bobby")
|
||
|
||
self.assertIn(expected, result)
|
||
self.assertEqual(
|
||
[call.args[0] for call in fetch.call_args_list],
|
||
[self.BOB],
|
||
)
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_invalid_or_unreachable_proxy_never_falls_back_to_direct_steam(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
client = MODULE.SteamClient(
|
||
config(
|
||
Path(temporary.name),
|
||
steam_api_key="A" * 32,
|
||
steam_id64=self.OWNER,
|
||
)
|
||
)
|
||
opener = mock.Mock()
|
||
opener.open.side_effect = MODULE.urllib.error.URLError("mihomo down")
|
||
with mock.patch.object(
|
||
MODULE.urllib.request,
|
||
"build_opener",
|
||
return_value=opener,
|
||
), mock.patch.object(MODULE.urllib.request, "urlopen") as direct:
|
||
with self.assertRaisesRegex(
|
||
MODULE.SteamUnavailable,
|
||
"Mihomo",
|
||
):
|
||
client.render_group_presence("")
|
||
|
||
direct.assert_not_called()
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
|
||
class CloudRunnerTests(unittest.TestCase):
|
||
def test_cloud_runner_ignores_system_ssh_config(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
try:
|
||
runner = MODULE.CloudRunner(config(Path(temporary.name)))
|
||
key = Path(temporary.name) / "openclaw_tunnel"
|
||
key.write_text("test", encoding="utf-8")
|
||
result = mock.Mock(
|
||
returncode=0,
|
||
stdout=json.dumps(
|
||
{
|
||
"status": "ok",
|
||
"result": {
|
||
"terminalReply": {
|
||
"text": '{"route":"reply","text":"云端正常"}'
|
||
}
|
||
},
|
||
}
|
||
),
|
||
stderr="",
|
||
)
|
||
with mock.patch.object(MODULE, "SSH_KEY", key), mock.patch.object(
|
||
MODULE.subprocess,
|
||
"run",
|
||
return_value=result,
|
||
) as run:
|
||
reply = runner.complete(
|
||
{
|
||
"event_id": "event-1",
|
||
"group_fingerprint": "group-a",
|
||
"sender": "Alice",
|
||
"question": "/status",
|
||
}
|
||
)
|
||
|
||
command = run.call_args.args[0]
|
||
self.assertEqual(command[:3], ["/usr/bin/ssh", "-F", "/dev/null"])
|
||
self.assertNotIn("--model", command)
|
||
self.assertNotIn("--thinking", command)
|
||
self.assertEqual(reply, "云端正常喵")
|
||
finally:
|
||
temporary.cleanup()
|
||
|
||
def test_cloud_protocol_rejects_an_unsupported_route(self):
|
||
with self.assertRaises(MODULE.EdgeError):
|
||
MODULE.cloud_reply_text('{"route":"main","task":"do something"}')
|
||
|
||
def test_cloud_protocol_unwraps_a_nested_reply_contract(self):
|
||
nested = json.dumps(
|
||
{
|
||
"route": "reply",
|
||
"text": json.dumps(
|
||
{"route": "reply", "text": "嵌套内容"},
|
||
ensure_ascii=False,
|
||
),
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
self.assertEqual(MODULE.cloud_reply_text(nested), "嵌套内容喵")
|
||
|
||
def test_cloud_protocol_ignores_a_suffix_after_the_json_object(self):
|
||
self.assertEqual(
|
||
MODULE.cloud_reply_text(
|
||
'{"route":"reply","text":"尾随后缀"}喵'
|
||
),
|
||
"尾随后缀喵",
|
||
)
|
||
|
||
|
||
class UnixSocketTests(unittest.TestCase):
|
||
def test_existing_direct_outbox_schema_gains_phase_columns(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
ledger = None
|
||
try:
|
||
database_path = Path(temporary.name) / "edge.sqlite3"
|
||
connection = sqlite3.connect(database_path)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE direct_chat_outbox (
|
||
event_id TEXT PRIMARY KEY,
|
||
state TEXT NOT NULL,
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
next_attempt_at REAL NOT NULL DEFAULT 0,
|
||
result_text TEXT NOT NULL DEFAULT '',
|
||
error_code TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)
|
||
"""
|
||
)
|
||
connection.close()
|
||
|
||
ledger = MODULE.Ledger(database_path)
|
||
columns = {
|
||
row["name"]
|
||
for row in ledger.connection.execute(
|
||
"PRAGMA table_info(direct_chat_outbox)"
|
||
).fetchall()
|
||
}
|
||
|
||
self.assertTrue(
|
||
{"kind", "generation_attempts", "delivery_attempts"}
|
||
<= columns
|
||
)
|
||
finally:
|
||
if ledger is not None:
|
||
ledger.close()
|
||
temporary.cleanup()
|
||
|
||
def test_socket_health_round_trip_does_not_create_an_event(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
application = None
|
||
server = None
|
||
try:
|
||
configured = config(Path(temporary.name))
|
||
application = MODULE.EdgeApplication(configured)
|
||
server = MODULE.EdgeServer(configured.socket_path, application)
|
||
os.chmod(configured.socket_path, 0o600)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
|
||
response = MODULE.request_edge({"op": "health"}, socket_path=configured.socket_path)
|
||
|
||
self.assertEqual(response["status"], "ok")
|
||
self.assertEqual(response["events"], 0)
|
||
finally:
|
||
if server is not None:
|
||
server.shutdown()
|
||
server.server_close()
|
||
if application is not None:
|
||
application.close()
|
||
temporary.cleanup()
|
||
|
||
def test_socket_fast_ack_releases_an_already_generated_final_reply(self):
|
||
temporary = tempfile.TemporaryDirectory()
|
||
application = None
|
||
server = None
|
||
try:
|
||
configured = config(Path(temporary.name))
|
||
application = MODULE.EdgeApplication(configured)
|
||
application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
||
application.deliver = mock.Mock()
|
||
server = MODULE.EdgeServer(configured.socket_path, application)
|
||
os.chmod(configured.socket_path, 0o600)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
|
||
accepted = MODULE.request_edge(event(), socket_path=configured.socket_path)
|
||
self.assertTrue(accepted["fast_ack"])
|
||
self.assertTrue(application.process_direct_chat_once())
|
||
application.deliver.assert_not_called()
|
||
|
||
confirmed = MODULE.request_edge(
|
||
{"op": "fast_ack_sent", "event_id": "event-1"},
|
||
socket_path=configured.socket_path,
|
||
)
|
||
self.assertEqual(confirmed["status"], "ok")
|
||
self.assertTrue(application.process_direct_chat_once())
|
||
application.deliver.assert_called_once()
|
||
finally:
|
||
if server is not None:
|
||
server.shutdown()
|
||
server.server_close()
|
||
if application is not None:
|
||
application.close()
|
||
temporary.cleanup()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|