release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Reviewed JWeBot local command adapters and broker implementation."""
|
||||
|
||||
from .descriptors import (
|
||||
DIRECT_KERNEL_COMMANDS,
|
||||
DIRECT_KERNEL_COMMAND_KINDS,
|
||||
JWEBOT_AUTHOR,
|
||||
LOCAL_COMMAND_ALIASES,
|
||||
LOCAL_EXTENSIONS,
|
||||
OFFICIAL_AUTHOR,
|
||||
KernelCommandDescriptor,
|
||||
LocalExtensionDescriptor,
|
||||
extension_for_kind,
|
||||
render_feature_list,
|
||||
)
|
||||
from .local_commands import (
|
||||
DICE_CHALLENGE_TIMEOUT_SECONDS,
|
||||
LOCAL_GAME_TIMEOUT_SECONDS,
|
||||
MAX_RPS_PLAYERS,
|
||||
LocalCommandEngine,
|
||||
LocalCommandError,
|
||||
deterministic_int,
|
||||
local_command_parts,
|
||||
)
|
||||
from .runtime import (
|
||||
LocalExtensionError,
|
||||
LocalExtensionRuntime,
|
||||
LocalServiceBroker,
|
||||
local_extension_command_token,
|
||||
)
|
||||
from .steam import (
|
||||
DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
||||
STEAM_API_BASE_URL,
|
||||
SteamClient,
|
||||
SteamUnavailable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
||||
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
||||
"DIRECT_KERNEL_COMMANDS",
|
||||
"DIRECT_KERNEL_COMMAND_KINDS",
|
||||
"JWEBOT_AUTHOR",
|
||||
"LOCAL_COMMAND_ALIASES",
|
||||
"LOCAL_EXTENSIONS",
|
||||
"LOCAL_GAME_TIMEOUT_SECONDS",
|
||||
"MAX_RPS_PLAYERS",
|
||||
"OFFICIAL_AUTHOR",
|
||||
"KernelCommandDescriptor",
|
||||
"LocalExtensionDescriptor",
|
||||
"LocalExtensionError",
|
||||
"LocalExtensionRuntime",
|
||||
"LocalServiceBroker",
|
||||
"STEAM_API_BASE_URL",
|
||||
"SteamClient",
|
||||
"SteamUnavailable",
|
||||
"LocalCommandEngine",
|
||||
"LocalCommandError",
|
||||
"deterministic_int",
|
||||
"local_command_parts",
|
||||
"local_extension_command_token",
|
||||
"extension_for_kind",
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
"""Generated official SDK command adapters."""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for coin."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'coin'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '抛一枚硬币'
|
||||
HELP_ENTRIES = ('/翻硬币',)
|
||||
COMMANDS = ('/翻硬币',)
|
||||
|
||||
|
||||
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 daily-fortune."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'daily-fortune'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '生成当天稳定一致的运势'
|
||||
HELP_ENTRIES = ('/今日运势',)
|
||||
COMMANDS = ('/今日运势',)
|
||||
|
||||
|
||||
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 debug-telemetry."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'debug-telemetry'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '埋点 p50/p95'
|
||||
HELP_ENTRIES = ('/debug',)
|
||||
COMMANDS = ('/debug',)
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for dice."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'dice'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '骰子和多人骰子挑战'
|
||||
HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]')
|
||||
COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战')
|
||||
|
||||
|
||||
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 draw-lot."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'draw-lot'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '抽取一条本地签文'
|
||||
HELP_ENTRIES = ('/抽签',)
|
||||
COMMANDS = ('/抽签',)
|
||||
|
||||
|
||||
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 guess-number."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'guess-number'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '群内持久化猜数字游戏'
|
||||
HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',)
|
||||
COMMANDS = ('/猜数字', '/猜')
|
||||
|
||||
|
||||
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 history-search."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'history-search'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '搜索本地群聊文字记录'
|
||||
HELP_ENTRIES = ('/查记录 关键词',)
|
||||
COMMANDS = ('/查记录', '/历史搜索')
|
||||
|
||||
|
||||
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 jwebot-version."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'jwebot-version'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = 'JWeBot 版本'
|
||||
HELP_ENTRIES = ('/version',)
|
||||
COMMANDS = ('/version',)
|
||||
|
||||
|
||||
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-memory."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'person-memory'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '管理个人和同群人物记忆'
|
||||
HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',)
|
||||
COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助')
|
||||
|
||||
|
||||
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 random-number."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'random-number'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '在指定整数范围内取随机数'
|
||||
HELP_ENTRIES = ('/随机数 最小值 最大值',)
|
||||
COMMANDS = ('/随机数',)
|
||||
|
||||
|
||||
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 repeat-ranking."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'repeat-ranking'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '查看北京时间今日复读排行'
|
||||
HELP_ENTRIES = ('/复读榜',)
|
||||
COMMANDS = ('/复读榜', '/复读')
|
||||
|
||||
|
||||
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 rock-paper-scissors."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'rock-paper-scissors'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '多人石头剪刀布游戏'
|
||||
HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',)
|
||||
COMMANDS = ('/石头剪刀布',)
|
||||
|
||||
|
||||
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 scan-ranking."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'scan-ranking'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '查看北京时间今日扫码排行'
|
||||
HELP_ENTRIES = ('/扫码排行',)
|
||||
COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码')
|
||||
|
||||
|
||||
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 server-status."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'server-status'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '服务器状态'
|
||||
HELP_ENTRIES = ('/状态;/status',)
|
||||
COMMANDS = ('/状态', '/status')
|
||||
|
||||
|
||||
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 steam-presence."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'steam-presence'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = 'Steam 状态与时长'
|
||||
HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',)
|
||||
COMMANDS = ('/steam',)
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Ownership map for Kernel commands and reviewed local extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
LOCAL_COMMAND_ALIASES = {
|
||||
"功能列表": "feature_list",
|
||||
"帮助": "feature_list",
|
||||
"help": "feature_list",
|
||||
"丢骰子": "dice",
|
||||
"骰子": "dice",
|
||||
"丢骰子挑战": "dice_challenge",
|
||||
"骰子挑战": "dice_challenge",
|
||||
"翻硬币": "coin",
|
||||
"随机数": "random_number",
|
||||
"抽签": "draw_lot",
|
||||
"今日运势": "daily_fortune",
|
||||
"猜数字": "guess_number",
|
||||
"猜": "guess_number",
|
||||
"石头剪刀布": "rps",
|
||||
"记住": "remember",
|
||||
"我的记忆": "my_memory",
|
||||
"忘记": "forget_memory",
|
||||
"人物记忆": "person_memory",
|
||||
"记忆帮助": "memory_help",
|
||||
"扫码排行": "scan_ranking",
|
||||
"扫码榜": "scan_ranking",
|
||||
"今日扫码": "scan_ranking",
|
||||
"复读榜": "repeat_ranking",
|
||||
"复读": "repeat_ranking",
|
||||
"查记录": "history_search",
|
||||
"历史搜索": "history_search",
|
||||
"状态": "local_status",
|
||||
"status": "server_status",
|
||||
"version": "jwebot_version",
|
||||
"debug": "telemetry_debug",
|
||||
"余额": "deepseek_balance",
|
||||
"steam": "steam_friends",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalExtensionDescriptor:
|
||||
extension_id: str
|
||||
author: str
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
command_kinds: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
return self.author.casefold()
|
||||
|
||||
@property
|
||||
def commands(self) -> tuple[str, ...]:
|
||||
owned = set(self.command_kinds)
|
||||
return tuple(
|
||||
f"/{alias}"
|
||||
for alias, kind in LOCAL_COMMAND_ALIASES.items()
|
||||
if kind in owned
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KernelCommandDescriptor:
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
command_kinds: tuple[str, ...]
|
||||
|
||||
|
||||
OFFICIAL_AUTHOR = "official"
|
||||
JWEBOT_AUTHOR = "jwebot"
|
||||
DIRECT_KERNEL_COMMANDS = (
|
||||
KernelCommandDescriptor(
|
||||
description="查看由插件元数据自动生成的功能列表",
|
||||
help_entries=("/功能列表(别名 /帮助、/help)",),
|
||||
command_kinds=("feature_list",),
|
||||
),
|
||||
KernelCommandDescriptor(
|
||||
description="查看 DeepSeek 账户余额",
|
||||
help_entries=("/余额",),
|
||||
command_kinds=("deepseek_balance",),
|
||||
),
|
||||
)
|
||||
DIRECT_KERNEL_COMMAND_KINDS = frozenset(
|
||||
kind
|
||||
for descriptor in DIRECT_KERNEL_COMMANDS
|
||||
for kind in descriptor.command_kinds
|
||||
)
|
||||
|
||||
LOCAL_EXTENSIONS = (
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="person-memory",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="管理个人和同群人物记忆",
|
||||
help_entries=(
|
||||
"/记住 内容;/我的记忆;/忘记 关键词或全部;"
|
||||
"/人物记忆 [显示名]",
|
||||
),
|
||||
command_kinds=(
|
||||
"remember",
|
||||
"my_memory",
|
||||
"forget_memory",
|
||||
"person_memory",
|
||||
"memory_help",
|
||||
),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="server-status",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="服务器状态",
|
||||
help_entries=("/状态;/status",),
|
||||
command_kinds=("local_status", "server_status"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="jwebot-version",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="JWeBot 版本",
|
||||
help_entries=("/version",),
|
||||
command_kinds=("jwebot_version",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="debug-telemetry",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="埋点 p50/p95",
|
||||
help_entries=("/debug",),
|
||||
command_kinds=("telemetry_debug",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="dice",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="骰子和多人骰子挑战",
|
||||
help_entries=(
|
||||
"/丢骰子 [NdM]",
|
||||
"/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]",
|
||||
),
|
||||
command_kinds=("dice", "dice_challenge"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="coin",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="抛一枚硬币",
|
||||
help_entries=("/翻硬币",),
|
||||
command_kinds=("coin",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="random-number",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="在指定整数范围内取随机数",
|
||||
help_entries=("/随机数 最小值 最大值",),
|
||||
command_kinds=("random_number",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="draw-lot",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="抽取一条本地签文",
|
||||
help_entries=("/抽签",),
|
||||
command_kinds=("draw_lot",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="daily-fortune",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="生成当天稳定一致的运势",
|
||||
help_entries=("/今日运势",),
|
||||
command_kinds=("daily_fortune",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="guess-number",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="群内持久化猜数字游戏",
|
||||
help_entries=(
|
||||
"/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]",
|
||||
),
|
||||
command_kinds=("guess_number",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="rock-paper-scissors",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="多人石头剪刀布游戏",
|
||||
help_entries=(
|
||||
"/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]",
|
||||
),
|
||||
command_kinds=("rps",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="scan-ranking",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="查看北京时间今日扫码排行",
|
||||
help_entries=("/扫码排行",),
|
||||
command_kinds=("scan_ranking",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="repeat-ranking",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="查看北京时间今日复读排行",
|
||||
help_entries=("/复读榜",),
|
||||
command_kinds=("repeat_ranking",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="history-search",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="搜索本地群聊文字记录",
|
||||
help_entries=("/查记录 关键词",),
|
||||
command_kinds=("history_search",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="steam-presence",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="Steam 状态与时长",
|
||||
help_entries=(
|
||||
"/steam;/steam时长 游戏[、玩家]",
|
||||
),
|
||||
command_kinds=("steam_friends",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def extension_for_kind(kind: str) -> LocalExtensionDescriptor | None:
|
||||
return next(
|
||||
(
|
||||
extension
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
if kind in extension.command_kinds
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def render_feature_list() -> str:
|
||||
lines = ["功能列表(由内核和插件描述自动生成)"]
|
||||
for descriptor in DIRECT_KERNEL_COMMANDS:
|
||||
lines.append(
|
||||
f"[内核] {descriptor.description}:"
|
||||
+ ";".join(descriptor.help_entries)
|
||||
)
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
lines.append(
|
||||
f"[{descriptor.author}/{descriptor.extension_id}] "
|
||||
f"{descriptor.description}:"
|
||||
+ ";".join(descriptor.help_entries)
|
||||
)
|
||||
lines.extend(
|
||||
(
|
||||
"[DeepSeek] 普通 @JWeBot 消息走本地直连 DeepSeek",
|
||||
"[OpenClaw] @JWeBot #任务进入 OpenClaw",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _validate_inventory() -> None:
|
||||
extension_kinds = [
|
||||
kind
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
for kind in extension.command_kinds
|
||||
]
|
||||
if len(extension_kinds) != len(set(extension_kinds)):
|
||||
raise RuntimeError("local extension command ownership overlaps")
|
||||
known_kinds = set(LOCAL_COMMAND_ALIASES.values())
|
||||
if set(extension_kinds) | DIRECT_KERNEL_COMMAND_KINDS != known_kinds:
|
||||
raise RuntimeError("local command inventory is incomplete")
|
||||
if set(extension_kinds) & DIRECT_KERNEL_COMMAND_KINDS:
|
||||
raise RuntimeError("Kernel commands cannot be extension-owned")
|
||||
if any(
|
||||
not extension.description or not extension.help_entries
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
):
|
||||
raise RuntimeError("local extensions must provide help metadata")
|
||||
|
||||
|
||||
_validate_inventory()
|
||||
@@ -0,0 +1,953 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official local command implementations for the packaged Edge runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||
|
||||
|
||||
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
||||
DICE_CHALLENGE_TIMEOUT_SECONDS = 60 * 60
|
||||
MAX_RPS_PLAYERS = 8
|
||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
||||
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||
|
||||
|
||||
class LocalCommandError(RuntimeError):
|
||||
"""Controlled local-command failure safe for the Edge response boundary."""
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def deterministic_int(seed: str, lower: int, upper: int) -> int:
|
||||
if lower > upper:
|
||||
raise LocalCommandError("invalid deterministic range")
|
||||
value = int.from_bytes(
|
||||
hashlib.sha256(seed.encode("utf-8", "replace")).digest()[:8],
|
||||
"big",
|
||||
)
|
||||
return lower + value % (upper - lower + 1)
|
||||
|
||||
|
||||
def local_command_parts(question: str) -> tuple[str, str]:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
return "", ""
|
||||
raw = normalized[1:].lstrip()
|
||||
folded = raw.casefold()
|
||||
for command, kind in sorted(
|
||||
LOCAL_COMMAND_ALIASES.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
command_key = command.casefold()
|
||||
if folded == command_key:
|
||||
return kind, ""
|
||||
if folded.startswith(command_key):
|
||||
return kind, raw[len(command):].strip()
|
||||
command, _, arguments = raw.partition(" ")
|
||||
return LOCAL_COMMAND_ALIASES.get(command.casefold(), ""), arguments.strip()
|
||||
|
||||
|
||||
class LocalCommandEngine:
|
||||
"""Deterministic group mini-games backed by the local edge ledger."""
|
||||
|
||||
LOTS = (
|
||||
"今天适合开一局,别在大厅里演了喵",
|
||||
"抽到普通签,稳住节奏比整活更重要喵",
|
||||
"小吉,喊人失败就先自己排一把喵",
|
||||
"中吉,适合清理积压的小任务喵",
|
||||
"大吉,今天的手感看起来不太像会背刺你喵",
|
||||
"末吉,先喝口水,别急着和队友对线喵",
|
||||
"吉,能做完一点就是赚到一点喵",
|
||||
"平签,今天适合安静发育,少折腾喵",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ledger: Any,
|
||||
config: Any | None = None,
|
||||
*,
|
||||
steam: Any | None = None,
|
||||
balance_provider: Callable[[], list[dict[str, str]]] | None = None,
|
||||
reply_cleaner: Callable[[object], str],
|
||||
person_display_label: Callable[[object], str],
|
||||
profile_fact_is_safe: Callable[[object], bool],
|
||||
profile_expiry: Callable[..., float],
|
||||
) -> None:
|
||||
self.ledger = ledger
|
||||
self.config = config
|
||||
self.steam = steam
|
||||
self.balance_provider = balance_provider
|
||||
self.reply_cleaner = reply_cleaner
|
||||
self.person_display_label = person_display_label
|
||||
self.profile_fact_is_safe = profile_fact_is_safe
|
||||
self.profile_expiry = profile_expiry
|
||||
|
||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
question = str(row["question"])
|
||||
kind, arguments = local_command_parts(question)
|
||||
if not kind:
|
||||
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
||||
if kind == "feature_list":
|
||||
return self.reply_cleaner(self.feature_list())
|
||||
if kind == "dice":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
return self.reply_cleaner(self.dice_challenge(row, arguments))
|
||||
if kind == "coin":
|
||||
return self.reply_cleaner(self.coin(row))
|
||||
if kind == "random_number":
|
||||
return self.reply_cleaner(self.random_number(row, arguments))
|
||||
if kind == "draw_lot":
|
||||
return self.reply_cleaner(self.draw_lot(row))
|
||||
if kind == "daily_fortune":
|
||||
return self.reply_cleaner(self.daily_fortune(row, event))
|
||||
if kind == "guess_number":
|
||||
return self.reply_cleaner(self.guess_number(row, arguments))
|
||||
if kind == "rps":
|
||||
return self.reply_cleaner(self.rps(row, arguments))
|
||||
if kind == "remember":
|
||||
return self.reply_cleaner(self.remember(row, arguments))
|
||||
if kind == "my_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, ""))
|
||||
if kind == "forget_memory":
|
||||
return self.reply_cleaner(self.forget_memory(row, arguments))
|
||||
if kind == "person_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, arguments))
|
||||
if kind == "memory_help":
|
||||
return self.reply_cleaner(self.memory_help())
|
||||
if kind == "scan_ranking":
|
||||
return self.reply_cleaner(self.scan_ranking(row))
|
||||
if kind == "repeat_ranking":
|
||||
return self.reply_cleaner(self.repeat_ranking(row))
|
||||
if kind == "history_search":
|
||||
return self.reply_cleaner(self.history_search(row, arguments))
|
||||
if kind in {"local_status", "server_status"}:
|
||||
return self.reply_cleaner(self.server_status())
|
||||
if kind == "jwebot_version":
|
||||
return self.reply_cleaner(self.jwebot_version())
|
||||
if kind == "telemetry_debug":
|
||||
return self.reply_cleaner(self.telemetry_debug())
|
||||
if kind == "deepseek_balance":
|
||||
return self.reply_cleaner(self.deepseek_balance())
|
||||
if kind == "steam_friends":
|
||||
return self.reply_cleaner(self.steam_friends(arguments))
|
||||
raise LocalCommandError("unsupported local command")
|
||||
|
||||
@staticmethod
|
||||
def feature_list() -> str:
|
||||
return render_feature_list()
|
||||
|
||||
@staticmethod
|
||||
def seed(row: dict[str, Any], label: str) -> str:
|
||||
return f"{row['group_fingerprint']}|{row['event_id']}|{label}"
|
||||
|
||||
@staticmethod
|
||||
def player_key(row: dict[str, Any]) -> str:
|
||||
return sha256(str(row["sender"]))[:16]
|
||||
|
||||
@staticmethod
|
||||
def memory_help() -> str:
|
||||
return (
|
||||
"记忆命令:/记住 内容 会立刻保存,/我的记忆 查看自己,"
|
||||
"/人物记忆 显示名 查看同群非敏感摘要,"
|
||||
"/忘记 关键词 或 /忘记 全部 可以清理自己的记忆"
|
||||
)
|
||||
|
||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.clock_scan_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有已确认送达的扫码记录"
|
||||
listing = ";".join(
|
||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
return f"北京时间今日扫码排行:{listing}"
|
||||
|
||||
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.repeat_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有可统计的复读"
|
||||
lines = []
|
||||
for index, entry in enumerate(ranking, start=1):
|
||||
repeaters = "、".join(
|
||||
f"{name}×{count}"
|
||||
for name, count in sorted(entry["repeaters"].items())
|
||||
)
|
||||
lines.append(
|
||||
f"{index}. “{entry['text'][:48]}” 首发 {entry['origin']},"
|
||||
f"后续复读 {entry['repeat_count']} 次({repeaters})"
|
||||
)
|
||||
return "北京时间今日复读榜:" + ";".join(lines)
|
||||
|
||||
def history_search(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if len(query) < 2:
|
||||
return "用法:/查记录 关键词,至少两个字"
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
records = self.ledger.search_repeat_history(
|
||||
str(row["group_fingerprint"]),
|
||||
query,
|
||||
)
|
||||
if not records:
|
||||
return f"当前群没有找到“{query}”的本地历史记录"
|
||||
listing = ";".join(
|
||||
f"{record['observed_at'][5:16]} {record['display_name']}:"
|
||||
f"{record['text'][:80]}"
|
||||
for record in records
|
||||
)
|
||||
return f"本地记录命中“{query}”:{listing}"
|
||||
|
||||
@staticmethod
|
||||
def service_state(service_name: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "--user", "is-active", service_name],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unknown"
|
||||
return "active" if result.returncode == 0 else "inactive"
|
||||
|
||||
def local_status(self) -> str:
|
||||
state = self.ledger.status()
|
||||
edge_service = self.service_state("jwebot-edge.service")
|
||||
ocr_service = self.service_state("wechat-ocr-adaptive-recorder.service")
|
||||
direct_api = "已配置" if self.config and self.config.deepseek_key else "未配置"
|
||||
return (
|
||||
f"本地状态:Edge {edge_service},OCR {ocr_service},"
|
||||
f"Flash {direct_api};直聊待处理 {state['direct_chat_pending']},"
|
||||
f"云端待处理 {state['outbox_pending']},"
|
||||
f"本地游戏 {state['local_games']}"
|
||||
)
|
||||
|
||||
def server_status(self) -> str:
|
||||
return self.local_status()
|
||||
|
||||
@staticmethod
|
||||
def package_version(module_name: str) -> str:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return str(getattr(module, "__version__", "unknown"))
|
||||
|
||||
def jwebot_version(self) -> str:
|
||||
return (
|
||||
"JWeBot 版本:"
|
||||
f"kernel {self.package_version('jwebot_kernel')},"
|
||||
f"sdk {self.package_version('jwebot_sdk')},"
|
||||
"official "
|
||||
f"{self.package_version('jwebot_official_extensions')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def percentile(values: list[float], percent: float) -> float:
|
||||
if not values:
|
||||
raise LocalCommandError("missing telemetry samples")
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
position = (len(ordered) - 1) * percent / 100
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (
|
||||
position - lower
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def telemetry_rows_from_text(text: str) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
if "telemetry " in line:
|
||||
line = line.split("telemetry ", 1)[1]
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
elapsed = float(value["elapsed_ms"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
name = str(value.get("name", ""))[:80]
|
||||
if not name:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"elapsed_ms": elapsed,
|
||||
"status": str(value.get("status", "unknown"))[:24],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def format_duration(milliseconds: float) -> str:
|
||||
if milliseconds >= 1000:
|
||||
return f"{milliseconds / 1000:.2f}s"
|
||||
return f"{milliseconds:.1f}ms"
|
||||
|
||||
@classmethod
|
||||
def render_telemetry_summary(cls, rows: list[dict[str, Any]]) -> str:
|
||||
values_by_name: dict[str, list[float]] = {}
|
||||
for row in rows:
|
||||
if row.get("status") != "ok":
|
||||
continue
|
||||
values_by_name.setdefault(str(row["name"]), []).append(
|
||||
float(row["elapsed_ms"])
|
||||
)
|
||||
if not values_by_name:
|
||||
return "调试埋点:最近没有可汇总的耗时样本"
|
||||
ranked = sorted(
|
||||
values_by_name.items(),
|
||||
key=lambda item: cls.percentile(item[1], 95),
|
||||
reverse=True,
|
||||
)[:8]
|
||||
lines = ["调试埋点 p50/p95(最近2小时)"]
|
||||
for name, values in ranked:
|
||||
lines.append(
|
||||
f"- {name}: n={len(values)},"
|
||||
f"p50={cls.format_duration(cls.percentile(values, 50))},"
|
||||
f"p95={cls.format_duration(cls.percentile(values, 95))},"
|
||||
f"max={cls.format_duration(max(values))}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def telemetry_debug(self) -> str:
|
||||
command = [
|
||||
"/usr/bin/journalctl",
|
||||
"--user",
|
||||
"-u",
|
||||
"jwebot-edge.service",
|
||||
"-u",
|
||||
"wechat-ocr-adaptive-recorder.service",
|
||||
"--since",
|
||||
"2 hours ago",
|
||||
"--no-pager",
|
||||
"-o",
|
||||
"cat",
|
||||
"-n",
|
||||
"2000",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=4,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "调试埋点:journal 当前不可用"
|
||||
if result.returncode != 0:
|
||||
return "调试埋点:journal 当前不可用"
|
||||
return self.render_telemetry_summary(
|
||||
self.telemetry_rows_from_text(result.stdout)
|
||||
)
|
||||
|
||||
def deepseek_balance(self) -> str:
|
||||
if self.config is None or self.balance_provider is None:
|
||||
return "余额查询当前不可用"
|
||||
balances = self.balance_provider()
|
||||
if not balances:
|
||||
return "DeepSeek 当前没有可用余额"
|
||||
listing = ";".join(
|
||||
f"{item['currency']} 总 {item['total_balance']},"
|
||||
f"充值 {item['topped_up_balance']},赠金 {item['granted_balance']}"
|
||||
for item in balances
|
||||
)
|
||||
return f"DeepSeek 余额:{listing}"
|
||||
|
||||
def steam_friends(self, arguments: str) -> str:
|
||||
if self.steam is None:
|
||||
return "Steam 查询当前不可用"
|
||||
return self.steam.render(arguments)
|
||||
|
||||
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
person = self.ledger.resolve_person(
|
||||
str(row["group_fingerprint"]),
|
||||
str(row["sender"]),
|
||||
)
|
||||
return self.ledger.person_profile(
|
||||
str(row["group_fingerprint"]),
|
||||
str(person["person_key"]),
|
||||
)
|
||||
|
||||
def remember(self, row: dict[str, Any], arguments: str) -> str:
|
||||
text = normalize_text(arguments)
|
||||
if not text:
|
||||
return "要记什么直接写在后面,例如 /记住 我更喜欢技术细节"
|
||||
if not self.profile_fact_is_safe(text):
|
||||
return "这类内容不适合做个人记忆,换成偏好、话题或未完结事项吧"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
facts = list(profile["facts"]) if profile else []
|
||||
normalized = text.casefold()
|
||||
matched = next(
|
||||
(fact for fact in facts if str(fact["text"]).casefold() == normalized),
|
||||
None,
|
||||
)
|
||||
now = utc_now()
|
||||
if matched is None:
|
||||
facts.append(
|
||||
{
|
||||
"text": text,
|
||||
"kind": "preference",
|
||||
"source": "explicit",
|
||||
"evidence_count": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"expires_at": self.profile_expiry("preference", explicit=True),
|
||||
}
|
||||
)
|
||||
else:
|
||||
matched["source"] = "explicit"
|
||||
matched["updated_at"] = now
|
||||
matched["expires_at"] = self.profile_expiry("preference", explicit=True)
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
facts,
|
||||
)
|
||||
return "记下了,这条只在当前群的同一身份作用域里生效"
|
||||
|
||||
def show_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
requested = normalize_text(arguments)
|
||||
if requested:
|
||||
person = self.ledger.resolve_person(group, requested)
|
||||
profile = self.ledger.person_profile(
|
||||
group,
|
||||
str(person["person_key"]),
|
||||
)
|
||||
if profile is None:
|
||||
return "当前群没有这位显示名的可用记忆"
|
||||
else:
|
||||
profile = self.profile_for_row(row)
|
||||
if not profile or not profile["facts"]:
|
||||
return "暂时没有可用记忆,/记住 内容 可以直接补一条"
|
||||
facts = ";".join(
|
||||
f"[{fact.get('kind', 'topic')}] {fact['text']}"
|
||||
for fact in profile["facts"]
|
||||
)
|
||||
label = profile["display_name"]
|
||||
return f"{label} 的群内记忆:{facts}"
|
||||
|
||||
def forget_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if not query:
|
||||
return "用法:/忘记 关键词,或 /忘记 全部"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
if profile is None:
|
||||
return "没有可清理的个人记忆"
|
||||
if query == "全部":
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "当前群内的个人记忆已清空"
|
||||
kept = [
|
||||
fact
|
||||
for fact in profile["facts"]
|
||||
if query.casefold() not in str(fact["text"]).casefold()
|
||||
]
|
||||
if len(kept) == len(profile["facts"]):
|
||||
return "没有找到匹配的记忆"
|
||||
if kept:
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
kept,
|
||||
)
|
||||
else:
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "匹配的个人记忆已忘掉"
|
||||
|
||||
def dice(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = DICE_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "骰子格式不对,试试 /丢骰子 或 /丢骰子 2d6"
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count > 10 or not 2 <= faces <= 1_000:
|
||||
return "一次最多 10 个骰子,点数范围 2 到 1000,别拿骰盅砸服务器喵"
|
||||
rolls = [
|
||||
deterministic_int(self.seed(row, f"dice:{index}"), 1, faces)
|
||||
for index in range(count)
|
||||
]
|
||||
label = f"{count}d{faces}"
|
||||
if count == 1:
|
||||
return f"骰子 {label} 掷出了 {rolls[0]}"
|
||||
return f"骰子 {label}:{'、'.join(map(str, rolls))},合计 {sum(rolls)}"
|
||||
|
||||
@staticmethod
|
||||
def dice_challenge_spec(value: str) -> tuple[int, int] | None:
|
||||
match = DICE_PATTERN.fullmatch(value)
|
||||
if not match:
|
||||
return None
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count != 5 or not 6 <= faces <= 1_000:
|
||||
return None
|
||||
return count, faces
|
||||
|
||||
@staticmethod
|
||||
def is_five_straight(rolls: list[int]) -> bool:
|
||||
values = sorted(set(rolls))
|
||||
return len(values) == 5 and all(
|
||||
values[index] + 1 == values[index + 1]
|
||||
for index in range(4)
|
||||
)
|
||||
|
||||
def dice_challenge(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "dice_challenge")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有丢骰子挑战,直接 /丢骰子挑战 开一局 5d6"
|
||||
entries = state.get("entries", {})
|
||||
if not isinstance(entries, dict) or not entries:
|
||||
return f"本局是 {state['dice_count']}d{state['faces']},还没人掷骰"
|
||||
ranking = sorted(
|
||||
(
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
),
|
||||
key=lambda entry: (-int(entry.get("total", 0)), str(entry.get("display_name", ""))),
|
||||
)
|
||||
listing = ";".join(
|
||||
f"{entry.get('display_name', 'unknown')} {entry.get('total', 0)}"
|
||||
for entry in ranking
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']}:{listing}。"
|
||||
"继续 /丢骰子挑战 掷骰,或 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action == "战绩":
|
||||
achievements = self.ledger.dice_challenge_achievements(group)
|
||||
if not achievements:
|
||||
return "本群还没有丢骰子挑战达成记录"
|
||||
listing = ";".join(
|
||||
f"{entry['display_name']} [{entry['achievement']}] {entry['details']}"
|
||||
for entry in achievements
|
||||
)
|
||||
return f"丢骰子挑战战绩:{listing}"
|
||||
|
||||
if action in {"结算", "结束"}:
|
||||
if state is None:
|
||||
if expired:
|
||||
return "本局丢骰子挑战已超时,未结算点数不计战绩,重新开一局吧"
|
||||
return "没有进行中的丢骰子挑战"
|
||||
if action == "结束":
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局丢骰子挑战已结束,未结算的点数不计战绩"
|
||||
entries = state.get("entries", {})
|
||||
valid_entries = [
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
] if isinstance(entries, dict) else []
|
||||
if not valid_entries:
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局没人掷骰,挑战取消"
|
||||
highest = max(int(entry.get("total", 0)) for entry in valid_entries)
|
||||
winners = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if int(entry.get("total", 0)) == highest
|
||||
]
|
||||
for winner in winners:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=str(winner["person_key"]),
|
||||
display_name=str(winner["display_name"]),
|
||||
achievement="总点冠军",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} 合计 {highest}"
|
||||
),
|
||||
)
|
||||
ranking = sorted(
|
||||
valid_entries,
|
||||
key=lambda entry: (
|
||||
-int(entry.get("total", 0)),
|
||||
str(entry.get("display_name", "")),
|
||||
),
|
||||
)
|
||||
full_results = ";".join(
|
||||
(
|
||||
f"{index}. {entry['display_name']} {entry['total']} "
|
||||
f"({'、'.join(map(str, entry['rolls']))})"
|
||||
+ (" 五连号" if entry.get("five_straight") else "")
|
||||
)
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
winner_names = "、".join(str(winner["display_name"]) for winner in winners)
|
||||
straights = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if bool(entry.get("five_straight"))
|
||||
]
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
straight_note = (
|
||||
";五连号达成:" + "、".join(
|
||||
str(entry["display_name"]) for entry in straights
|
||||
)
|
||||
if straights
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']} 结算:"
|
||||
f"{full_results}。冠军 {winner_names},{highest} 点{straight_note}"
|
||||
)
|
||||
|
||||
requested_spec = ""
|
||||
if action == "开始":
|
||||
requested_spec = parts[1] if len(parts) == 2 else "5d6"
|
||||
if state is not None:
|
||||
return (
|
||||
f"本局已经是 {state['dice_count']}d{state['faces']},"
|
||||
"直接 /丢骰子挑战 掷骰"
|
||||
)
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None:
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "dice_challenge", state)
|
||||
return (
|
||||
f"丢骰子挑战开局:{state['dice_count']}d{state['faces']}。"
|
||||
"每人直接 /丢骰子挑战 掷骰,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action in {"", "掷", "骰"}:
|
||||
requested_spec = ""
|
||||
else:
|
||||
requested_spec = action
|
||||
if state is None:
|
||||
spec = self.dice_challenge_spec(requested_spec or "5d6")
|
||||
if spec is None:
|
||||
if expired:
|
||||
return "上一局挑战已超时,直接 /丢骰子挑战 或 /丢骰子挑战5d20 重开"
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
elif requested_spec:
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None or spec != (int(state["dice_count"]), int(state["faces"])):
|
||||
return (
|
||||
f"本局固定为 {state['dice_count']}d{state['faces']},"
|
||||
"先结算或结束再换规则"
|
||||
)
|
||||
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
entries = state.setdefault("entries", {})
|
||||
if person_key in entries:
|
||||
return "你这局已经掷过了,等大家掷完后 /丢骰子挑战 结算"
|
||||
rolls = [
|
||||
deterministic_int(
|
||||
self.seed(row, f"dice-challenge:{index}"),
|
||||
1,
|
||||
int(state["faces"]),
|
||||
)
|
||||
for index in range(int(state["dice_count"]))
|
||||
]
|
||||
total = sum(rolls)
|
||||
straight = self.is_five_straight(rolls)
|
||||
entries[person_key] = {
|
||||
"person_key": person_key,
|
||||
"display_name": self.person_display_label(person["display_name"]),
|
||||
"rolls": rolls,
|
||||
"total": total,
|
||||
"five_straight": straight,
|
||||
}
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
"dice_challenge",
|
||||
self.touch_challenge(state),
|
||||
)
|
||||
if straight:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=person_key,
|
||||
display_name=self.person_display_label(person["display_name"]),
|
||||
achievement="五连号",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} "
|
||||
f"{'-'.join(map(str, sorted(rolls)))}"
|
||||
),
|
||||
)
|
||||
straight_note = ",五连号达成" if straight else ""
|
||||
return (
|
||||
f"{self.person_display_label(person['display_name'])} 挑战掷出 "
|
||||
f"{'、'.join(map(str, rolls))},合计 {total}{straight_note}。"
|
||||
"其他人继续 /丢骰子挑战,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
def coin(self, row: dict[str, Any]) -> str:
|
||||
side = ("正面", "反面")[
|
||||
deterministic_int(self.seed(row, "coin"), 0, 1)
|
||||
]
|
||||
return f"硬币落地:{side}"
|
||||
|
||||
def random_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = RANDOM_NUMBER_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "用法:/随机数 最小值 最大值,例如 /随机数 1 100"
|
||||
lower, upper = int(match.group(1)), int(match.group(2))
|
||||
if lower > upper or upper - lower > 1_000_000:
|
||||
return "范围不对,最大跨度是 1000000,别让随机数跑马拉松喵"
|
||||
value = deterministic_int(self.seed(row, "random"), lower, upper)
|
||||
return f"随机数:{value}(范围 {lower} 到 {upper})"
|
||||
|
||||
def draw_lot(self, row: dict[str, Any]) -> str:
|
||||
value = self.LOTS[
|
||||
deterministic_int(self.seed(row, "draw-lot"), 0, len(self.LOTS) - 1)
|
||||
]
|
||||
return f"抽签结果:{value}"
|
||||
|
||||
def daily_fortune(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
observed_at = str(event.get("observed_at") or row["created_at"])
|
||||
day = observed_at[:10]
|
||||
score = deterministic_int(
|
||||
f"{row['group_fingerprint']}|{self.player_key(row)}|{day}|fortune",
|
||||
1,
|
||||
100,
|
||||
)
|
||||
if score >= 90:
|
||||
note = "今天手感在线,想做的事可以大胆开局"
|
||||
elif score >= 70:
|
||||
note = "节奏不错,别被小事带偏"
|
||||
elif score >= 40:
|
||||
note = "平稳发育就行,少和概率较劲"
|
||||
else:
|
||||
note = "先补水再开工,别硬扛"
|
||||
return f"今日运势 {score} 分:{note}"
|
||||
|
||||
def active_game(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
game_key: str,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
state = self.ledger.local_game(str(row["group_fingerprint"]), game_key)
|
||||
if state is None:
|
||||
return None, False
|
||||
if float(state.get("expires_at", 0)) <= time.time():
|
||||
self.ledger.clear_local_game(str(row["group_fingerprint"]), game_key)
|
||||
return None, True
|
||||
return state, False
|
||||
|
||||
def touch(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + LOCAL_GAME_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def touch_challenge(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + DICE_CHALLENGE_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def guess_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "guess_number")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return (
|
||||
f"本群已经有一局猜数字,范围 1 到 {state['upper']},"
|
||||
"直接 /猜 数字 就行"
|
||||
)
|
||||
upper = 100
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
upper = int(parts[1])
|
||||
if not 10 <= upper <= 1_000:
|
||||
return "上限请设在 10 到 1000,别把群友扔进无尽数字海喵"
|
||||
state = self.touch(
|
||||
{
|
||||
"target": deterministic_int(
|
||||
self.seed(row, "guess-target"),
|
||||
1,
|
||||
upper,
|
||||
),
|
||||
"upper": upper,
|
||||
"attempts": 0,
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "guess_number", state)
|
||||
return f"猜数字开局,范围 1 到 {upper},用 /猜 数字 试试"
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有猜数字局,先 /猜数字 开始"
|
||||
return f"猜数字还在继续,范围 1 到 {state['upper']},目前猜了 {state['attempts']} 次"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局"
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"本局结束,答案是 {state['target']},下次别被数字拿捏喵"
|
||||
|
||||
guess_text = ""
|
||||
if action == "猜" and len(parts) == 2:
|
||||
guess_text = parts[1]
|
||||
elif action.isdigit() and len(parts) == 1:
|
||||
guess_text = action
|
||||
if not guess_text:
|
||||
if expired:
|
||||
return "上一局猜数字已超时,先 /猜数字 开始"
|
||||
return "用 /猜 数字 下注,或者 /猜数字 开始 新开一局"
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局,先 /猜数字 开始"
|
||||
guess = int(guess_text)
|
||||
upper = int(state["upper"])
|
||||
if not 1 <= guess <= upper:
|
||||
return f"只能猜 1 到 {upper},数字别离家出走喵"
|
||||
state["attempts"] = int(state["attempts"]) + 1
|
||||
if guess == int(state["target"]):
|
||||
attempts = state["attempts"]
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"猜中了,答案就是 {guess},你用了 {attempts} 次,确实有点东西喵"
|
||||
self.ledger.save_local_game(group, "guess_number", self.touch(state))
|
||||
direction = "大了" if guess > int(state["target"]) else "小了"
|
||||
return f"{guess} {direction},继续猜"
|
||||
|
||||
def rps(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
player = self.player_key(row)
|
||||
state, expired = self.active_game(row, "rps")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return "石头剪刀布已经开着了,/石头剪刀布 加入 后再出招"
|
||||
state = self.touch(
|
||||
{
|
||||
"round": 1,
|
||||
"players": {player: True},
|
||||
"choices": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "rps", state)
|
||||
return "石头剪刀布开局了,你已加入,还差至少一名玩家,/石头剪刀布 加入"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的石头剪刀布"
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "石头剪刀布已收摊,下局再来喵"
|
||||
if state is None:
|
||||
if expired:
|
||||
return "上一局石头剪刀布已超时,重新 /石头剪刀布 开始"
|
||||
return "先 /石头剪刀布 开始 开局"
|
||||
players = state.setdefault("players", {})
|
||||
choices = state.setdefault("choices", {})
|
||||
if action == "加入":
|
||||
if player in players:
|
||||
return "你已经在这局里了,别想卡双号喵"
|
||||
if len(players) >= MAX_RPS_PLAYERS:
|
||||
return f"本局最多 {MAX_RPS_PLAYERS} 人,观战席先坐会儿喵"
|
||||
players[player] = True
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"加入成功,目前 {len(players)} 人,/石头剪刀布 出 石头"
|
||||
if action == "状态":
|
||||
missing = max(0, len(players) - len(choices))
|
||||
return (
|
||||
f"第 {state['round']} 回合:{len(players)} 人参战,"
|
||||
f"{len(choices)} 人已出招,还差 {missing} 人"
|
||||
)
|
||||
if action == "结算":
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "这局手动结算为取消,下一局记得别挂机喵"
|
||||
|
||||
choice = ""
|
||||
if action == "出" and len(parts) == 2:
|
||||
choice = parts[1]
|
||||
elif action in {"石头", "剪刀", "布"}:
|
||||
choice = action
|
||||
if choice not in {"石头", "剪刀", "布"}:
|
||||
return "用 /石头剪刀布 出 石头、剪刀或布"
|
||||
if player not in players:
|
||||
return "先 /石头剪刀布 加入,再偷偷出招喵"
|
||||
choices[player] = choice
|
||||
if len(players) < 2:
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return "出招记下了,但还差一名玩家加入"
|
||||
if len(choices) < len(players):
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"出招记下了,还差 {len(players) - len(choices)} 人"
|
||||
outcome = self.resolve_rps(choices)
|
||||
state["round"] = int(state["round"]) + 1
|
||||
state["choices"] = {}
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def resolve_rps(choices: dict[str, str]) -> str:
|
||||
values = set(choices.values())
|
||||
if len(values) in {1, 3}:
|
||||
return "这一轮平局,大家默契得有点过头了,下一轮继续"
|
||||
winning_choice = next(
|
||||
winner
|
||||
for winner, loser in (("石头", "剪刀"), ("剪刀", "布"), ("布", "石头"))
|
||||
if winner in values and loser in values
|
||||
)
|
||||
winners = sum(choice == winning_choice for choice in choices.values())
|
||||
return f"这一轮 {winning_choice} 赢了,{winners} 名玩家拿分,下一轮继续"
|
||||
@@ -0,0 +1,167 @@
|
||||
"""In-process compatibility runtime for generated reviewed adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, PlanKind
|
||||
|
||||
from .descriptors import LOCAL_EXTENSIONS, extension_for_kind
|
||||
from .local_commands import (
|
||||
LOCAL_COMMAND_ALIASES,
|
||||
LocalCommandEngine,
|
||||
local_command_parts,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class LocalExtensionError(ValueError):
|
||||
"""Controlled local adapter or broker contract failure."""
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self) -> None:
|
||||
self.handlers: dict[str, Callable[[Event, str], ExtensionResult]] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
command: str,
|
||||
handler: Callable[[Event, str], ExtensionResult],
|
||||
) -> None:
|
||||
key = command.casefold()
|
||||
if key in self.handlers:
|
||||
raise LocalExtensionError(f"duplicate local command: {command}")
|
||||
self.handlers[key] = handler
|
||||
|
||||
|
||||
class LocalServiceBroker:
|
||||
"""Kernel-side implementation of reviewed local service profiles."""
|
||||
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
def call(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
if operation != "execute":
|
||||
raise LocalExtensionError("unsupported local service operation")
|
||||
command = payload.get("command")
|
||||
arguments = payload.get("arguments", "")
|
||||
event_value = payload.get("event")
|
||||
if (
|
||||
not isinstance(command, str)
|
||||
or not isinstance(arguments, str)
|
||||
or not isinstance(event_value, Mapping)
|
||||
):
|
||||
raise LocalExtensionError("invalid local service payload")
|
||||
kind, _ = local_command_parts(
|
||||
f"{command} {arguments}".rstrip()
|
||||
)
|
||||
owner = extension_for_kind(kind)
|
||||
if owner is None or owner.extension_id != profile:
|
||||
raise LocalExtensionError("local service profile mismatch")
|
||||
event = dict(event_value)
|
||||
row = {
|
||||
"event_id": str(event.get("event_id", "")),
|
||||
"group_fingerprint": str(event.get("group_fingerprint", "")),
|
||||
"sender": str(event.get("sender", "")),
|
||||
"question": f"{command} {arguments}".rstrip(),
|
||||
"created_at": str(event.get("observed_at") or utc_now()),
|
||||
}
|
||||
return {"text": self.engine.handle(row, event)}
|
||||
|
||||
|
||||
class _Context:
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
self.commands = _Registry()
|
||||
self.services = LocalServiceBroker(engine)
|
||||
|
||||
|
||||
def local_extension_command_token(question: str) -> str:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
raise LocalExtensionError("local command must start with slash")
|
||||
raw = normalized[1:]
|
||||
folded = raw.casefold()
|
||||
for alias in sorted(LOCAL_COMMAND_ALIASES, key=len, reverse=True):
|
||||
if (
|
||||
extension_for_kind(LOCAL_COMMAND_ALIASES[alias]) is not None
|
||||
and folded.startswith(alias.casefold())
|
||||
):
|
||||
return "/" + raw[: len(alias)]
|
||||
raise LocalExtensionError("extension command alias is unknown")
|
||||
|
||||
|
||||
class LocalExtensionRuntime:
|
||||
"""Load generated adapters once, then dispatch without mutable call state."""
|
||||
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
context = _Context(engine)
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
module_name = descriptor.extension_id.replace("-", "_")
|
||||
module = importlib.import_module(
|
||||
f".adapters.{module_name}",
|
||||
package=__package__,
|
||||
)
|
||||
entrypoint = getattr(module, "plugin", None)
|
||||
if not callable(entrypoint):
|
||||
raise LocalExtensionError(
|
||||
f"local adapter has no entrypoint: {descriptor.extension_id}"
|
||||
)
|
||||
entrypoint(context)
|
||||
expected = {
|
||||
command.casefold()
|
||||
for descriptor in LOCAL_EXTENSIONS
|
||||
for command in descriptor.commands
|
||||
}
|
||||
if set(context.commands.handlers) != expected:
|
||||
raise LocalExtensionError(
|
||||
"local adapter registration does not match ownership inventory"
|
||||
)
|
||||
self._handlers = dict(context.commands.handlers)
|
||||
|
||||
def handle(
|
||||
self,
|
||||
row: Mapping[str, Any],
|
||||
event: Mapping[str, Any],
|
||||
) -> str:
|
||||
question = str(row.get("question", ""))
|
||||
_, arguments = local_command_parts(question)
|
||||
command = local_extension_command_token(question)
|
||||
try:
|
||||
handler = self._handlers[command.casefold()]
|
||||
except KeyError as error:
|
||||
raise LocalExtensionError("local command is not registered") from error
|
||||
observed_at = str(
|
||||
event.get("observed_at")
|
||||
or row.get("created_at")
|
||||
or utc_now()
|
||||
)
|
||||
sdk_event = Event(
|
||||
event_id=str(row.get("event_id") or event.get("event_id") or ""),
|
||||
observed_at=observed_at,
|
||||
sender=str(row.get("sender") or event.get("sender") or ""),
|
||||
text=str(event.get("text") or question),
|
||||
group_fingerprint=str(
|
||||
row.get("group_fingerprint")
|
||||
or event.get("group_fingerprint")
|
||||
or ""
|
||||
),
|
||||
mention_bot=bool(event.get("mention_bot", True)),
|
||||
confidence=float(event.get("confidence", 0.0)),
|
||||
sender_confidence=float(event.get("sender_confidence", 0.0)),
|
||||
body_confidence=float(event.get("body_confidence", 0.0)),
|
||||
source=str(event.get("source", "jwebot-edge")),
|
||||
)
|
||||
result = handler(sdk_event, arguments)
|
||||
if not isinstance(result, ExtensionResult):
|
||||
raise LocalExtensionError("local adapter returned invalid result")
|
||||
if result.reply.kind is not PlanKind.TEXT:
|
||||
raise LocalExtensionError("local command returned no visible reply")
|
||||
return result.reply.message
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official Steam status and playtime provider with mandatory proxy egress."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
STEAM_API_BASE_URL = "https://api.steampowered.com"
|
||||
STEAM_CACHE_SECONDS = 60
|
||||
STEAM_FRIEND_CACHE_SECONDS = 5 * 60
|
||||
STEAM_PLAYTIME_CACHE_SECONDS = 5 * 60
|
||||
STEAM_REQUEST_TIMEOUT_SECONDS = 8
|
||||
STEAM_SUMMARY_BATCH_SIZE = 100
|
||||
STEAM_PLAYTIME_WORKERS = 8
|
||||
STEAM_MAX_RESPONSE_BYTES = 4_000_000
|
||||
STEAM_RESULT_LIMIT = 8
|
||||
STEAM_SCOPED_RESULT_LIMIT = 16
|
||||
DEFAULT_STEAM_MIHOMO_PROXY_URL = "http://127.0.0.1:7890"
|
||||
STEAM_ID64_PATTERN = re.compile(r"^\d{17}$")
|
||||
STEAM_API_KEY_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
|
||||
SAFE_STEAM_PROXY_URL = re.compile(
|
||||
r"^http://(?:127\.0\.0\.1|localhost):(?:[1-9]\d{0,3}|[1-5]\d{4})$"
|
||||
)
|
||||
PLAYTIME_PERSON_FIELD = re.compile(
|
||||
r"(?:玩家|用户|person|人)\s*[:=:]\s*(?P<selector>.+)$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Public AppIDs make common OCR-shortened names deterministic. Other games are
|
||||
# resolved against the configured account's library, then public friend data.
|
||||
STEAM_GAME_ALIASES = {
|
||||
"cs2": (730, "Counter-Strike 2"),
|
||||
"csgo": (730, "Counter-Strike 2"),
|
||||
"dota": (570, "Dota 2"),
|
||||
"dota2": (570, "Dota 2"),
|
||||
"pubg": (578080, "PUBG: BATTLEGROUNDS"),
|
||||
"gta5": (271590, "Grand Theft Auto V"),
|
||||
"gtav": (271590, "Grand Theft Auto V"),
|
||||
"apex": (1172470, "Apex Legends"),
|
||||
}
|
||||
|
||||
|
||||
class SteamUnavailable(RuntimeError):
|
||||
"""Steam cannot be reached through the mandatory approved proxy."""
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def bounded_text(value: object, *, limit: int) -> str:
|
||||
return normalize_text(value)[:limit]
|
||||
|
||||
|
||||
def match_key(value: object) -> str:
|
||||
normalized = unicodedata.normalize("NFKC", str(value or "")).casefold()
|
||||
return re.sub(r"[\W_]+", "", normalized, flags=re.UNICODE)
|
||||
|
||||
|
||||
class SteamClient:
|
||||
"""Read public Steam data through Mihomo without any direct fallback."""
|
||||
|
||||
PERSONA_STATES = {
|
||||
0: "离线",
|
||||
1: "在线",
|
||||
2: "忙碌",
|
||||
3: "离开",
|
||||
4: "打盹",
|
||||
5: "想交易",
|
||||
6: "想玩",
|
||||
}
|
||||
|
||||
def __init__(self, config: Any) -> None:
|
||||
self.config = config
|
||||
self.lock = threading.Lock()
|
||||
self.friend_cache: tuple[float, list[str]] | None = None
|
||||
self.presence_cache: tuple[float, list[dict[str, Any]]] | None = None
|
||||
self.playtime_cache: dict[tuple[int, tuple[str, ...]], tuple[float, str]] = {}
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
STEAM_API_KEY_PATTERN.fullmatch(self.config.steam_api_key)
|
||||
and STEAM_ID64_PATTERN.fullmatch(self.config.steam_id64)
|
||||
and SAFE_STEAM_PROXY_URL.fullmatch(self.config.steam_proxy_url)
|
||||
)
|
||||
|
||||
def configured_roster(self) -> list[dict[str, Any]]:
|
||||
values = []
|
||||
seen: set[str] = set()
|
||||
for raw in getattr(self.config, "steam_roster", ()):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
steam_id = str(raw.get("steam_id", ""))
|
||||
label = bounded_text(raw.get("label", ""), limit=40)
|
||||
if (
|
||||
not STEAM_ID64_PATTERN.fullmatch(steam_id)
|
||||
or not label
|
||||
or steam_id in seen
|
||||
):
|
||||
continue
|
||||
selectors = tuple(
|
||||
dict.fromkeys(
|
||||
bounded_text(selector, limit=120)
|
||||
for selector in raw.get("selectors", ())
|
||||
if bounded_text(selector, limit=120)
|
||||
)
|
||||
)
|
||||
values.append(
|
||||
{
|
||||
"steam_id": steam_id,
|
||||
"label": label,
|
||||
"selectors": selectors,
|
||||
}
|
||||
)
|
||||
seen.add(steam_id)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def usage() -> str:
|
||||
return (
|
||||
"用法:/steam [在线|游戏|全部|刷新];"
|
||||
"/steam时长 游戏名[、玩家名](可省略空格)"
|
||||
)
|
||||
|
||||
def render(self, arguments: str) -> str:
|
||||
if not self.configured():
|
||||
return "Steam 尚未配置"
|
||||
mode, value, force_refresh = self.parse_arguments(arguments)
|
||||
if mode == "help":
|
||||
return self.usage()
|
||||
if mode == "playtime":
|
||||
if not value:
|
||||
return "请在 /steam时长 后填写游戏名"
|
||||
game_query, person_selector = self.parse_playtime_query(value)
|
||||
if not game_query:
|
||||
return "请在 /steam时长 后填写游戏名"
|
||||
return self.render_playtime(
|
||||
game_query,
|
||||
person_selector=person_selector,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return self.render_presence(
|
||||
mode=mode,
|
||||
selector=value,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
def render_group_presence(self, arguments: str) -> str:
|
||||
"""Compatibility entrypoint retained for the reviewed service profile."""
|
||||
|
||||
return self.render(arguments)
|
||||
|
||||
@staticmethod
|
||||
def parse_arguments(arguments: str) -> tuple[str, str, bool]:
|
||||
value = normalize_text(arguments)
|
||||
folded = value.casefold()
|
||||
force_refresh = False
|
||||
for refresh in ("刷新", "refresh"):
|
||||
if folded == refresh:
|
||||
return "online", "", True
|
||||
suffix = f" {refresh}"
|
||||
if folded.endswith(suffix):
|
||||
value = value[: -len(suffix)].strip()
|
||||
folded = value.casefold()
|
||||
force_refresh = True
|
||||
|
||||
for prefix in ("时长", "playtime", "hours"):
|
||||
if folded.startswith(prefix):
|
||||
return "playtime", value[len(prefix):].strip(), force_refresh
|
||||
|
||||
modes = {
|
||||
"": "online",
|
||||
"在线": "online",
|
||||
"online": "online",
|
||||
"游戏": "game",
|
||||
"game": "game",
|
||||
"全部": "all",
|
||||
"all": "all",
|
||||
"帮助": "help",
|
||||
"help": "help",
|
||||
}
|
||||
if folded in modes:
|
||||
return modes[folded], "", force_refresh
|
||||
return "online", value, force_refresh
|
||||
|
||||
@staticmethod
|
||||
def parse_playtime_query(query: str) -> tuple[str, str]:
|
||||
value = normalize_text(query)
|
||||
field_matches = list(PLAYTIME_PERSON_FIELD.finditer(value))
|
||||
if field_matches:
|
||||
match = field_matches[-1]
|
||||
game = value[:match.start()].rstrip(" ,,、;;")
|
||||
selector = normalize_text(match.group("selector"))
|
||||
if game and selector:
|
||||
return game, selector
|
||||
|
||||
game, separator, selector = value.rpartition("@")
|
||||
if separator and game.strip() and selector.strip():
|
||||
return game.strip(), selector.strip()
|
||||
|
||||
delimiter_index = max(value.rfind(mark) for mark in ("、", ",", ","))
|
||||
if delimiter_index > 0:
|
||||
game = value[:delimiter_index].strip()
|
||||
selector = value[delimiter_index + 1:].strip()
|
||||
if game and selector:
|
||||
return game, selector
|
||||
return value, ""
|
||||
|
||||
def friend_ids(self, *, force_refresh: bool = False) -> list[str]:
|
||||
roster = self.configured_roster()
|
||||
if roster:
|
||||
return [str(record["steam_id"]) for record in roster]
|
||||
now = time.monotonic()
|
||||
with self.lock:
|
||||
if (
|
||||
not force_refresh
|
||||
and self.friend_cache is not None
|
||||
and now - self.friend_cache[0] < STEAM_FRIEND_CACHE_SECONDS
|
||||
):
|
||||
return list(self.friend_cache[1])
|
||||
payload = self.request_json(
|
||||
"/ISteamUser/GetFriendList/v1/",
|
||||
{
|
||||
"steamid": self.config.steam_id64,
|
||||
"relationship": "friend",
|
||||
},
|
||||
)
|
||||
friendslist = payload.get("friendslist")
|
||||
friends = friendslist.get("friends") if isinstance(friendslist, dict) else None
|
||||
if not isinstance(friends, list):
|
||||
raise SteamUnavailable("Steam 列表不可见")
|
||||
values = [self.config.steam_id64]
|
||||
values.extend(
|
||||
str(friend.get("steamid", ""))
|
||||
for friend in friends
|
||||
if isinstance(friend, dict)
|
||||
)
|
||||
unique = list(dict.fromkeys(
|
||||
value for value in values if STEAM_ID64_PATTERN.fullmatch(value)
|
||||
))
|
||||
with self.lock:
|
||||
self.friend_cache = (time.monotonic(), list(unique))
|
||||
return unique
|
||||
|
||||
def presence(self, *, force_refresh: bool = False) -> list[dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
with self.lock:
|
||||
if (
|
||||
not force_refresh
|
||||
and self.presence_cache is not None
|
||||
and now - self.presence_cache[0] < STEAM_CACHE_SECONDS
|
||||
):
|
||||
return [dict(record) for record in self.presence_cache[1]]
|
||||
roster = self.configured_roster()
|
||||
steam_ids = (
|
||||
[str(record["steam_id"]) for record in roster]
|
||||
if roster
|
||||
else self.friend_ids(force_refresh=force_refresh)
|
||||
)
|
||||
roster_by_id = {
|
||||
str(record["steam_id"]): record
|
||||
for record in roster
|
||||
}
|
||||
summaries = self.fetch_summaries(steam_ids)
|
||||
records = []
|
||||
for index, steam_id in enumerate(steam_ids, start=1):
|
||||
summary = summaries.get(steam_id)
|
||||
roster_record = roster_by_id.get(steam_id)
|
||||
try:
|
||||
state = int(summary.get("personastate", 0)) if summary else -1
|
||||
except (TypeError, ValueError):
|
||||
state = -1
|
||||
label = (
|
||||
str(roster_record["label"])
|
||||
if roster_record is not None
|
||||
else bounded_text(
|
||||
summary.get("personaname", "") if summary else "",
|
||||
limit=40,
|
||||
) or f"用户{index}"
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"steam_id": steam_id,
|
||||
"label": label,
|
||||
"selectors": (
|
||||
tuple(roster_record["selectors"])
|
||||
if roster_record is not None
|
||||
else (label,)
|
||||
),
|
||||
"scoped": roster_record is not None,
|
||||
"state": state,
|
||||
"game": bounded_text(
|
||||
summary.get("gameextrainfo", "") if summary else "",
|
||||
limit=120,
|
||||
),
|
||||
}
|
||||
)
|
||||
with self.lock:
|
||||
self.presence_cache = (
|
||||
time.monotonic(),
|
||||
[dict(record) for record in records],
|
||||
)
|
||||
return records
|
||||
|
||||
def render_presence(
|
||||
self,
|
||||
*,
|
||||
mode: str,
|
||||
selector: str,
|
||||
force_refresh: bool,
|
||||
) -> str:
|
||||
records = self.presence(force_refresh=force_refresh)
|
||||
if selector:
|
||||
record = self.select_record(records, selector)
|
||||
if record is None:
|
||||
return self.usage()
|
||||
return f"Steam:{self.render_record(record)}"
|
||||
online_count = sum(record["state"] > 0 for record in records)
|
||||
game_count = sum(bool(record["game"]) for record in records)
|
||||
if mode == "game":
|
||||
visible = [record for record in records if record["game"]]
|
||||
elif mode == "all":
|
||||
visible = records
|
||||
else:
|
||||
visible = [record for record in records if record["state"] > 0]
|
||||
visible.sort(
|
||||
key=lambda record: (
|
||||
not bool(record["game"]),
|
||||
-int(record["state"]),
|
||||
str(record["label"]).casefold(),
|
||||
)
|
||||
)
|
||||
if not visible:
|
||||
return (
|
||||
f"Steam:在线 {online_count}/{len(records)},"
|
||||
f"游戏中 {game_count};当前没有匹配状态"
|
||||
)
|
||||
result_limit = (
|
||||
STEAM_SCOPED_RESULT_LIMIT
|
||||
if any(bool(record.get("scoped")) for record in records)
|
||||
else STEAM_RESULT_LIMIT
|
||||
)
|
||||
shown = visible[:result_limit]
|
||||
listing = ";".join(self.render_record(record) for record in shown)
|
||||
omitted = len(visible) - len(shown)
|
||||
suffix = f";另有 {omitted} 个账号未展开" if omitted else ""
|
||||
return (
|
||||
f"Steam:在线 {online_count}/{len(records)},"
|
||||
f"游戏中 {game_count};{listing}{suffix}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def select_record(
|
||||
records: list[dict[str, Any]],
|
||||
selector: str,
|
||||
) -> dict[str, Any] | None:
|
||||
matches = SteamClient.matching_records(records, selector)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
@staticmethod
|
||||
def matching_records(
|
||||
records: list[dict[str, Any]],
|
||||
selector: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = match_key(selector)
|
||||
if not query:
|
||||
return []
|
||||
|
||||
def keys(record: dict[str, Any]) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
key
|
||||
for key in (
|
||||
match_key(record.get("label", "")),
|
||||
*(
|
||||
match_key(value)
|
||||
for value in record.get("selectors", ())
|
||||
),
|
||||
)
|
||||
if key
|
||||
)
|
||||
)
|
||||
|
||||
exact = [record for record in records if query in keys(record)]
|
||||
if exact:
|
||||
return exact
|
||||
partial = [
|
||||
record
|
||||
for record in records
|
||||
if any(query in key for key in keys(record))
|
||||
]
|
||||
if partial:
|
||||
return partial
|
||||
scored = [
|
||||
(
|
||||
difflib.SequenceMatcher(
|
||||
None,
|
||||
query,
|
||||
key,
|
||||
).ratio(),
|
||||
record,
|
||||
)
|
||||
for record in records
|
||||
for key in [max(keys(record), key=lambda value: difflib.SequenceMatcher(
|
||||
None,
|
||||
query,
|
||||
value,
|
||||
).ratio(), default="")]
|
||||
if key
|
||||
]
|
||||
if not scored:
|
||||
return []
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
best_score = scored[0][0]
|
||||
if best_score < 0.78:
|
||||
return []
|
||||
return [
|
||||
record
|
||||
for score, record in scored
|
||||
if score >= 0.78 and best_score - score < 0.05
|
||||
]
|
||||
|
||||
def render_playtime(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
person_selector: str = "",
|
||||
force_refresh: bool,
|
||||
) -> str:
|
||||
records = self.presence(force_refresh=force_refresh)
|
||||
if person_selector:
|
||||
matches = self.matching_records(records, person_selector)
|
||||
safe_selector = bounded_text(person_selector, limit=40)
|
||||
if not matches:
|
||||
return f"Steam:没有匹配到玩家“{safe_selector}”"
|
||||
if len(matches) > 1:
|
||||
return f"Steam:玩家“{safe_selector}”匹配不唯一,请写完整名称"
|
||||
records = matches
|
||||
steam_ids = [str(record["steam_id"]) for record in records]
|
||||
seed_id = (
|
||||
steam_ids[0]
|
||||
if person_selector
|
||||
else self.config.steam_id64
|
||||
)
|
||||
seed_games = self.fetch_owned_games(seed_id)
|
||||
game = self.resolve_game(query, seed_games or [])
|
||||
prefetched: dict[str, list[dict[str, Any]] | None] = {
|
||||
seed_id: seed_games,
|
||||
}
|
||||
if game is None:
|
||||
prefetched.update(
|
||||
self.fetch_owned_games_parallel(
|
||||
[
|
||||
steam_id
|
||||
for steam_id in steam_ids
|
||||
if steam_id != seed_id
|
||||
],
|
||||
app_id=None,
|
||||
)
|
||||
)
|
||||
candidates = [
|
||||
entry
|
||||
for games in prefetched.values()
|
||||
if games
|
||||
for entry in games
|
||||
]
|
||||
game = self.resolve_game(query, candidates)
|
||||
if game is None:
|
||||
return f"Steam:没有匹配到“{bounded_text(query, limit=40)}”"
|
||||
|
||||
app_id, game_name = game
|
||||
cache_key = (app_id, tuple(steam_ids))
|
||||
with self.lock:
|
||||
cached = self.playtime_cache.get(cache_key)
|
||||
if (
|
||||
not force_refresh
|
||||
and cached is not None
|
||||
and time.monotonic() - cached[0] < STEAM_PLAYTIME_CACHE_SECONDS
|
||||
):
|
||||
return cached[1]
|
||||
|
||||
missing = [steam_id for steam_id in steam_ids if steam_id not in prefetched]
|
||||
prefetched.update(
|
||||
self.fetch_owned_games_parallel(missing, app_id=app_id)
|
||||
)
|
||||
entries = []
|
||||
visible_count = 0
|
||||
hidden_count = 0
|
||||
record_by_id = {str(record["steam_id"]): record for record in records}
|
||||
for steam_id in steam_ids:
|
||||
games = prefetched.get(steam_id)
|
||||
if games is None:
|
||||
hidden_count += 1
|
||||
continue
|
||||
visible_count += 1
|
||||
minutes = 0
|
||||
for owned in games:
|
||||
if int(owned.get("appid", 0)) == app_id:
|
||||
try:
|
||||
minutes = max(0, int(owned.get("playtime_forever", 0)))
|
||||
except (TypeError, ValueError):
|
||||
minutes = 0
|
||||
break
|
||||
if minutes > 0:
|
||||
entries.append(
|
||||
(
|
||||
minutes,
|
||||
bounded_text(record_by_id[steam_id]["label"], limit=40),
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda item: (-item[0], item[1].casefold()))
|
||||
result_limit = (
|
||||
STEAM_SCOPED_RESULT_LIMIT
|
||||
if any(bool(record.get("scoped")) for record in records)
|
||||
else STEAM_RESULT_LIMIT
|
||||
)
|
||||
listing = ";".join(
|
||||
f"{label} {self.format_hours(minutes)}"
|
||||
for minutes, label in entries[:result_limit]
|
||||
)
|
||||
omitted = len(entries) - min(len(entries), result_limit)
|
||||
if listing:
|
||||
details = listing
|
||||
elif person_selector:
|
||||
label = bounded_text(records[0]["label"], limit=40)
|
||||
details = (
|
||||
f"{label} 资料不可见"
|
||||
if hidden_count
|
||||
else f"{label} 暂无公开游玩时长"
|
||||
)
|
||||
else:
|
||||
details = "暂无公开游玩时长"
|
||||
if omitted:
|
||||
details += f";另有 {omitted} 个有记录账号未展开"
|
||||
result = (
|
||||
f"Steam [■■■] {game_name}:已查询 {len(steam_ids)} 个账号,"
|
||||
f"有时长 {len(entries)},公开但未玩 {visible_count - len(entries)},"
|
||||
f"不可见 {hidden_count};{details}"
|
||||
)
|
||||
with self.lock:
|
||||
self.playtime_cache[cache_key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
def fetch_owned_games_parallel(
|
||||
self,
|
||||
steam_ids: list[str],
|
||||
*,
|
||||
app_id: int | None,
|
||||
) -> dict[str, list[dict[str, Any]] | None]:
|
||||
if not steam_ids:
|
||||
return {}
|
||||
result: dict[str, list[dict[str, Any]] | None] = {}
|
||||
errors: list[SteamUnavailable] = []
|
||||
workers = min(STEAM_PLAYTIME_WORKERS, len(steam_ids))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(self.fetch_owned_games, steam_id, app_id=app_id):
|
||||
steam_id
|
||||
for steam_id in steam_ids
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
steam_id = futures[future]
|
||||
try:
|
||||
result[steam_id] = future.result()
|
||||
except SteamUnavailable as error:
|
||||
errors.append(error)
|
||||
result[steam_id] = None
|
||||
if errors and len(errors) == len(steam_ids):
|
||||
raise errors[0]
|
||||
return result
|
||||
|
||||
def fetch_owned_games(
|
||||
self,
|
||||
steam_id: str,
|
||||
*,
|
||||
app_id: int | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
input_value: dict[str, Any] = {
|
||||
"steamid": steam_id,
|
||||
"include_appinfo": True,
|
||||
"include_played_free_games": True,
|
||||
}
|
||||
if app_id is not None:
|
||||
input_value["appids_filter"] = [app_id]
|
||||
payload = self.request_json(
|
||||
"/IPlayerService/GetOwnedGames/v1/",
|
||||
{
|
||||
"input_json": json.dumps(
|
||||
input_value,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
},
|
||||
)
|
||||
response = payload.get("response")
|
||||
if not isinstance(response, dict):
|
||||
raise SteamUnavailable("Steam API returned invalid game data")
|
||||
games = response.get("games")
|
||||
if games is None and "game_count" not in response:
|
||||
return None
|
||||
if games is None:
|
||||
return []
|
||||
if not isinstance(games, list):
|
||||
raise SteamUnavailable("Steam API returned invalid game data")
|
||||
return [game for game in games if isinstance(game, dict)]
|
||||
|
||||
@staticmethod
|
||||
def resolve_game(
|
||||
query: str,
|
||||
games: list[dict[str, Any]],
|
||||
) -> tuple[int, str] | None:
|
||||
query_key = match_key(query)
|
||||
if not query_key:
|
||||
return None
|
||||
alias = STEAM_GAME_ALIASES.get(query_key)
|
||||
if alias is not None:
|
||||
return alias
|
||||
candidates: dict[int, str] = {}
|
||||
for game in games:
|
||||
try:
|
||||
app_id = int(game.get("appid", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = bounded_text(game.get("name", ""), limit=120)
|
||||
if app_id > 0 and name:
|
||||
candidates.setdefault(app_id, name)
|
||||
exact = [
|
||||
(app_id, name)
|
||||
for app_id, name in candidates.items()
|
||||
if match_key(name) == query_key
|
||||
]
|
||||
if exact:
|
||||
return exact[0]
|
||||
|
||||
scored: list[tuple[float, int, str]] = []
|
||||
for app_id, name in candidates.items():
|
||||
name_key = match_key(name)
|
||||
ratio = difflib.SequenceMatcher(None, query_key, name_key).ratio()
|
||||
if query_key in name_key:
|
||||
ratio = max(ratio, 0.9 + min(0.08, len(query_key) / 100))
|
||||
scored.append((ratio, app_id, name))
|
||||
if not scored:
|
||||
return None
|
||||
score, app_id, name = max(scored)
|
||||
threshold = 0.78 if len(query_key) <= 3 else 0.68
|
||||
return (app_id, name) if score >= threshold else None
|
||||
|
||||
def fetch_summaries(self, steam_ids: list[str]) -> dict[str, dict[str, Any]]:
|
||||
summaries: dict[str, dict[str, Any]] = {}
|
||||
for offset in range(0, len(steam_ids), STEAM_SUMMARY_BATCH_SIZE):
|
||||
batch = steam_ids[offset:offset + STEAM_SUMMARY_BATCH_SIZE]
|
||||
payload = self.request_json(
|
||||
"/ISteamUser/GetPlayerSummaries/v2/",
|
||||
{"steamids": ",".join(batch)},
|
||||
)
|
||||
response = payload.get("response")
|
||||
players = response.get("players") if isinstance(response, dict) else None
|
||||
if not isinstance(players, list):
|
||||
raise SteamUnavailable("Steam API returned invalid player data")
|
||||
for player in players:
|
||||
steam_id = (
|
||||
str(player.get("steamid", ""))
|
||||
if isinstance(player, dict)
|
||||
else ""
|
||||
)
|
||||
if steam_id in batch and isinstance(player, dict):
|
||||
summaries[steam_id] = player
|
||||
return summaries
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
path: str,
|
||||
parameters: dict[str, object],
|
||||
) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode(
|
||||
{"key": self.config.steam_api_key, **parameters},
|
||||
doseq=True,
|
||||
safe=",",
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"{STEAM_API_BASE_URL}{path}?{query}",
|
||||
headers={"Accept": "application/json", "User-Agent": "jwebot-edge/1"},
|
||||
method="GET",
|
||||
)
|
||||
proxy_handler = urllib.request.ProxyHandler(
|
||||
{"https": self.config.steam_proxy_url}
|
||||
)
|
||||
opener = urllib.request.build_opener(proxy_handler)
|
||||
try:
|
||||
with opener.open(
|
||||
request,
|
||||
timeout=STEAM_REQUEST_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
raw = response.read(STEAM_MAX_RESPONSE_BYTES + 1)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in {401, 403}:
|
||||
raise SteamUnavailable("Steam API key 无效或资料不可见") from error
|
||||
if error.code == 429:
|
||||
raise SteamUnavailable("Steam API 请求过快,请稍后重试") from error
|
||||
raise SteamUnavailable(f"Steam API HTTP {error.code}") from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
raise SteamUnavailable("Steam Mihomo 代理不可用") from error
|
||||
if len(raw) > STEAM_MAX_RESPONSE_BYTES:
|
||||
raise SteamUnavailable("Steam API response exceeded limit")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SteamUnavailable("Steam API returned invalid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SteamUnavailable("Steam API returned invalid data")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def format_hours(minutes: int) -> str:
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h" if minutes % 60 else f"{int(hours)}h"
|
||||
|
||||
def render_record(self, record: dict[str, Any]) -> str:
|
||||
label = str(record["label"])
|
||||
game = str(record["game"])
|
||||
state = int(record["state"])
|
||||
if game:
|
||||
return f"{label} 正在玩 {game}"
|
||||
if state < 0:
|
||||
return f"{label} 状态不可见"
|
||||
return f"{label} {self.PERSONA_STATES.get(state, '未知')}"
|
||||
Reference in New Issue
Block a user