tenko-plugin-repeater 0.1.0__py3-none-any.whl
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.
|
@@ -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,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,6 @@
|
|
|
1
|
+
tenko_plugin_repeater/__init__.py,sha256=TD-tlLdcQdpPmIChtGX5Ytyvw0vCRil40Gbj-y6OLZg,5982
|
|
2
|
+
tenko_plugin_repeater/chain.py,sha256=8TgOccsHsJBMUPk4iNSh6s1yiITgDydzpOSdgkH1zEA,4344
|
|
3
|
+
tenko_plugin_repeater-0.1.0.dist-info/METADATA,sha256=fuatb1msOgsyDD2Ui4UpFH0PJthoDTtrlL_SYqzm3g8,2357
|
|
4
|
+
tenko_plugin_repeater-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
tenko_plugin_repeater-0.1.0.dist-info/entry_points.txt,sha256=Mv10JMLLdwl3cJ40me2B71E1OkWeskHJ0qUem5c-5sA,49
|
|
6
|
+
tenko_plugin_repeater-0.1.0.dist-info/RECORD,,
|