feat(telegram): optional resume (#104)
This commit is contained in:
@@ -48,6 +48,7 @@ line. If you want auto-resume without replies, enable chat sessions.
|
|||||||
Configuration (under `[transports.telegram]`):
|
Configuration (under `[transports.telegram]`):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
show_resume_line = true # set false to hide resume lines
|
||||||
session_mode = "chat" # or "stateless"
|
session_mode = "chat" # or "stateless"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,6 +60,10 @@ Behavior:
|
|||||||
|
|
||||||
State is stored in `telegram_chat_sessions_state.json` alongside the config file.
|
State is stored in `telegram_chat_sessions_state.json` alongside the config file.
|
||||||
|
|
||||||
|
Set `show_resume_line = false` to hide resume lines when takopi can auto-resume
|
||||||
|
(topics or chat sessions) and a project context is resolved. Otherwise the resume
|
||||||
|
line stays visible so reply-to-continue still works.
|
||||||
|
|
||||||
## Message overflow
|
## Message overflow
|
||||||
|
|
||||||
By default, takopi trims long final responses to ~3500 characters to stay under
|
By default, takopi trims long final responses to ~3500 characters to stay under
|
||||||
@@ -84,7 +89,6 @@ Configuration (under `[transports.telegram]`):
|
|||||||
[transports.telegram.topics]
|
[transports.telegram.topics]
|
||||||
enabled = true
|
enabled = true
|
||||||
scope = "auto" # auto | main | projects | all
|
scope = "auto" # auto | main | projects | all
|
||||||
show_resume_line = true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
@@ -96,10 +100,6 @@ Requirements:
|
|||||||
- `auto`: if any project chats are configured, uses `projects`; otherwise `main`.
|
- `auto`: if any project chats are configured, uses `projects`; otherwise `main`.
|
||||||
- The bot needs the **Manage Topics** permission in the relevant chat(s).
|
- The bot needs the **Manage Topics** permission in the relevant chat(s).
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
- `show_resume_line`: set `false` to hide the resume command line in topic threads.
|
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
||||||
- `main`: `/topic <project> @branch` creates a topic in the main chat and binds it.
|
- `main`: `/topic <project> @branch` creates a topic in the main chat and binds it.
|
||||||
|
|||||||
+5
-6
@@ -39,6 +39,8 @@ To continue a session:
|
|||||||
- **Chat sessions** (optional) store one resume token per chat (per sender in groups) so new messages
|
- **Chat sessions** (optional) store one resume token per chat (per sender in groups) so new messages
|
||||||
auto-resume without replying. Enable with `session_mode = "chat"` and reset with `/new`.
|
auto-resume without replying. Enable with `session_mode = "chat"` and reset with `/new`.
|
||||||
State is stored in `telegram_chat_sessions_state.json`.
|
State is stored in `telegram_chat_sessions_state.json`.
|
||||||
|
You can hide resume lines by setting `[transports.telegram].show_resume_line = false`
|
||||||
|
when auto-resume is available and a project context is resolved.
|
||||||
|
|
||||||
Reply-to-continue always works, even if chat sessions or topics are enabled.
|
Reply-to-continue always works, even if chat sessions or topics are enabled.
|
||||||
|
|
||||||
@@ -262,7 +264,6 @@ Topics bind Telegram forum threads to specific project/branch contexts. They als
|
|||||||
```toml
|
```toml
|
||||||
[transports.telegram.topics]
|
[transports.telegram.topics]
|
||||||
enabled = true
|
enabled = true
|
||||||
# show_resume_line = false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Your bot needs **Manage Topics** permission in the group.
|
Your bot needs **Manage Topics** permission in the group.
|
||||||
@@ -270,8 +271,6 @@ Your bot needs **Manage Topics** permission in the group.
|
|||||||
If any `projects.<alias>.chat_id` are configured, topics are managed in those
|
If any `projects.<alias>.chat_id` are configured, topics are managed in those
|
||||||
project chats; otherwise topics are managed in the main chat.
|
project chats; otherwise topics are managed in the main chat.
|
||||||
|
|
||||||
Set `show_resume_line = false` to hide the resume command line in topic threads.
|
|
||||||
|
|
||||||
### Topic behavior
|
### Topic behavior
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -321,10 +320,10 @@ In project chats, omit the project: `/topic @branch` or `/ctx set @branch`.
|
|||||||
```toml
|
```toml
|
||||||
[transports.telegram]
|
[transports.telegram]
|
||||||
chat_id = -1001234567890
|
chat_id = -1001234567890
|
||||||
|
# show_resume_line = false
|
||||||
|
|
||||||
[transports.telegram.topics]
|
[transports.telegram.topics]
|
||||||
enabled = true
|
enabled = true
|
||||||
# show_resume_line = false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Project chats:**
|
**Project chats:**
|
||||||
@@ -332,10 +331,10 @@ enabled = true
|
|||||||
```toml
|
```toml
|
||||||
[transports.telegram]
|
[transports.telegram]
|
||||||
chat_id = 123456789 # main chat (private, for non-project messages)
|
chat_id = 123456789 # main chat (private, for non-project messages)
|
||||||
|
# show_resume_line = false
|
||||||
|
|
||||||
[transports.telegram.topics]
|
[transports.telegram.topics]
|
||||||
enabled = true
|
enabled = true
|
||||||
# show_resume_line = false
|
|
||||||
|
|
||||||
[projects.takopi]
|
[projects.takopi]
|
||||||
path = "~/dev/takopi"
|
path = "~/dev/takopi"
|
||||||
@@ -448,6 +447,7 @@ chat_id = 123456789
|
|||||||
voice_transcription = true
|
voice_transcription = true
|
||||||
voice_transcription_model = "gpt-4o-mini-transcribe"
|
voice_transcription_model = "gpt-4o-mini-transcribe"
|
||||||
session_mode = "stateless" # or "chat" for auto-resume per chat
|
session_mode = "stateless" # or "chat" for auto-resume per chat
|
||||||
|
show_resume_line = true
|
||||||
|
|
||||||
[transports.telegram.files]
|
[transports.telegram.files]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -460,7 +460,6 @@ deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
|
|||||||
[transports.telegram.topics]
|
[transports.telegram.topics]
|
||||||
enabled = true
|
enabled = true
|
||||||
scope = "auto"
|
scope = "auto"
|
||||||
show_resume_line = true
|
|
||||||
|
|
||||||
# Project definitions
|
# Project definitions
|
||||||
[projects.takopi]
|
[projects.takopi]
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class TelegramTopicsSettings(BaseModel):
|
|||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
scope: Literal["auto", "main", "projects", "all"] = "auto"
|
scope: Literal["auto", "main", "projects", "all"] = "auto"
|
||||||
show_resume_line: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramFilesSettings(BaseModel):
|
class TelegramFilesSettings(BaseModel):
|
||||||
@@ -104,6 +103,7 @@ class TelegramTransportSettings(BaseModel):
|
|||||||
voice_max_bytes: StrictInt = 10 * 1024 * 1024
|
voice_max_bytes: StrictInt = 10 * 1024 * 1024
|
||||||
voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe"
|
voice_transcription_model: NonEmptyStr = "gpt-4o-mini-transcribe"
|
||||||
session_mode: Literal["stateless", "chat"] = "stateless"
|
session_mode: Literal["stateless", "chat"] = "stateless"
|
||||||
|
show_resume_line: bool = True
|
||||||
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
|
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
|
||||||
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
|
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class TelegramBackend(TransportBackend):
|
|||||||
startup_msg=startup_msg,
|
startup_msg=startup_msg,
|
||||||
exec_cfg=exec_cfg,
|
exec_cfg=exec_cfg,
|
||||||
session_mode=settings.session_mode,
|
session_mode=settings.session_mode,
|
||||||
|
show_resume_line=settings.show_resume_line,
|
||||||
voice_transcription=settings.voice_transcription,
|
voice_transcription=settings.voice_transcription,
|
||||||
voice_max_bytes=int(settings.voice_max_bytes),
|
voice_max_bytes=int(settings.voice_max_bytes),
|
||||||
voice_transcription_model=settings.voice_transcription_model,
|
voice_transcription_model=settings.voice_transcription_model,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class TelegramBridgeConfig:
|
|||||||
startup_msg: str
|
startup_msg: str
|
||||||
exec_cfg: ExecBridgeConfig
|
exec_cfg: ExecBridgeConfig
|
||||||
session_mode: Literal["stateless", "chat"] = "stateless"
|
session_mode: Literal["stateless", "chat"] = "stateless"
|
||||||
|
show_resume_line: bool = True
|
||||||
voice_transcription: bool = False
|
voice_transcription: bool = False
|
||||||
voice_max_bytes: int = 10 * 1024 * 1024
|
voice_max_bytes: int = 10 * 1024 * 1024
|
||||||
voice_transcription_model: str = "gpt-4o-mini-transcribe"
|
voice_transcription_model: str = "gpt-4o-mini-transcribe"
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ from .topics import (
|
|||||||
_maybe_update_topic_context,
|
_maybe_update_topic_context,
|
||||||
_topic_key,
|
_topic_key,
|
||||||
_topic_title,
|
_topic_title,
|
||||||
_topics_chat_allowed,
|
|
||||||
_topics_chat_project,
|
_topics_chat_project,
|
||||||
_topics_command_error,
|
_topics_command_error,
|
||||||
)
|
)
|
||||||
@@ -280,6 +279,21 @@ class _ResumeLineProxy:
|
|||||||
return self.runner.run(prompt, resume)
|
return self.runner.run(prompt, resume)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_show_resume_line(
|
||||||
|
*,
|
||||||
|
show_resume_line: bool,
|
||||||
|
stateful_mode: bool,
|
||||||
|
context: RunContext | None,
|
||||||
|
) -> bool:
|
||||||
|
if show_resume_line:
|
||||||
|
return True
|
||||||
|
if not stateful_mode:
|
||||||
|
return True
|
||||||
|
if context is None or context.project is None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def resolve_file_put_paths(
|
def resolve_file_put_paths(
|
||||||
plan: _FilePutPlan,
|
plan: _FilePutPlan,
|
||||||
*,
|
*,
|
||||||
@@ -1283,7 +1297,7 @@ async def _run_engine(
|
|||||||
await reply(text=f"error:\n{exc}")
|
await reply(text=f"error:\n{exc}")
|
||||||
return
|
return
|
||||||
runner: Runner = entry.runner
|
runner: Runner = entry.runner
|
||||||
if thread_id is not None and not show_resume_line:
|
if not show_resume_line:
|
||||||
runner = cast(Runner, _ResumeLineProxy(runner))
|
runner = cast(Runner, _ResumeLineProxy(runner))
|
||||||
if not entry.available:
|
if not entry.available:
|
||||||
reason = entry.issue or "engine unavailable"
|
reason = entry.issue or "engine unavailable"
|
||||||
@@ -1396,6 +1410,7 @@ class _TelegramCommandExecutor(CommandExecutor):
|
|||||||
user_msg_id: int,
|
user_msg_id: int,
|
||||||
thread_id: int | None,
|
thread_id: int | None,
|
||||||
show_resume_line: bool,
|
show_resume_line: bool,
|
||||||
|
stateful_mode: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._exec_cfg = exec_cfg
|
self._exec_cfg = exec_cfg
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
@@ -1405,6 +1420,7 @@ class _TelegramCommandExecutor(CommandExecutor):
|
|||||||
self._user_msg_id = user_msg_id
|
self._user_msg_id = user_msg_id
|
||||||
self._thread_id = thread_id
|
self._thread_id = thread_id
|
||||||
self._show_resume_line = show_resume_line
|
self._show_resume_line = show_resume_line
|
||||||
|
self._stateful_mode = stateful_mode
|
||||||
self._reply_ref = MessageRef(
|
self._reply_ref = MessageRef(
|
||||||
channel_id=chat_id,
|
channel_id=chat_id,
|
||||||
message_id=user_msg_id,
|
message_id=user_msg_id,
|
||||||
@@ -1450,6 +1466,11 @@ class _TelegramCommandExecutor(CommandExecutor):
|
|||||||
self, request: RunRequest, *, mode: RunMode = "emit"
|
self, request: RunRequest, *, mode: RunMode = "emit"
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
request = self._apply_default_context(request)
|
request = self._apply_default_context(request)
|
||||||
|
effective_show_resume_line = _should_show_resume_line(
|
||||||
|
show_resume_line=self._show_resume_line,
|
||||||
|
stateful_mode=self._stateful_mode,
|
||||||
|
context=request.context,
|
||||||
|
)
|
||||||
engine = self._runtime.resolve_engine(
|
engine = self._runtime.resolve_engine(
|
||||||
engine_override=request.engine,
|
engine_override=request.engine,
|
||||||
context=request.context,
|
context=request.context,
|
||||||
@@ -1474,7 +1495,7 @@ class _TelegramCommandExecutor(CommandExecutor):
|
|||||||
on_thread_known=None,
|
on_thread_known=None,
|
||||||
engine_override=engine,
|
engine_override=engine,
|
||||||
thread_id=self._thread_id,
|
thread_id=self._thread_id,
|
||||||
show_resume_line=self._show_resume_line,
|
show_resume_line=effective_show_resume_line,
|
||||||
)
|
)
|
||||||
return RunResult(engine=engine, message=capture.last_message)
|
return RunResult(engine=engine, message=capture.last_message)
|
||||||
await _run_engine(
|
await _run_engine(
|
||||||
@@ -1490,7 +1511,7 @@ class _TelegramCommandExecutor(CommandExecutor):
|
|||||||
on_thread_known=self._scheduler.note_thread_known,
|
on_thread_known=self._scheduler.note_thread_known,
|
||||||
engine_override=engine,
|
engine_override=engine,
|
||||||
thread_id=self._thread_id,
|
thread_id=self._thread_id,
|
||||||
show_resume_line=self._show_resume_line,
|
show_resume_line=effective_show_resume_line,
|
||||||
)
|
)
|
||||||
return RunResult(engine=engine, message=None)
|
return RunResult(engine=engine, message=None)
|
||||||
|
|
||||||
@@ -1524,6 +1545,7 @@ async def _dispatch_command(
|
|||||||
args_text: str,
|
args_text: str,
|
||||||
running_tasks: RunningTasks,
|
running_tasks: RunningTasks,
|
||||||
scheduler: ThreadScheduler,
|
scheduler: ThreadScheduler,
|
||||||
|
stateful_mode: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
allowlist = cfg.runtime.allowlist
|
allowlist = cfg.runtime.allowlist
|
||||||
chat_id = msg.chat_id
|
chat_id = msg.chat_id
|
||||||
@@ -1537,8 +1559,6 @@ async def _dispatch_command(
|
|||||||
if msg.reply_to_message_id is not None
|
if msg.reply_to_message_id is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
topic_thread = msg.thread_id is not None and _topics_chat_allowed(cfg, msg.chat_id)
|
|
||||||
show_resume_line = cfg.topics.show_resume_line if topic_thread else True
|
|
||||||
executor = _TelegramCommandExecutor(
|
executor = _TelegramCommandExecutor(
|
||||||
exec_cfg=cfg.exec_cfg,
|
exec_cfg=cfg.exec_cfg,
|
||||||
runtime=cfg.runtime,
|
runtime=cfg.runtime,
|
||||||
@@ -1547,7 +1567,8 @@ async def _dispatch_command(
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_msg_id=user_msg_id,
|
user_msg_id=user_msg_id,
|
||||||
thread_id=msg.thread_id,
|
thread_id=msg.thread_id,
|
||||||
show_resume_line=show_resume_line,
|
show_resume_line=cfg.show_resume_line,
|
||||||
|
stateful_mode=stateful_mode,
|
||||||
)
|
)
|
||||||
message_ref = MessageRef(
|
message_ref = MessageRef(
|
||||||
channel_id=chat_id, message_id=user_msg_id, thread_id=msg.thread_id
|
channel_id=chat_id, message_id=user_msg_id, thread_id=msg.thread_id
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from .commands import (
|
|||||||
_parse_slash_command,
|
_parse_slash_command,
|
||||||
_reserved_commands,
|
_reserved_commands,
|
||||||
_save_file_put,
|
_save_file_put,
|
||||||
|
_should_show_resume_line,
|
||||||
_run_engine,
|
_run_engine,
|
||||||
_set_command_menu,
|
_set_command_menu,
|
||||||
handle_callback_cancel,
|
handle_callback_cancel,
|
||||||
@@ -444,8 +445,11 @@ async def run_main_loop(
|
|||||||
)
|
)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
show_resume_line = (
|
stateful_mode = topic_key is not None or chat_session_key is not None
|
||||||
cfg.topics.show_resume_line if topic_key is not None else True
|
show_resume_line = _should_show_resume_line(
|
||||||
|
show_resume_line=cfg.show_resume_line,
|
||||||
|
stateful_mode=stateful_mode,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
await _run_engine(
|
await _run_engine(
|
||||||
exec_cfg=cfg.exec_cfg,
|
exec_cfg=cfg.exec_cfg,
|
||||||
@@ -590,6 +594,7 @@ async def run_main_loop(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
chat_session_key = _chat_session_key(msg, store=chat_session_store)
|
chat_session_key = _chat_session_key(msg, store=chat_session_store)
|
||||||
|
stateful_mode = topic_key is not None or chat_session_key is not None
|
||||||
chat_project = (
|
chat_project = (
|
||||||
_topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None
|
_topics_chat_project(cfg, chat_id) if cfg.topics.enabled else None
|
||||||
)
|
)
|
||||||
@@ -706,6 +711,7 @@ async def run_main_loop(
|
|||||||
args_text,
|
args_text,
|
||||||
running_tasks,
|
running_tasks,
|
||||||
scheduler,
|
scheduler,
|
||||||
|
stateful_mode,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1462,9 +1462,19 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None:
|
|||||||
presenter=MarkdownPresenter(),
|
presenter=MarkdownPresenter(),
|
||||||
final_notify=True,
|
final_notify=True,
|
||||||
)
|
)
|
||||||
|
projects = ProjectsConfig(
|
||||||
|
projects={
|
||||||
|
"proj": ProjectConfig(
|
||||||
|
alias="proj",
|
||||||
|
path=tmp_path,
|
||||||
|
worktrees_dir=Path(".worktrees"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
default_project="proj",
|
||||||
|
)
|
||||||
runtime = TransportRuntime(
|
runtime = TransportRuntime(
|
||||||
router=_make_router(runner),
|
router=_make_router(runner),
|
||||||
projects=_empty_projects(),
|
projects=projects,
|
||||||
config_path=state_path,
|
config_path=state_path,
|
||||||
)
|
)
|
||||||
cfg = TelegramBridgeConfig(
|
cfg = TelegramBridgeConfig(
|
||||||
@@ -1526,6 +1536,69 @@ async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None:
|
|||||||
assert runner2.calls[0][1] == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
|
assert runner2.calls[0][1] == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_main_loop_hides_resume_line_when_disabled(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
resume_value = "resume-123"
|
||||||
|
state_path = tmp_path / "takopi.toml"
|
||||||
|
|
||||||
|
transport = _FakeTransport()
|
||||||
|
bot = _FakeBot()
|
||||||
|
runner = ScriptRunner(
|
||||||
|
[Return(answer="ok")],
|
||||||
|
engine=CODEX_ENGINE,
|
||||||
|
resume_value=resume_value,
|
||||||
|
)
|
||||||
|
exec_cfg = ExecBridgeConfig(
|
||||||
|
transport=transport,
|
||||||
|
presenter=MarkdownPresenter(),
|
||||||
|
final_notify=True,
|
||||||
|
)
|
||||||
|
projects = ProjectsConfig(
|
||||||
|
projects={
|
||||||
|
"proj": ProjectConfig(
|
||||||
|
alias="proj",
|
||||||
|
path=tmp_path,
|
||||||
|
worktrees_dir=Path(".worktrees"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
default_project="proj",
|
||||||
|
)
|
||||||
|
runtime = TransportRuntime(
|
||||||
|
router=_make_router(runner),
|
||||||
|
projects=projects,
|
||||||
|
config_path=state_path,
|
||||||
|
)
|
||||||
|
cfg = TelegramBridgeConfig(
|
||||||
|
bot=bot,
|
||||||
|
runtime=runtime,
|
||||||
|
chat_id=123,
|
||||||
|
startup_msg="",
|
||||||
|
exec_cfg=exec_cfg,
|
||||||
|
session_mode="chat",
|
||||||
|
show_resume_line=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poller(_cfg: TelegramBridgeConfig):
|
||||||
|
yield TelegramIncomingMessage(
|
||||||
|
transport="telegram",
|
||||||
|
chat_id=123,
|
||||||
|
message_id=1,
|
||||||
|
text="hello",
|
||||||
|
reply_to_message_id=None,
|
||||||
|
reply_to_text=None,
|
||||||
|
sender_id=123,
|
||||||
|
chat_type="private",
|
||||||
|
)
|
||||||
|
|
||||||
|
await run_main_loop(cfg, poller)
|
||||||
|
|
||||||
|
assert transport.send_calls
|
||||||
|
final_text = transport.send_calls[-1]["message"].text
|
||||||
|
assert resume_value not in final_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_run_main_loop_chat_sessions_isolate_group_senders(
|
async def test_run_main_loop_chat_sessions_isolate_group_senders(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
Reference in New Issue
Block a user