tenko-plugin-repeater 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tenko_plugin_repeater-0.1.0/.gitignore +8 -0
- tenko_plugin_repeater-0.1.0/PKG-INFO +50 -0
- tenko_plugin_repeater-0.1.0/README.md +38 -0
- tenko_plugin_repeater-0.1.0/pyproject.toml +25 -0
- tenko_plugin_repeater-0.1.0/src/tenko_plugin_repeater/__init__.py +155 -0
- tenko_plugin_repeater-0.1.0/src/tenko_plugin_repeater/chain.py +122 -0
- tenko_plugin_repeater-0.1.0/tests/conftest.py +36 -0
- tenko_plugin_repeater-0.1.0/tests/test_repeater.py +144 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tenko-plugin-repeater
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tenko 复读机:群友复读时偶尔跟一句,偶尔打断一下
|
|
5
|
+
Project-URL: Homepage, https://github.com/g1331/tenko-plugins/tree/main/plugins/repeater
|
|
6
|
+
Project-URL: Repository, https://github.com/g1331/tenko-plugins
|
|
7
|
+
License-Expression: GPL-3.0-only
|
|
8
|
+
Requires-Python: <3.14,>=3.11
|
|
9
|
+
Requires-Dist: aiohttp>=3.9
|
|
10
|
+
Requires-Dist: tenko-api<0.3,>=0.2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# tenko-plugin-repeater
|
|
14
|
+
|
|
15
|
+
[Tenko](https://github.com/g1331/tenko) 的复读机插件。群友开始复读时,Tenko 偶尔跟着复读一句,偶尔打断一下。
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
群友 A:草
|
|
19
|
+
群友 B:草
|
|
20
|
+
Tenko: 草
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 规则
|
|
24
|
+
|
|
25
|
+
- 连续出现相同内容、并且来自不同的人,才算一串复读;同一个人连发不算。
|
|
26
|
+
- 达到 2 个人后,每多一个人复读就判断一次:先以 5% 的概率打断,否则按递增的概率跟一句。
|
|
27
|
+
跟读概率从 15% 起步,第 3 个人约 28%,第 4 个人约 39%。
|
|
28
|
+
- 每串复读最多出声一次,出声后这个群冷却 10 分钟。
|
|
29
|
+
- 打断时,文字会被打乱字序发出,其他内容换成一句打断语。
|
|
30
|
+
- 支持文字、QQ 表情、商城表情和单张图片。图片按内容比较,只在连续两条都是单张图片时才会下载比对。
|
|
31
|
+
- 命令、@ 或引用回复的消息、超过 100 字的消息,以及 Tenko 其他账号发的消息都不参与。
|
|
32
|
+
|
|
33
|
+
插件在新群默认开启,群管理员可以用 `/关闭 复读机` 关掉。
|
|
34
|
+
|
|
35
|
+
## 安装
|
|
36
|
+
|
|
37
|
+
在 WebUI 的插件市场安装,或由 Master 在聊天里 `/安装插件 repeater`,启用后重启生效。
|
|
38
|
+
|
|
39
|
+
## 配置
|
|
40
|
+
|
|
41
|
+
`config/tenko.toml` 的 `[plugin_config.repeater]`,也可以在 WebUI 的插件配置里修改:
|
|
42
|
+
|
|
43
|
+
| 配置 | 默认值 | 说明 |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `min_times` | `2` | 几个不同的人发了同一句后开始考虑复读,最小为 2 |
|
|
46
|
+
| `repeat_probability` | `0.15` | 达到 `min_times` 时跟一句的概率;之后每多一人,“不跟”的概率再乘以 (1 - 它) |
|
|
47
|
+
| `interrupt_probability` | `0.05` | 每次考虑时打断的概率 |
|
|
48
|
+
| `cooldown_seconds` | `600` | 同一个群出声后的冷却时间 |
|
|
49
|
+
| `max_length` | `100` | 超过这个字数的消息不参与 |
|
|
50
|
+
| `interrupt_lines` | `["打断!", "不许复读!", "复读到此为止~"]` | 无法打乱字序时随机挑一句 |
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# tenko-plugin-repeater
|
|
2
|
+
|
|
3
|
+
[Tenko](https://github.com/g1331/tenko) 的复读机插件。群友开始复读时,Tenko 偶尔跟着复读一句,偶尔打断一下。
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
群友 A:草
|
|
7
|
+
群友 B:草
|
|
8
|
+
Tenko: 草
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 规则
|
|
12
|
+
|
|
13
|
+
- 连续出现相同内容、并且来自不同的人,才算一串复读;同一个人连发不算。
|
|
14
|
+
- 达到 2 个人后,每多一个人复读就判断一次:先以 5% 的概率打断,否则按递增的概率跟一句。
|
|
15
|
+
跟读概率从 15% 起步,第 3 个人约 28%,第 4 个人约 39%。
|
|
16
|
+
- 每串复读最多出声一次,出声后这个群冷却 10 分钟。
|
|
17
|
+
- 打断时,文字会被打乱字序发出,其他内容换成一句打断语。
|
|
18
|
+
- 支持文字、QQ 表情、商城表情和单张图片。图片按内容比较,只在连续两条都是单张图片时才会下载比对。
|
|
19
|
+
- 命令、@ 或引用回复的消息、超过 100 字的消息,以及 Tenko 其他账号发的消息都不参与。
|
|
20
|
+
|
|
21
|
+
插件在新群默认开启,群管理员可以用 `/关闭 复读机` 关掉。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
在 WebUI 的插件市场安装,或由 Master 在聊天里 `/安装插件 repeater`,启用后重启生效。
|
|
26
|
+
|
|
27
|
+
## 配置
|
|
28
|
+
|
|
29
|
+
`config/tenko.toml` 的 `[plugin_config.repeater]`,也可以在 WebUI 的插件配置里修改:
|
|
30
|
+
|
|
31
|
+
| 配置 | 默认值 | 说明 |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `min_times` | `2` | 几个不同的人发了同一句后开始考虑复读,最小为 2 |
|
|
34
|
+
| `repeat_probability` | `0.15` | 达到 `min_times` 时跟一句的概率;之后每多一人,“不跟”的概率再乘以 (1 - 它) |
|
|
35
|
+
| `interrupt_probability` | `0.05` | 每次考虑时打断的概率 |
|
|
36
|
+
| `cooldown_seconds` | `600` | 同一个群出声后的冷却时间 |
|
|
37
|
+
| `max_length` | `100` | 超过这个字数的消息不参与 |
|
|
38
|
+
| `interrupt_lines` | `["打断!", "不许复读!", "复读到此为止~"]` | 无法打乱字序时随机挑一句 |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tenko-plugin-repeater"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tenko 复读机:群友复读时偶尔跟一句,偶尔打断一下"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "GPL-3.0-only"
|
|
7
|
+
requires-python = ">=3.11,<3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"tenko-api>=0.2,<0.3",
|
|
10
|
+
"aiohttp>=3.9",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/g1331/tenko-plugins/tree/main/plugins/repeater"
|
|
15
|
+
Repository = "https://github.com/g1331/tenko-plugins"
|
|
16
|
+
|
|
17
|
+
[project.entry-points."tenko.plugins"]
|
|
18
|
+
repeater = "tenko_plugin_repeater"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/tenko_plugin_repeater"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""复读机:群友开始复读时,偶尔跟一句,偶尔打断一下。
|
|
2
|
+
|
|
3
|
+
判定逻辑在 :mod:`.chain`;这里负责把群消息拆成可比较的内容,并把结果发回群里。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
from arclet.entari import BasicConfModel, MessageCreatedEvent, Session, plugin
|
|
12
|
+
from arclet.entari.config import EntariConfig, model_field
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from satori import Emoji, Image, Text
|
|
15
|
+
from satori.element import Custom, Message
|
|
16
|
+
from tenko_api.accounts import online_accounts
|
|
17
|
+
from tenko_api.context import context_from_session
|
|
18
|
+
from tenko_api.features import declare, feature_required
|
|
19
|
+
from tenko_api.perm import Permission
|
|
20
|
+
|
|
21
|
+
from .chain import Repeater, Said, Settings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RepeaterConfig(BasicConfModel):
|
|
25
|
+
"""对应 config/tenko.toml 中的 ``[plugin_config.repeater]``。"""
|
|
26
|
+
|
|
27
|
+
min_times: int = 2
|
|
28
|
+
"""几个不同的人发了同一句之后,才开始考虑复读。"""
|
|
29
|
+
repeat_probability: float = 0.15
|
|
30
|
+
"""达到 min_times 时跟一句的概率;之后每多一人复读,“不跟”的概率再乘以 (1 - 它)。"""
|
|
31
|
+
interrupt_probability: float = 0.05
|
|
32
|
+
"""每次考虑时先判断是否打断:文字打乱字序发出,其他内容发一句打断语。"""
|
|
33
|
+
cooldown_seconds: int = 600
|
|
34
|
+
"""同一个群出声一次后,这么久之内不再出声。"""
|
|
35
|
+
max_length: int = 100
|
|
36
|
+
"""超过这个字数的消息不参与复读。"""
|
|
37
|
+
interrupt_lines: list[str] = model_field(
|
|
38
|
+
default_factory=lambda: ["打断!", "不许复读!", "复读到此为止~"],
|
|
39
|
+
description="无法打乱字序时,随机挑一句发出。",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
plugin.metadata(
|
|
44
|
+
"复读机",
|
|
45
|
+
author=["13"],
|
|
46
|
+
version="0.1.0",
|
|
47
|
+
description="群友复读时偶尔跟一句,偶尔打断一下。",
|
|
48
|
+
urls={
|
|
49
|
+
"homepage": "https://github.com/g1331/tenko-plugins/tree/main/plugins/repeater",
|
|
50
|
+
"repository": "https://github.com/g1331/tenko-plugins",
|
|
51
|
+
},
|
|
52
|
+
config=RepeaterConfig,
|
|
53
|
+
)
|
|
54
|
+
declare(scope="group", default_enabled=True, level=Permission.ActiveGroup)
|
|
55
|
+
|
|
56
|
+
config = plugin.get_config(RepeaterConfig)
|
|
57
|
+
|
|
58
|
+
_IMAGE_LIMIT = 4 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _digest(src: str) -> str | None:
|
|
62
|
+
"""同一张图被不同人发出时地址不同,只能按内容比较;超时或过大就放弃。"""
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
timeout = aiohttp.ClientTimeout(total=8)
|
|
66
|
+
async with aiohttp.ClientSession(timeout=timeout) as client:
|
|
67
|
+
async with client.get(src) as response:
|
|
68
|
+
if response.status != 200:
|
|
69
|
+
return None
|
|
70
|
+
hasher, size = hashlib.sha256(), 0
|
|
71
|
+
async for chunk in response.content.iter_chunked(64 * 1024):
|
|
72
|
+
size += len(chunk)
|
|
73
|
+
if size > _IMAGE_LIMIT:
|
|
74
|
+
return None
|
|
75
|
+
hasher.update(chunk)
|
|
76
|
+
return hasher.hexdigest()
|
|
77
|
+
except (aiohttp.ClientError, TimeoutError, ValueError) as error:
|
|
78
|
+
logger.debug("复读机拉取图片失败:{}", type(error).__name__)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
repeater = Repeater(
|
|
83
|
+
Settings(
|
|
84
|
+
min_times=max(config.min_times, 2),
|
|
85
|
+
repeat_probability=config.repeat_probability,
|
|
86
|
+
interrupt_probability=config.interrupt_probability,
|
|
87
|
+
cooldown_seconds=config.cooldown_seconds,
|
|
88
|
+
interrupt_lines=tuple(config.interrupt_lines),
|
|
89
|
+
),
|
|
90
|
+
_digest,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _command_prefixes() -> tuple[str, ...]:
|
|
95
|
+
try:
|
|
96
|
+
prefixes = EntariConfig.instance.basic.prefix
|
|
97
|
+
except AttributeError:
|
|
98
|
+
prefixes = []
|
|
99
|
+
return tuple(p for p in prefixes if p) or ("/",)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse(elements: list, user: str, max_length: int) -> Said | None:
|
|
103
|
+
"""拆出可比较的内容;命令、@、引用、长文和混排图片等不参与复读,返回 None。"""
|
|
104
|
+
|
|
105
|
+
key: list[str] = []
|
|
106
|
+
text: list[str] = []
|
|
107
|
+
send: list = []
|
|
108
|
+
images: list[str] = []
|
|
109
|
+
for element in elements:
|
|
110
|
+
if isinstance(element, Text):
|
|
111
|
+
key.append(element.text)
|
|
112
|
+
text.append(element.text)
|
|
113
|
+
send.append(element)
|
|
114
|
+
elif isinstance(element, Emoji):
|
|
115
|
+
key.append(f"<face:{element.id}>")
|
|
116
|
+
send.append(element)
|
|
117
|
+
elif isinstance(element, Image):
|
|
118
|
+
images.append(element.src)
|
|
119
|
+
elif isinstance(element, Custom) and element.tag == "onebot:mface":
|
|
120
|
+
attrs = getattr(element, "_attrs", {})
|
|
121
|
+
emoji = attrs.get("emojiId")
|
|
122
|
+
if not emoji or not attrs.get("url"):
|
|
123
|
+
return None
|
|
124
|
+
key.append(f"<mface:{attrs.get('emojiPackageId')}:{emoji}>")
|
|
125
|
+
send.append(Image(src=attrs["url"])) # 发送端不支持商城表情,以图片发回
|
|
126
|
+
else:
|
|
127
|
+
return None
|
|
128
|
+
content = "".join(text).strip()
|
|
129
|
+
if images:
|
|
130
|
+
if len(images) > 1 or content or len(key) != len(text):
|
|
131
|
+
return None # 只有“单独一张图”才按图片比较
|
|
132
|
+
return Said(user, None, [Image(src=images[0])], image=images[0])
|
|
133
|
+
joined = "".join(key).strip()
|
|
134
|
+
if not joined or len(content) > max_length or content.startswith(_command_prefixes()):
|
|
135
|
+
return None
|
|
136
|
+
return Said(user, joined, send, text=content)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@plugin.listen(MessageCreatedEvent)
|
|
140
|
+
@feature_required()
|
|
141
|
+
async def on_message(session: Session[MessageCreatedEvent]):
|
|
142
|
+
ctx = context_from_session(session)
|
|
143
|
+
if ctx.chat_type != "group":
|
|
144
|
+
return
|
|
145
|
+
# 同一个群里的其他 Tenko 账号发的消息不算群友复读。
|
|
146
|
+
if ctx.user_id in {str(account.self_id) for account in online_accounts()}:
|
|
147
|
+
return
|
|
148
|
+
said = parse(list(session.event.message.message), ctx.user_id, config.max_length)
|
|
149
|
+
if said is None:
|
|
150
|
+
repeater.forget(ctx.channel_id)
|
|
151
|
+
return
|
|
152
|
+
reply = await repeater.feed(ctx.channel_id, said)
|
|
153
|
+
if reply:
|
|
154
|
+
# 复读不引用原消息:宿主只给“对触发消息的回复”补引用,包在独立的 <message> 里就会跳过。
|
|
155
|
+
await session.send([Message(content=reply)])
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""复读判定:每个群记住最近一串相同的消息,决定跟一句、打断,还是不出声。
|
|
2
|
+
|
|
3
|
+
这里不碰 Entari:输入是已经拆好的 :class:`Said`,输出是要发出去的元素。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# 拉取图片并返回内容摘要;拉不到时返回 None,这条消息就不会被当成复读。
|
|
15
|
+
Digest = Callable[[str], Awaitable[str | None]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class Said:
|
|
20
|
+
"""一条可以参与复读的消息。
|
|
21
|
+
|
|
22
|
+
``key`` 是文字、QQ 表情和商城表情拼成的比较键;纯图片消息的 ``key`` 为空,
|
|
23
|
+
``image`` 是图片地址,比较时按图片内容判断。``elements`` 是复读时原样发回的元素。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
user: str
|
|
27
|
+
key: str | None
|
|
28
|
+
elements: Sequence[Any]
|
|
29
|
+
text: str = ""
|
|
30
|
+
image: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class Run:
|
|
35
|
+
first: Said
|
|
36
|
+
users: set[str] = field(default_factory=set)
|
|
37
|
+
acted: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Settings:
|
|
42
|
+
min_times: int = 2
|
|
43
|
+
repeat_probability: float = 0.15
|
|
44
|
+
interrupt_probability: float = 0.05
|
|
45
|
+
cooldown_seconds: float = 600
|
|
46
|
+
interrupt_lines: Sequence[str] = ("打断!",)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Repeater:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
settings: Settings,
|
|
53
|
+
digest: Digest,
|
|
54
|
+
*,
|
|
55
|
+
rng: random.Random | None = None,
|
|
56
|
+
clock: Callable[[], float] = time.monotonic,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.settings = settings
|
|
59
|
+
self.digest = digest
|
|
60
|
+
self.rng = rng or random.Random()
|
|
61
|
+
self.clock = clock
|
|
62
|
+
self.runs: dict[str, Run] = {}
|
|
63
|
+
self.cooldown_until: dict[str, float] = {}
|
|
64
|
+
self._digests: dict[str, str | None] = {}
|
|
65
|
+
|
|
66
|
+
def forget(self, group: str) -> None:
|
|
67
|
+
"""不能参与复读的消息(命令、长文、转发等)把这一串断开。"""
|
|
68
|
+
|
|
69
|
+
self.runs.pop(group, None)
|
|
70
|
+
|
|
71
|
+
async def feed(self, group: str, said: Said) -> list[Any] | None:
|
|
72
|
+
"""记下一条消息;需要出声时返回要发送的元素。"""
|
|
73
|
+
|
|
74
|
+
run = self.runs.get(group)
|
|
75
|
+
if run is None or not await self._same(run.first, said):
|
|
76
|
+
self.runs[group] = Run(said, {said.user})
|
|
77
|
+
return None
|
|
78
|
+
if said.user in run.users:
|
|
79
|
+
return None # 同一个人连发不算复读
|
|
80
|
+
run.users.add(said.user)
|
|
81
|
+
if run.acted or self.clock() < self.cooldown_until.get(group, 0):
|
|
82
|
+
return None
|
|
83
|
+
times = len(run.users)
|
|
84
|
+
if times < self.settings.min_times:
|
|
85
|
+
return None
|
|
86
|
+
reply = self._decide(run.first, times)
|
|
87
|
+
if reply is not None:
|
|
88
|
+
run.acted = True
|
|
89
|
+
self.cooldown_until[group] = self.clock() + self.settings.cooldown_seconds
|
|
90
|
+
return reply
|
|
91
|
+
|
|
92
|
+
def _decide(self, said: Said, times: int) -> list[Any] | None:
|
|
93
|
+
s = self.settings
|
|
94
|
+
if self.rng.random() < s.interrupt_probability:
|
|
95
|
+
return self._interrupt(said)
|
|
96
|
+
# 每多一条复读,“不跟”的概率乘以 (1 - p):复读越久越容易跟,起步很克制。
|
|
97
|
+
chance = 1 - (1 - s.repeat_probability) ** (times - s.min_times + 1)
|
|
98
|
+
if self.rng.random() < chance:
|
|
99
|
+
return list(said.elements)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def _interrupt(self, said: Said) -> list[Any]:
|
|
103
|
+
chars = list(said.text)
|
|
104
|
+
if said.key is not None and len(set(chars)) >= 3:
|
|
105
|
+
shuffled = chars[:]
|
|
106
|
+
while shuffled == chars:
|
|
107
|
+
self.rng.shuffle(shuffled)
|
|
108
|
+
return ["".join(shuffled)]
|
|
109
|
+
return [self.rng.choice(list(self.settings.interrupt_lines) or ["打断!"])]
|
|
110
|
+
|
|
111
|
+
async def _same(self, a: Said, b: Said) -> bool:
|
|
112
|
+
if a.image is None or b.image is None:
|
|
113
|
+
return a.image is None and b.image is None and a.key == b.key
|
|
114
|
+
first, second = await self._digest(a.image), await self._digest(b.image)
|
|
115
|
+
return first is not None and first == second
|
|
116
|
+
|
|
117
|
+
async def _digest(self, src: str) -> str | None:
|
|
118
|
+
if src not in self._digests:
|
|
119
|
+
if len(self._digests) >= 64: # 只需要记住最近几张图
|
|
120
|
+
self._digests.pop(next(iter(self._digests)))
|
|
121
|
+
self._digests[src] = await self.digest(src)
|
|
122
|
+
return self._digests[src]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""用真实的 Tenko 宿主加载复读机插件,不启动网络服务。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import pytest_asyncio
|
|
9
|
+
from arclet.entari import Entari
|
|
10
|
+
from arclet.entari.config import EntariConfig
|
|
11
|
+
from tenko.host.plugins import PluginRuntime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def host(tmp_path_factory):
|
|
16
|
+
root = tmp_path_factory.mktemp("tenko-host")
|
|
17
|
+
previous = os.getcwd()
|
|
18
|
+
os.chdir(root)
|
|
19
|
+
EntariConfig(root / "entari.yml")
|
|
20
|
+
Entari(ignore_self_message=True)
|
|
21
|
+
(root / "state.json").write_text(json.dumps({"enabled": ["repeater"]}))
|
|
22
|
+
yield SimpleNamespace(root=root)
|
|
23
|
+
os.chdir(previous)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
|
27
|
+
async def runtime(host):
|
|
28
|
+
runtime = PluginRuntime(host.root / "no-builtin", state_file=host.root / "state.json")
|
|
29
|
+
await runtime.load_all(external_configs={"repeater": {"cooldown_seconds": 60}})
|
|
30
|
+
yield runtime
|
|
31
|
+
runtime.unload("repeater")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope="session")
|
|
35
|
+
def module(runtime):
|
|
36
|
+
return runtime.loaded_plugins["repeater"].module
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import AsyncMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from satori import (
|
|
8
|
+
At,
|
|
9
|
+
Channel,
|
|
10
|
+
ChannelType,
|
|
11
|
+
EventType,
|
|
12
|
+
Guild,
|
|
13
|
+
Image,
|
|
14
|
+
Login,
|
|
15
|
+
MessageObject,
|
|
16
|
+
Text,
|
|
17
|
+
User,
|
|
18
|
+
)
|
|
19
|
+
from satori.element import Custom
|
|
20
|
+
from satori.model import Event, Member
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Scripted(random.Random):
|
|
24
|
+
"""按顺序给出 random() 的结果;shuffle 固定为倒序,便于断言。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *values: float) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.values = list(values)
|
|
29
|
+
|
|
30
|
+
def random(self) -> float:
|
|
31
|
+
return self.values.pop(0)
|
|
32
|
+
|
|
33
|
+
def shuffle(self, x) -> None:
|
|
34
|
+
x.reverse()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def chain(module):
|
|
39
|
+
return module.chain
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make(chain, rng, *, digest=None, now=None):
|
|
43
|
+
clock = now or [0.0]
|
|
44
|
+
return chain.Repeater(
|
|
45
|
+
chain.Settings(cooldown_seconds=600, interrupt_lines=("打断!",)),
|
|
46
|
+
digest or AsyncMock(return_value=None),
|
|
47
|
+
rng=rng,
|
|
48
|
+
clock=lambda: clock[0],
|
|
49
|
+
), clock
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def said(chain, user, text="草"):
|
|
53
|
+
return chain.Said(user, text, [text], text=text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_plugin_loads_and_is_on_by_default_in_new_groups(runtime, module):
|
|
57
|
+
from tenko.host.features import feature_service
|
|
58
|
+
|
|
59
|
+
metadata = runtime.loaded_plugins["repeater"].metadata
|
|
60
|
+
assert metadata.feature_scope == "group"
|
|
61
|
+
assert "repeater" not in feature_service.plugin_defaults # 没有声明“新群默认关闭”
|
|
62
|
+
assert module.config.cooldown_seconds == 60
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_repeats_once_per_run_ignores_same_user_and_cools_down(chain):
|
|
66
|
+
# 第 2 人:不打断(0.9)、跟读(0.1 < 0.15)
|
|
67
|
+
repeater, clock = make(chain, Scripted(0.9, 0.1, 0.9, 0.1))
|
|
68
|
+
assert await repeater.feed("g", said(chain, "a")) is None
|
|
69
|
+
assert await repeater.feed("g", said(chain, "a")) is None # 同一个人不算
|
|
70
|
+
assert await repeater.feed("g", said(chain, "b")) == ["草"]
|
|
71
|
+
assert await repeater.feed("g", said(chain, "c")) is None # 这一串已经出过声
|
|
72
|
+
|
|
73
|
+
await repeater.feed("g", said(chain, "a", "好"))
|
|
74
|
+
assert await repeater.feed("g", said(chain, "b", "好")) is None # 冷却中
|
|
75
|
+
clock[0] = 601
|
|
76
|
+
await repeater.feed("g", said(chain, "a", "行"))
|
|
77
|
+
assert await repeater.feed("g", said(chain, "b", "行")) == ["行"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_chance_grows_with_each_extra_repeater(chain):
|
|
81
|
+
# 第 2 人 0.2 > 15% 不跟;第 3 人 0.2 < 1 - 0.85² ≈ 27.75% 跟
|
|
82
|
+
repeater, _ = make(chain, Scripted(0.9, 0.2, 0.9, 0.2))
|
|
83
|
+
for user in "ab":
|
|
84
|
+
assert await repeater.feed("g", said(chain, user)) is None
|
|
85
|
+
assert await repeater.feed("g", said(chain, "c")) == ["草"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_interrupt_shuffles_text_or_falls_back_to_a_line(chain):
|
|
89
|
+
# 子类只覆盖 random() 时,choice 也会取一次 random()
|
|
90
|
+
repeater, clock = make(chain, Scripted(0.01, 0.01, 0.0))
|
|
91
|
+
await repeater.feed("g", said(chain, "a", "复读机"))
|
|
92
|
+
assert await repeater.feed("g", said(chain, "b", "复读机")) == ["机读复"]
|
|
93
|
+
clock[0] = 601
|
|
94
|
+
await repeater.feed("h", said(chain, "a", "草"))
|
|
95
|
+
assert await repeater.feed("h", said(chain, "b", "草")) == ["打断!"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_images_compare_by_content_not_url(chain):
|
|
99
|
+
digest = AsyncMock(side_effect=lambda src: "same")
|
|
100
|
+
repeater, _ = make(chain, Scripted(0.9, 0.1), digest=digest)
|
|
101
|
+
first = chain.Said("a", None, ["img1"], image="https://cdn/1")
|
|
102
|
+
second = chain.Said("b", None, ["img2"], image="https://cdn/2")
|
|
103
|
+
await repeater.feed("g", said(chain, "a"))
|
|
104
|
+
await repeater.feed("g", first)
|
|
105
|
+
digest.assert_not_called() # 前一条是文字,不用下载
|
|
106
|
+
assert await repeater.feed("g", second) == ["img1"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_parse_skips_commands_mentions_long_text_and_mixed_images(module):
|
|
110
|
+
parse = module.parse
|
|
111
|
+
assert parse([Text("/帮助")], "a", 100) is None
|
|
112
|
+
assert parse([At("1"), Text("草")], "a", 100) is None
|
|
113
|
+
assert parse([Text("长" * 101)], "a", 100) is None
|
|
114
|
+
assert parse([Text("看"), Image(src="https://x")], "a", 100) is None
|
|
115
|
+
mface = Custom("onebot:mface", {"emojiId": "e1", "emojiPackageId": "9", "url": "https://m"})
|
|
116
|
+
parsed = parse([mface], "a", 100)
|
|
117
|
+
assert parsed.key == "<mface:9:e1>" and parsed.elements[0].src == "https://m"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_listener_sends_the_repeat_to_the_group(module, monkeypatch):
|
|
121
|
+
monkeypatch.setattr(module.repeater, "rng", Scripted(0.9, 0.1))
|
|
122
|
+
monkeypatch.setattr(module.repeater, "runs", {})
|
|
123
|
+
monkeypatch.setattr(module.repeater, "cooldown_until", {})
|
|
124
|
+
sends = []
|
|
125
|
+
|
|
126
|
+
def session(user):
|
|
127
|
+
event = Event(
|
|
128
|
+
type=EventType.MESSAGE_CREATED,
|
|
129
|
+
timestamp=datetime.now(),
|
|
130
|
+
login=Login(platform="onebot", user=User("10001")),
|
|
131
|
+
channel=Channel("40001", ChannelType.TEXT),
|
|
132
|
+
guild=Guild("40001", "群"),
|
|
133
|
+
member=Member(user=User(user)),
|
|
134
|
+
user=User(user),
|
|
135
|
+
message=MessageObject(id=f"m{user}", content="草"),
|
|
136
|
+
)
|
|
137
|
+
return SimpleNamespace(
|
|
138
|
+
event=SimpleNamespace(_origin=event, message=event.message),
|
|
139
|
+
send=AsyncMock(side_effect=sends.append),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await module.on_message.callable_target(session("20001"))
|
|
143
|
+
await module.on_message.callable_target(session("20002"))
|
|
144
|
+
assert [str(x) for x in sends[0]] == ["<message>草</message>"]
|