Skip to content

Commit 4dd924f

Browse files
committed
refactor: Better plugin management in groupbot.
1 parent 908c64a commit 4dd924f

File tree

6 files changed

+156
-55
lines changed

6 files changed

+156
-55
lines changed

tools/groupbot/api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def save(self) -> None:
109109
@dataclass
110110
class Reply:
111111
text: str
112+
message_type: core.Tox_Message_Type = core.TOX_MESSAGE_TYPE_NORMAL
112113

113114

114115
T = TypeVar("T")
@@ -135,3 +136,30 @@ def wrapper(
135136
return func(self, bot, friend_pk, message_type, params)
136137

137138
return wrapper
139+
140+
141+
@dataclass
142+
class HandlerData:
143+
"""Serializable handler data."""
144+
145+
data: dict[str, str]
146+
147+
def __init__(self, **data: str) -> None:
148+
self.data = data
149+
150+
151+
class Handler:
152+
153+
def handle(
154+
self,
155+
bot: GroupBot,
156+
friend_pk: bytes,
157+
message_type: core.Tox_Message_Type,
158+
message: tuple[str, ...],
159+
) -> Optional[Reply]:
160+
"""Handle a Tox message."""
161+
raise NotImplementedError
162+
163+
def data(self) -> HandlerData:
164+
"""Get the handler data usable to clone a handler."""
165+
return HandlerData()

tools/groupbot/commands.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,53 @@
33
import importlib
44
import shlex
55
import sys
6+
from dataclasses import dataclass
7+
from typing import Callable
68
from typing import Optional
79

810
from py_toxcore_c.tools.groupbot import api
11+
from py_toxcore_c.tools.groupbot.plugins import echo
912
from py_toxcore_c.tools.groupbot.plugins import github
1013

1114
import pytox.toxcore.tox as core
1215

1316

14-
def _reload_modules() -> None:
17+
@dataclass
18+
class Module:
19+
"""A groupbot module."""
20+
21+
prefix: str
22+
new: Callable[[api.Config], api.Handler]
23+
clone: Callable[[api.HandlerData], api.Handler]
24+
25+
26+
def _reload_modules() -> tuple[Module, ...]:
1527
"""Reload the extension modules."""
1628
for name in tuple(sys.modules.keys()):
1729
if name.startswith("py_toxcore_c.tools.groupbot.plugins."):
1830
importlib.reload(sys.modules[name])
1931

32+
return (
33+
Module("echo", echo.Echo.new, echo.Echo.clone),
34+
Module("gh", github.GitHub.new, github.GitHub.clone),
35+
)
36+
2037

2138
class Commands:
22-
_gh: github.GitHub
39+
_handlers: dict[str, api.Handler]
2340

2441
def __init__(self, config: api.Config) -> None:
25-
self._gh = github.GitHub(config.github_path)
42+
self._handlers = {
43+
mod.prefix: mod.new(config)
44+
for mod in _reload_modules()
45+
}
2646

2747
def _reload(self) -> None:
2848
"""Reload the extension modules."""
29-
_reload_modules()
30-
self._gh = github.GitHub(self._gh.path)
49+
self._handlers = {
50+
mod.prefix: mod.clone(self._handlers[mod.prefix].data())
51+
for mod in _reload_modules()
52+
}
3153

3254
def handle(
3355
self,
@@ -44,19 +66,14 @@ def handle(
4466
if not command:
4567
return None
4668
try:
47-
fun = self._dispatch(command[0])
48-
if fun:
49-
return fun(bot, friend_pk, message_type, tuple(command[1:]))
69+
return self._dispatch(command[0])(bot, friend_pk, message_type,
70+
tuple(command[1:]))
5071
except Exception as e:
5172
return api.Reply(f"Error: {e}")
5273
return None
5374

54-
def _dispatch(self, command: str) -> Optional[api.CommandFunction]:
75+
def _dispatch(self, command: str) -> api.CommandFunction:
5576
"""Dispatch a command."""
56-
if command == "echo":
57-
return self.echo
58-
if command == "gh":
59-
return self.gh
6077
if command == "leave":
6178
return self.leave
6279
if command == "nick":
@@ -65,27 +82,19 @@ def _dispatch(self, command: str) -> Optional[api.CommandFunction]:
6582
return self.reload
6683
if command == "save":
6784
return self.save
68-
return None
69-
70-
def echo(
71-
self,
72-
bot: api.GroupBot,
73-
friend_pk: bytes,
74-
message_type: core.Tox_Message_Type,
75-
message: tuple[str, ...],
76-
) -> Optional[api.Reply]:
77-
"""Echo a message."""
78-
return api.Reply(str(list(message)))
85+
if command in self._handlers:
86+
return self._handlers[command].handle
87+
return self.null
7988

80-
def gh(
89+
def null(
8190
self,
8291
bot: api.GroupBot,
8392
friend_pk: bytes,
8493
message_type: core.Tox_Message_Type,
8594
params: tuple[str, ...],
8695
) -> Optional[api.Reply]:
87-
"""Provide access to GitHub issue/PR information."""
88-
return self._gh.handle(bot, friend_pk, message_type, params)
96+
"""Null command."""
97+
return None
8998

9099
@api.admin
91100
def leave(

tools/groupbot/groupbot.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def handle_group_message(
110110
) -> None:
111111
print("Group message:", group_number, peer_id, message_type.name,
112112
message.decode())
113-
peer = self.group_peer_get_name(group_number, peer_id)
113+
peer = self.group_peer_get_name(group_number, peer_id).decode()
114114
if not self.conference_chatlist:
115115
return
116116
if message.startswith(b"~"):
@@ -125,13 +125,12 @@ def handle_group_message(
125125
if reply:
126126
self.group_send_message(
127127
group_number,
128-
core.TOX_MESSAGE_TYPE_NORMAL,
129-
f"{peer.decode()}: {reply.text}".encode(),
128+
reply.message_type,
129+
f"{peer}: {reply.text}".encode(),
130130
)
131131
else:
132132
self.conference_send_message(
133-
0, message_type,
134-
f"<{peer.decode()}> {message.decode()}".encode())
133+
0, message_type, f"<{peer}> {message.decode()}".encode())
135134

136135
def handle_conference_message(
137136
self,
@@ -164,7 +163,7 @@ def handle_conference_message(
164163
if reply:
165164
self.conference_send_message(
166165
conference_number,
167-
core.TOX_MESSAGE_TYPE_NORMAL,
166+
reply.message_type,
168167
f"{peer}: {reply.text}".encode(),
169168
)
170169
else:

tools/groupbot/groupbot.tox

0 Bytes
Binary file not shown.

tools/groupbot/plugins/echo.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# Copyright © 2025 The TokTok team
3+
from typing import Optional
4+
5+
from py_toxcore_c.tools.groupbot import api
6+
7+
import pytox.toxcore.tox as core
8+
9+
10+
class Echo(api.Handler):
11+
12+
@staticmethod
13+
def new(config: api.Config) -> "Echo":
14+
"""Create a Echo module instance from a configuration."""
15+
return Echo()
16+
17+
@staticmethod
18+
def clone(data: api.HandlerData) -> "Echo":
19+
"""Clone a module instance."""
20+
return Echo()
21+
22+
def handle(
23+
self,
24+
bot: api.GroupBot,
25+
friend_pk: bytes,
26+
message_type: core.Tox_Message_Type,
27+
message: tuple[str, ...],
28+
) -> Optional[api.Reply]:
29+
"""Handle a Tox message."""
30+
return api.Reply(str(list(message)), message_type)

tools/groupbot/plugins/github.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,20 @@ class Issue:
7777
title: str
7878
state: str
7979
user: User
80-
is_pull_request: bool
8180

8281
@property
8382
def repo(self) -> str:
8483
return self.path.repo.name
8584

85+
@property
86+
def is_pull_request(self) -> bool:
87+
return self.path.is_pull_request
88+
89+
@property
90+
def emoji(self) -> str:
91+
"""Get an emoji to distinguish between issue and PR."""
92+
return "🎁" if self.is_pull_request else "🐛"
93+
8694
@staticmethod
8795
def fromJSON(path: IssuePath, issue: dict[str, Any]) -> "Issue":
8896
return Issue(
@@ -91,14 +99,29 @@ def fromJSON(path: IssuePath, issue: dict[str, Any]) -> "Issue":
9199
title=str(issue["title"]),
92100
state=str(issue["state"]),
93101
user=User.fromJSON(issue["user"]),
94-
is_pull_request=bool(issue.get("pull_request")),
95102
)
96103

97104

98105
@dataclass
99-
class GitHub:
106+
class GitHub(api.Handler):
100107
path: str
101108

109+
@staticmethod
110+
def new(config: api.Config) -> "GitHub":
111+
"""Create a GitHub instance from a configuration."""
112+
return GitHub(config.github_path)
113+
114+
@staticmethod
115+
def clone(data: api.HandlerData) -> "GitHub":
116+
"""Clone a GitHub instance."""
117+
if "path" not in data.data:
118+
raise ValueError("Missing path in HandlerData")
119+
return GitHub(data.data["path"])
120+
121+
def data(self) -> api.HandlerData:
122+
"""Get the module data."""
123+
return api.HandlerData(path=self.path)
124+
102125
def __hash__(self) -> int:
103126
return hash(self.path)
104127

@@ -127,43 +150,55 @@ def handle_cli(self, message: tuple[str, ...]) -> Optional[api.Reply]:
127150

128151
return None
129152

130-
def handle_issue(self, repo_name: Optional[str],
131-
issue_id: str) -> api.Reply:
132-
"""Handle an issue/PR number."""
133-
try:
134-
issue_number = int(issue_id)
135-
except ValueError:
136-
return api.Reply(f"Error: {issue_id} is not a valid issue number")
137-
138-
issues: list[Issue] = []
153+
def _find_issue(
154+
self, repo_name: Optional[str],
155+
issue_number: int) -> tuple[Optional[str], Optional[Issue]]:
156+
"""Find an issue/PR by number."""
157+
candidates: list[Issue] = []
139158
repos = (self.repos() if repo_name is None else [
140159
repo for repo in self.repos()
141-
if repo.name.lower() == repo_name.lower()
160+
if repo.name.lower().startswith(repo_name.lower())
142161
])
143162
if not repos:
144-
return api.Reply(f"Error: Repository {repo_name} not found")
163+
return None, None
145164
for repo in repos:
146165
for issue_path in self.issues(repo):
147166
if issue_path.number == issue_number:
148-
issues.append(self.load_issue(issue_path))
167+
candidates.append(self.load_issue(issue_path))
149168

169+
if len(repos) == 1:
170+
repo_name = repos[0].name
150171
repo_name = repo_name or "any repository"
151172

152173
# If any issues were found, return the first one that's open. If none
153174
# are open, return the first one.
154175
issue: Optional[Issue] = None
155-
for candidate in issues:
176+
for candidate in candidates:
156177
if candidate.state == "open":
178+
issue = candidate
157179
break
158180
else:
159-
issue = issues[0] if issues else None
160-
if issue:
181+
issue = candidates[0] if candidates else None
182+
183+
return repo_name, issue
184+
185+
def handle_issue(self, repo_name: Optional[str],
186+
issue_id: str) -> api.Reply:
187+
"""Handle an issue/PR number."""
188+
try:
189+
issue_number = int(issue_id)
190+
except ValueError:
191+
return api.Reply(f"Error: {issue_id} is not a valid issue number")
192+
193+
found_repo, issue = self._find_issue(repo_name, issue_number)
194+
if not found_repo:
195+
return api.Reply(f"Error: Repository {repo_name} not found")
196+
if not issue:
161197
return api.Reply(
162-
f"{issue.title} by {issue.user.login} ({issue.repo}#{issue.number}, {issue.state})"
163-
)
198+
f"Error: Issue {issue_number} not found in {found_repo}")
164199

165-
return api.Reply(
166-
f"Error: Issue {issue_number} not found in {repo_name}")
200+
return api.Reply(f"{issue.emoji} {issue.title} by {issue.user.login} "
201+
f"({issue.repo}#{issue.number}, {issue.state})")
167202

168203
@memoize
169204
def load_issue(self, issue: IssuePath) -> Issue:

0 commit comments

Comments
 (0)