release: alpha 0.6.1 local player plugins and capture reconciliation
check / local-gate (push) Failing after 3m24s
check / local-gate (push) Failing after 3m24s
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "0.6.0a0+1015"
|
||||
version = "0.6.1a0+1015"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
__version__ = "0.6.1a0+1015"
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "0.6.0a0+1015"
|
||||
version = "0.6.1a0+1015"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-kernel==0.6.0a0+1015",
|
||||
"jwebot-sdk==0.6.1a0+1015",
|
||||
"jwebot-kernel==0.6.1a0+1015",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
__version__ = "0.6.1a0+1015"
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.0a0+1015"
|
||||
version = "0.6.1a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.0a0+1015",
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-official-extensions==0.6.0a0+1015",
|
||||
"jwebot-config==0.6.1a0+1015",
|
||||
"jwebot-sdk==0.6.1a0+1015",
|
||||
"jwebot-official-extensions==0.6.1a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
__version__ = "0.6.1a0+1015"
|
||||
|
||||
@@ -941,6 +941,34 @@ class Ledger:
|
||||
observed_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS context_history (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS context_history_group_time
|
||||
ON context_history(group_fingerprint, observed_at, event_id);
|
||||
INSERT OR IGNORE INTO context_history
|
||||
SELECT event_id, group_fingerprint, display_name, text, observed_at
|
||||
FROM repeat_history;
|
||||
CREATE TABLE IF NOT EXISTS player_accounts (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
person_key TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, person_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS player_actions (
|
||||
group_fingerprint TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
person_key TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
response_text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_fingerprint, event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_group_created
|
||||
ON events(group_fingerprint, created_at);
|
||||
CREATE INDEX IF NOT EXISTS outbox_due
|
||||
@@ -2713,14 +2741,20 @@ class Ledger:
|
||||
) -> bool:
|
||||
content = bounded_text(text, limit=1_500)
|
||||
repeat_key = repeat_text_key(content)
|
||||
if not event_id or not group_fingerprint or not content:
|
||||
return False
|
||||
person = self.resolve_person(group_fingerprint, sender)
|
||||
with self.lock:
|
||||
self.connection.execute(
|
||||
"INSERT OR IGNORE INTO context_history VALUES (?, ?, ?, ?, ?)",
|
||||
(event_id, group_fingerprint, person_display_label(person["display_name"]),
|
||||
content, observed_at),
|
||||
)
|
||||
if (
|
||||
not event_id
|
||||
or not group_fingerprint
|
||||
or not repeat_key
|
||||
not repeat_key
|
||||
or len(repeat_key) < 2
|
||||
):
|
||||
return False
|
||||
person = self.resolve_person(group_fingerprint, sender)
|
||||
with self.lock, self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
@@ -2846,6 +2880,102 @@ class Ledger:
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def recent_context_messages(
|
||||
self, group: str, limit: int, *, exclude_event_id: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
if limit not in {100, 200, 300}:
|
||||
raise LocalCommandError("上下文数量只支持 100、200、300")
|
||||
with self.lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT event_id, display_name, text, observed_at
|
||||
FROM context_history
|
||||
WHERE group_fingerprint = ? AND event_id != ?
|
||||
ORDER BY observed_at DESC, event_id DESC LIMIT ?
|
||||
""", (group, exclude_event_id, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def person_directory(self, group: str) -> list[dict[str, Any]]:
|
||||
self.seed_person_aliases(group)
|
||||
with self.lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT canonical_name, alias_key, match_kind FROM person_aliases
|
||||
WHERE group_fingerprint = ?
|
||||
ORDER BY canonical_name, match_kind, alias_key
|
||||
""", (group,),
|
||||
).fetchall()
|
||||
people: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
name = str(row["canonical_name"])
|
||||
person = people.setdefault(name, {
|
||||
"display_name": person_display_label(name),
|
||||
"person_key": person_scope_key(group, name),
|
||||
"aliases": [], "prefixes": [],
|
||||
})
|
||||
person["prefixes" if row["match_kind"] == "prefix" else "aliases"].append(
|
||||
str(row["alias_key"]))
|
||||
# Include profiles with a canonical name even when no alias was configured.
|
||||
for template in PERSON_ALIAS_TEMPLATES:
|
||||
name = str(template["canonical_name"])
|
||||
people.setdefault(name, {
|
||||
"display_name": person_display_label(name),
|
||||
"person_key": person_scope_key(group, name),
|
||||
"aliases": [], "prefixes": [],
|
||||
})
|
||||
return list(people.values())
|
||||
|
||||
def apply_player_action(
|
||||
self, group: str, sender: str, event_id: str, command: str,
|
||||
action: Callable[[dict[str, Any]], str],
|
||||
) -> str:
|
||||
"""Commit identity-scoped funds, game state and replay response together."""
|
||||
if not group or not event_id or not sender or sender == "unknown":
|
||||
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
|
||||
person = self.resolve_person(group, sender)
|
||||
person_key = str(person["person_key"])
|
||||
with self.lock:
|
||||
self.connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
previous = self.connection.execute(
|
||||
"""SELECT person_key, command, response_text FROM player_actions
|
||||
WHERE group_fingerprint = ? AND event_id = ?""",
|
||||
(group, event_id),
|
||||
).fetchone()
|
||||
if previous is not None:
|
||||
if previous["person_key"] != person_key or previous["command"] != command:
|
||||
raise LocalCommandError("同一事件不能变更账户或游戏指令")
|
||||
self.connection.execute("COMMIT")
|
||||
return str(previous["response_text"])
|
||||
stored = self.connection.execute(
|
||||
"""SELECT state_json FROM player_accounts
|
||||
WHERE group_fingerprint = ? AND person_key = ?""",
|
||||
(group, person_key),
|
||||
).fetchone()
|
||||
account = json.loads(stored["state_json"]) if stored else {
|
||||
"balance": 1000, "game": None, "wins": 0, "losses": 0, "ties": 0,
|
||||
}
|
||||
response = action(account)
|
||||
if type(account["balance"]) is not int or not 0 <= account["balance"] <= 1_000_000_000:
|
||||
raise LocalCommandError("金币结算超出允许范围")
|
||||
timestamp = utc_now()
|
||||
self.connection.execute(
|
||||
"""INSERT INTO player_accounts VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
|
||||
SET state_json = excluded.state_json, updated_at = excluded.updated_at""",
|
||||
(group, person_key, json.dumps(account, ensure_ascii=False), timestamp),
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(group, event_id, person_key, command, response, timestamp),
|
||||
)
|
||||
self.connection.execute("COMMIT")
|
||||
return response
|
||||
except BaseException:
|
||||
self.connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self.lock:
|
||||
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "0.6.0a0+1015"
|
||||
version = "0.6.1a0+1015"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-sdk==0.6.1a0+1015",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -62,4 +62,4 @@ __all__ = [
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
__version__ = "0.6.1a0+1015"
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for context-summary."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'context-summary'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
|
||||
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
|
||||
COMMANDS = ('/上下文', '/context')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for person-directory."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'person-directory'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
|
||||
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
||||
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for single-player-games."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'single-player-games'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '本人虚拟金币账户、抽奖、彩票和单人21点'
|
||||
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games')
|
||||
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
@@ -38,6 +38,25 @@ LOCAL_COMMAND_ALIASES = {
|
||||
"debug": "telemetry_debug",
|
||||
"余额": "deepseek_balance",
|
||||
"steam": "steam_friends",
|
||||
"上下文": "context_summary",
|
||||
"context": "context_summary",
|
||||
"金币": "wallet",
|
||||
"wallet": "wallet",
|
||||
"抽奖": "prize_draw",
|
||||
"draw": "prize_draw",
|
||||
"买彩票": "lottery",
|
||||
"lottery": "lottery",
|
||||
"21点": "blackjack",
|
||||
"二十一点": "blackjack",
|
||||
"blackjack": "blackjack",
|
||||
"游戏帮助": "game_rules",
|
||||
"games": "game_rules",
|
||||
"人物列表": "person_directory",
|
||||
"别名列表": "person_directory",
|
||||
"people": "person_directory",
|
||||
"人物": "person_info",
|
||||
"个人属性": "person_info",
|
||||
"person": "person_info",
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +110,31 @@ DIRECT_KERNEL_COMMAND_KINDS = frozenset(
|
||||
)
|
||||
|
||||
LOCAL_EXTENSIONS = (
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="context-summary",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="提取当前群最近100、200或300条本地文字的上下文",
|
||||
help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
|
||||
command_kinds=("context_summary",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="single-player-games",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="本人虚拟金币账户、抽奖、彩票和单人21点",
|
||||
help_entries=(
|
||||
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery",
|
||||
"/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end",
|
||||
"/游戏帮助;/games",
|
||||
),
|
||||
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="person-directory",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="查询当前群已配置人物、基本属性和别名",
|
||||
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
|
||||
command_kinds=("person_directory", "person_info"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="person-memory",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
@@ -237,7 +281,6 @@ def render_feature_list() -> str:
|
||||
)
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
lines.append(
|
||||
f"[{descriptor.author}/{descriptor.extension_id}] "
|
||||
f"{descriptor.description}:"
|
||||
+ ";".join(descriptor.help_entries)
|
||||
)
|
||||
|
||||
@@ -9,11 +9,13 @@ import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||
from .player_games import PlayerGames
|
||||
|
||||
|
||||
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
||||
@@ -104,6 +106,7 @@ class LocalCommandEngine:
|
||||
self.person_display_label = person_display_label
|
||||
self.profile_fact_is_safe = profile_fact_is_safe
|
||||
self.profile_expiry = profile_expiry
|
||||
self.players = PlayerGames(ledger, person_display_label)
|
||||
|
||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
question = str(row["question"])
|
||||
@@ -112,6 +115,12 @@ class LocalCommandEngine:
|
||||
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
||||
if kind == "feature_list":
|
||||
return self.reply_cleaner(self.feature_list())
|
||||
if kind == "context_summary":
|
||||
return self.reply_cleaner(self.context_summary(row, arguments))
|
||||
if kind in {"person_directory", "person_info"}:
|
||||
return self.reply_cleaner(self.person_index(row, kind, arguments))
|
||||
if kind in {"wallet", "prize_draw", "lottery", "blackjack", "game_rules"}:
|
||||
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||||
if kind == "dice":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
@@ -229,6 +238,82 @@ class LocalCommandEngine:
|
||||
)
|
||||
return f"本地记录命中“{query}”:{listing}"
|
||||
|
||||
def context_summary(self, row: dict[str, Any], arguments: str) -> str:
|
||||
arguments = arguments.strip()
|
||||
if arguments.strip() not in {"", "100", "200", "300"}:
|
||||
return "用法:/上下文 100|200|300(/context),默认100条"
|
||||
limit = int(arguments or 100)
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
records = self.ledger.recent_context_messages(
|
||||
str(row["group_fingerprint"]), limit,
|
||||
exclude_event_id=str(row["event_id"]))
|
||||
if not records:
|
||||
return "当前群还没有可总结的本地文字记录"
|
||||
# Local extractive summary: rank representative messages, retaining chronology.
|
||||
stop = {"这个", "那个", "就是", "然后", "现在", "我们", "你们", "可以",
|
||||
"不是", "一个", "什么", "怎么", "感觉", "还是", "已经", "没有",
|
||||
"the", "and", "you", "that", "this", "jwebot"}
|
||||
texts = [re.sub(r"^@\S+\s+", "", normalize_text(record["text"])) for record in records]
|
||||
tokens = [set(re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}|[\u4e00-\u9fff]{2}", text.casefold())) - stop
|
||||
for text in texts]
|
||||
frequency = Counter(token for message in tokens for token in message)
|
||||
topics = [token for token, count in frequency.most_common(6) if count > 1]
|
||||
scored = sorted(range(len(records)), key=lambda i: (
|
||||
-sum(frequency[token] for token in tokens[i]),
|
||||
-int(bool(re.search(r"决定|计划|问题|需要|建议|修复|明天|[??]", texts[i]))),
|
||||
-i))
|
||||
selected: list[int] = []
|
||||
seen: set[str] = set()
|
||||
for index in scored:
|
||||
if texts[index] in seen or texts[index].lstrip().startswith("/"):
|
||||
continue
|
||||
selected.append(index)
|
||||
seen.add(texts[index])
|
||||
if len(selected) == 5:
|
||||
break
|
||||
if not selected:
|
||||
selected = list(range(max(0, len(records) - 3), len(records)))
|
||||
people = len({record["display_name"] for record in records})
|
||||
lines = [f"上下文总结(本地提取):请求最近{limit}条,实际{len(records)}条,{people}人。",
|
||||
f"范围 {records[0]['observed_at'][:16]} ~ {records[-1]['observed_at'][:16]}。",
|
||||
"主要话题:" + ("、".join(topics) if topics else "话题分散,以下为代表性内容。")]
|
||||
for index in sorted(selected):
|
||||
record = records[index]
|
||||
lines.append(f"• {record['display_name']}:{texts[index][:100]}")
|
||||
lines.append("仅覆盖本机已记录的当前群文字;问题或计划按原话提取。")
|
||||
return "\n".join(lines)
|
||||
|
||||
def person_index(self, row: dict[str, Any], kind: str, arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
directory = self.ledger.person_directory(group)
|
||||
if kind == "person_directory":
|
||||
if arguments and not re.fullmatch(r"[1-9]\d{0,2}", arguments):
|
||||
return "用法:/人物列表 [页码](/people),每页5人"
|
||||
page = int(arguments or 1)
|
||||
pages = max(1, (len(directory) + 4) // 5)
|
||||
if page > pages:
|
||||
return f"只有 {pages} 页;/人物列表 1"
|
||||
lines = [f"当前群人物与别名({page}/{pages},共{len(directory)}人)"]
|
||||
for person in directory[(page - 1) * 5:page * 5]:
|
||||
aliases = "、".join(person["aliases"])[:80] or "未配置"
|
||||
prefixes = "、".join(person["prefixes"])[:40]
|
||||
lines.append(f"{person['display_name']}:{aliases}" + (f";前缀 {prefixes}*" if prefixes else ""))
|
||||
lines.append("/人物 [名称或别名](/person)查看基本属性;/人物列表 页码 翻页")
|
||||
return "\n".join(lines)
|
||||
requested = normalize_text(arguments) or str(row["sender"])
|
||||
person = self.ledger.resolve_person(group, requested)
|
||||
entry = next((item for item in directory if item["person_key"] == person["person_key"]), None)
|
||||
if arguments and entry is None:
|
||||
return "当前群没有这个已配置人物或别名;/人物列表 查看索引"
|
||||
facts = str(person.get("identity_fact", ""))
|
||||
if facts and not self.profile_fact_is_safe(facts):
|
||||
facts = ""
|
||||
aliases = "、".join(entry["aliases"])[:160] if entry else "未配置"
|
||||
return (f"人物:{self.person_display_label(person['display_name'])};"
|
||||
f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n"
|
||||
f"基本属性:{facts or '暂无配置'}。\n"
|
||||
"本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。")
|
||||
|
||||
@staticmethod
|
||||
def service_state(service_name: str) -> str:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Single-player virtual currency rules, invoked by the reviewed Kernel service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
INITIAL_COINS = 1000
|
||||
MAX_BET = 100
|
||||
GAME_TIMEOUT_SECONDS = 600
|
||||
|
||||
|
||||
def card_label(card: int) -> str:
|
||||
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
|
||||
|
||||
|
||||
def hand_value(cards: list[int]) -> int:
|
||||
total = sum(min(card % 13 + 1, 10) if card % 13 else 11 for card in cards)
|
||||
aces = sum(card % 13 == 0 for card in cards)
|
||||
while total > 21 and aces:
|
||||
total -= 10
|
||||
aces -= 1
|
||||
return total
|
||||
|
||||
|
||||
class PlayerGames:
|
||||
def __init__(self, ledger: Any, display_label: Any) -> None:
|
||||
self.ledger = ledger
|
||||
self.display_label = display_label
|
||||
self.rng = random.SystemRandom()
|
||||
|
||||
@staticmethod
|
||||
def rules() -> str:
|
||||
return (
|
||||
"单人游戏:每人首次使用获 1000 虚拟金币;每次下注 2~100。\n"
|
||||
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
|
||||
"/21点 开始 [金额] /blackjack start。\n"
|
||||
"21点:/21点 要牌|停牌|状态|结束(hit|stand|status|end);"
|
||||
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
|
||||
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
|
||||
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
|
||||
"以上均含本金;虚拟金币不能充值、转账或兑换。"
|
||||
)
|
||||
|
||||
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
|
||||
arguments = arguments.strip()
|
||||
if kind == "game_rules":
|
||||
return self.rules()
|
||||
if arguments.casefold() in {"帮助", "规则", "help", "rules"}:
|
||||
return self.rules()
|
||||
if kind == "wallet" and arguments:
|
||||
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则"
|
||||
if kind == "prize_draw":
|
||||
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
|
||||
return "用法:/抽奖 [2~100金币](/draw),默认10金币"
|
||||
bet = int(arguments or 10)
|
||||
elif kind == "lottery":
|
||||
match = re.fullmatch(r"(?:(\d{3})(?:\s+(\d{1,3}))?)?", arguments)
|
||||
if match is None:
|
||||
return "用法:/买彩票 [三位号码000~999] [2~100金币](/lottery),默认随机号码、10金币"
|
||||
number = int(match[1]) if match[1] is not None else None
|
||||
bet = int(match[2] or 10)
|
||||
elif kind == "blackjack":
|
||||
parts = arguments.casefold().split()
|
||||
action = parts[0] if parts else "状态"
|
||||
actions = {"开始": "start", "start": "start", "要牌": "hit", "hit": "hit",
|
||||
"停牌": "stand", "stand": "stand", "状态": "status", "status": "status",
|
||||
"结束": "end", "end": "end"}
|
||||
action = actions.get(action, "")
|
||||
if not action or (action != "start" and len(parts) > 1) or len(parts) > 2:
|
||||
return "用法:/21点 开始 [金额]|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)"
|
||||
if action == "start":
|
||||
if len(parts) == 2 and not re.fullmatch(r"\d{1,3}", parts[1]):
|
||||
return "21点下注必须为 2~100 的整数金币"
|
||||
bet = int(parts[1]) if len(parts) == 2 else 50
|
||||
else:
|
||||
bet = 0
|
||||
else:
|
||||
bet = 0
|
||||
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
|
||||
if not 2 <= bet <= MAX_BET:
|
||||
return "下注必须为 2~100 的整数金币"
|
||||
|
||||
def execute(account: dict[str, Any]) -> str:
|
||||
expired = ""
|
||||
game = account.get("game")
|
||||
if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS:
|
||||
account["game"] = None
|
||||
account["losses"] += 1
|
||||
expired = "上一局已超时,按认输结束;"
|
||||
if kind == "wallet":
|
||||
state = "进行中" if account.get("game") else "无进行中的牌局"
|
||||
return (f"{expired}金币 {account['balance']};21点 {state};"
|
||||
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
|
||||
"首次赠送1000仅发一次;/游戏帮助 查看规则")
|
||||
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
|
||||
return expired + self.play_blackjack(account, action)
|
||||
if account.get("game"):
|
||||
return expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
|
||||
if account["balance"] < bet:
|
||||
return expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
|
||||
account["balance"] -= bet
|
||||
if kind == "blackjack":
|
||||
deck = list(range(52))
|
||||
self.rng.shuffle(deck)
|
||||
game = {"bet": bet, "deck": deck[4:], "player": deck[:2],
|
||||
"dealer": deck[2:4], "updated_at": time.time()}
|
||||
account["game"] = game
|
||||
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
|
||||
return expired + self.finish_blackjack(account, natural=True)
|
||||
return expired + self.show_hand(game)
|
||||
if kind == "prize_draw":
|
||||
roll = self.rng.randrange(100)
|
||||
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
|
||||
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
|
||||
else:
|
||||
selected = self.rng.randrange(1000) if number is None else number
|
||||
winning = self.rng.randrange(1000)
|
||||
payout = bet * 500 if selected == winning else 0
|
||||
result = f"号码 {selected:03d};开奖号 {winning:03d};" + ("中奖" if payout else "未中奖")
|
||||
account["balance"] += payout
|
||||
account["wins" if payout > bet else "losses"] += 1
|
||||
return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}"
|
||||
|
||||
return self.ledger.apply_player_action(
|
||||
str(row["group_fingerprint"]), str(row["sender"]),
|
||||
str(row["event_id"]), kind + ":" + arguments, execute)
|
||||
|
||||
@staticmethod
|
||||
def show_hand(game: dict[str, Any]) -> str:
|
||||
player = " ".join(card_label(card) for card in game["player"])
|
||||
return (f"21点:你的牌 {player}({hand_value(game['player'])}点);"
|
||||
f"庄家 {card_label(game['dealer'][0])} + 暗牌;下注 {game['bet']}。\n"
|
||||
"/21点 要牌(hit)或 /21点 停牌(stand)")
|
||||
|
||||
def play_blackjack(self, account: dict[str, Any], action: str) -> str:
|
||||
game = account.get("game")
|
||||
if not game:
|
||||
return "本人没有进行中的21点;/21点 开始 [金额](/blackjack start)"
|
||||
if action == "status":
|
||||
return self.show_hand(game)
|
||||
if action == "end":
|
||||
account["game"] = None
|
||||
account["losses"] += 1
|
||||
return f"本人的21点已认输结束,不退下注;金币 {account['balance']}"
|
||||
game["updated_at"] = time.time()
|
||||
if action == "hit":
|
||||
game["player"].append(game["deck"].pop())
|
||||
if hand_value(game["player"]) > 21:
|
||||
return self.finish_blackjack(account)
|
||||
return self.show_hand(game)
|
||||
while hand_value(game["dealer"]) < 17:
|
||||
game["dealer"].append(game["deck"].pop())
|
||||
return self.finish_blackjack(account)
|
||||
|
||||
@staticmethod
|
||||
def finish_blackjack(account: dict[str, Any], *, natural: bool = False) -> str:
|
||||
game = account["game"]
|
||||
player = hand_value(game["player"])
|
||||
dealer = hand_value(game["dealer"])
|
||||
bet = game["bet"]
|
||||
if player > 21 or natural and dealer == 21 and player != 21:
|
||||
outcome, payout, stat = "你输了", 0, "losses"
|
||||
elif player == dealer:
|
||||
outcome, payout, stat = "平局", bet, "ties"
|
||||
elif natural and player == 21:
|
||||
outcome, payout, stat = "自然21点获胜", bet * 5 // 2, "wins"
|
||||
elif dealer > 21 or player > dealer:
|
||||
outcome, payout, stat = "你赢了", bet * 2, "wins"
|
||||
else:
|
||||
outcome, payout, stat = "你输了", 0, "losses"
|
||||
account["balance"] += payout
|
||||
account[stat] += 1
|
||||
account["game"] = None
|
||||
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点),"
|
||||
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);"
|
||||
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}")
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "0.6.0a0+1015"
|
||||
version = "0.6.1a0+1015"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
@@ -38,4 +38,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
__version__ = "0.6.1a0+1015"
|
||||
|
||||
Reference in New Issue
Block a user