release: public alpha 0.6.0 baseline

This commit is contained in:
JWeBot Contributors
2026-10-15 03:58:31 +08:00
commit d4242ae07c
238 changed files with 37724 additions and 0 deletions
@@ -0,0 +1,15 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "0.6.0a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.0a0+1015",
]
[tool.setuptools.packages.find]
where = ["src"]
@@ -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"
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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, '未知')}"