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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .tenko/
@@ -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>"]