Skip to content

Commit a52c393

Browse files
committed
Implement moderation backend
1 parent f8bcb7f commit a52c393

8 files changed

Lines changed: 372 additions & 33 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ So I'm starting from a clean state and building a new bridge that better adapts
2525
that's also flexible and ready for the demands of tomorrow.
2626

2727
## Is Shinobu production-ready?
28-
**No.**
28+
No (with an asterisk).
2929

30-
Although I am testing this in some communities, Shinobu should **not** be used for production use.
31-
It is still a heavy work in progress and many features are either missing or incomplete.
30+
Shinobu is still in early alpha and many features are missing or incomplete. However, it has been
31+
tested in some communities and it has shown to be reliable enough for day-to-day bridging.
32+
33+
You can use Shinobu if you want, but please note support may be limited. Use at your own risk.
3234

3335
## Todo
3436
- [X] Shinobu Runtime (core, secrets manager, debug tools, etc)

shinobu/beacon/cogs/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"""
1818

1919
import discord
20-
from discord.ext import commands, bridge
20+
from discord.ext import bridge
2121
from shinobu.runtime.models import shinobu_cog
2222
from shinobu.beacon.protocol import beacon
2323

shinobu/beacon/protocol/bans.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""
2+
Shinobu - Converse from anywhere, anytime.
3+
Copyright (C) 2026-present Green (@greeeen-dev)
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU Affero General Public License as
7+
published by the Free Software Foundation, either version 3 of the
8+
License, or (at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU Affero General Public License for more details.
14+
15+
You should have received a copy of the GNU Affero General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
"""
18+
19+
import time
20+
from enum import Enum
21+
from shinobu.beacon.models import user as beacon_user, server as beacon_server
22+
23+
class BeaconBanType(Enum):
24+
unknown = 0
25+
user = 1
26+
server = 2
27+
28+
def __int__(self):
29+
return self.value
30+
31+
class BeaconBan:
32+
"""Represents a moderator.
33+
Moderators can lock Spaces, ban users/servers, etc."""
34+
35+
def __init__(self, user_id: str, ban_type: BeaconBanType | int, platform: str | None = None,
36+
expiry: int | None = None):
37+
self._id: str = user_id
38+
self._type: BeaconBanType = ban_type if type(ban_type) is BeaconBanType else BeaconBanType(ban_type)
39+
self._expiry: int | None = expiry
40+
self._platform: str | None = platform
41+
42+
@property
43+
def id(self) -> str:
44+
return self._id
45+
46+
@property
47+
def type(self) -> BeaconBanType:
48+
return self._type
49+
50+
@property
51+
def platform(self) -> str:
52+
return self._platform
53+
54+
@property
55+
def expiry(self) -> int | None:
56+
return self._expiry
57+
58+
@property
59+
def is_permanent(self) -> bool:
60+
return self._expiry is None
61+
62+
@property
63+
def expired(self) -> bool:
64+
if self.is_permanent:
65+
return False
66+
67+
return time.time() >= self.expiry
68+
69+
def to_dict(self):
70+
return {
71+
"id": self.id,
72+
"type": int(self.type),
73+
"platform": self.platform,
74+
"expiry": self.expiry
75+
}
76+
77+
class BeaconBanManager:
78+
def __init__(self):
79+
self._scheme_version: int = 1
80+
self._bans: dict[str, BeaconBan] = {}
81+
82+
@property
83+
def bans(self) -> list[BeaconBan]:
84+
return list(self._bans.values())
85+
return list(self._admins.values())
86+
87+
def add_ban(self, ban: BeaconBan):
88+
"""Adds a Beacon ban."""
89+
self._bans.update({ban.id: ban})
90+
91+
def add_bans(self, bans: list[BeaconBan]):
92+
"""Adds Beacon bans."""
93+
94+
for ban in bans:
95+
self.add_ban(ban)
96+
97+
def ban(self, user_or_server: beacon_user.BeaconUser | beacon_server.BeaconServer | str,
98+
duration: int | None = None) -> int | None:
99+
"""Bans a user and returns the ban expiry unix time."""
100+
101+
# Get ban type, platform and ID
102+
ban_type: BeaconBanType = BeaconBanType.unknown
103+
ban_platform: str | None = None
104+
105+
if isinstance(user_or_server, beacon_user.BeaconUser):
106+
ban_type = BeaconBanType.user
107+
ban_id: str = user_or_server.id
108+
ban_platform = user_or_server.platform
109+
elif isinstance(user_or_server, beacon_server.BeaconServer):
110+
ban_type = BeaconBanType.server
111+
ban_id: str = user_or_server.id
112+
ban_platform = user_or_server.platform
113+
else:
114+
ban_id: str = user_or_server
115+
116+
# Ban user or server
117+
ban: BeaconBan = BeaconBan(ban_id, ban_type, ban_platform, round(time.time()) + duration)
118+
119+
if self.is_banned(user_or_server):
120+
self._bans[ban.id] = ban
121+
else:
122+
self._bans.update({ban.id: ban})
123+
124+
return ban.expiry
125+
126+
def is_banned(self, user_or_server: beacon_user.BeaconUser | beacon_server.BeaconServer | str):
127+
if isinstance(user_or_server, beacon_user.BeaconUser) or isinstance(user_or_server, beacon_server.BeaconServer):
128+
user_or_server = user_or_server.id
129+
130+
if user_or_server in self._bans:
131+
ban: BeaconBan = self._bans[user_or_server]
132+
if ban.expired:
133+
self.remove_ban(user_or_server)
134+
return False
135+
else:
136+
return True
137+
else:
138+
return False
139+
140+
def remove_ban(self, ban_id: str):
141+
self._bans.pop(ban_id)
142+
143+
def to_dict(self) -> dict:
144+
data = {}
145+
146+
for ban, ban_obj in self._bans.items():
147+
data[ban] = ban_obj.to_dict()
148+
149+
return data

shinobu/beacon/protocol/beacon.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from enum import Enum
2222
from discord.ext import bridge
2323
from shinobu.beacon.protocol import (drivers as beacon_drivers, spaces as beacon_spaces, messages as beacon_messages,
24-
filters as beacon_filters, pausing as beacon_pausing)
24+
filters as beacon_filters, pausing as beacon_pausing, moderators as beacon_mods,
25+
bans as beacon_bans)
2526
from shinobu.beacon.models import (space as beacon_space, message as beacon_message, content as beacon_content,
2627
filter as beacon_filter, member as beacon_member, channel as beacon_channel,
2728
driver as beacon_driver, server as beacon_server, user as beacon_user)
@@ -39,6 +40,10 @@ class BeaconPlatformDisabled(Exception):
3940
def __init__(self, platform: str):
4041
super().__init__(f"The resource is not available because {platform} is disabled.")
4142

43+
class BeaconIsBanned(Exception):
44+
def __init__(self, user_or_server: str):
45+
super().__init__(f"This action cannot be done as {user_or_server} is banned.")
46+
4247
class BeaconCallback:
4348
def __init__(self, func, args: list | None = None, kwargs: dict | None = None):
4449
self._func = func
@@ -91,6 +96,8 @@ def __init__(self, bot: bridge.Bot, files_wrapper: fine_grained.FineGrainedSecur
9196
self._messages: beacon_messages.BeaconMessageCache = beacon_messages.BeaconMessageCache(self.__wrapper)
9297
self._filters: beacon_filters.BeaconFilterManager = beacon_filters.BeaconFilterManager()
9398
self._pausing: beacon_pausing.BeaconPauseManager = beacon_pausing.BeaconPauseManager()
99+
self._moderators: beacon_mods.BeaconModManager = beacon_mods.BeaconModManager()
100+
self._bans: beacon_bans.BeaconBanManager = beacon_bans.BeaconBanManager()
94101

95102
@property
96103
def initialized(self) -> bool:
@@ -112,6 +119,14 @@ def spaces(self) -> beacon_spaces.BeaconSpaceManager:
112119
def messages(self) -> beacon_messages.BeaconMessageCache:
113120
return self._messages
114121

122+
@property
123+
def moderators(self) -> beacon_mods.BeaconModManager:
124+
return self._moderators
125+
126+
@property
127+
def bans(self) -> beacon_bans.BeaconBanManager:
128+
return self._bans
129+
115130
@property
116131
def disabled_platforms(self) -> list[str]:
117132
return self._disabled_platforms
@@ -333,6 +348,33 @@ def _load_data(self):
333348

334349
self.messages.add_message(group)
335350

351+
# Load moderators
352+
if data.get("moderators"):
353+
for _, mod_data in data["moderators"].get("moderators", {}).items():
354+
moderator: beacon_mods.BeaconModerator = beacon_mods.BeaconModerator(
355+
user_id=mod_data["id"],
356+
platform=mod_data["platform"]
357+
)
358+
self.moderators.add_moderator(moderator)
359+
360+
for _, admin_data in data["moderators"].get("admins", {}).items():
361+
admin: beacon_mods.BeaconAdmin = beacon_mods.BeaconAdmin(
362+
user_id=admin_data["id"],
363+
platform=admin_data["platform"]
364+
)
365+
self.moderators.add_admin(admin)
366+
367+
# Load bans
368+
for ban_id, ban_data in data.get("bans", {}).items():
369+
ban: beacon_bans.BeaconBan = beacon_bans.BeaconBan(
370+
user_id=ban_id,
371+
ban_type=ban_data.get("type", 0),
372+
platform=ban_data.get("platform"),
373+
expiry=ban_data.get("expiry")
374+
)
375+
376+
self.bans.add_ban(ban)
377+
336378
self._init = True
337379

338380
# Add shutdown cleanup
@@ -350,6 +392,8 @@ def save_data(self):
350392
# Assemble data dict
351393
data: dict = {
352394
"spaces": self._spaces.to_dict(),
395+
"moderators": self._moderators.to_dict(),
396+
"bans": self._bans.to_dict(),
353397
"paused": self._pausing.to_dict(),
354398
"raw": self._data
355399
}
@@ -589,6 +633,14 @@ async def send(self, author: beacon_member.BeaconMember | beacon_member.BeaconPa
589633
if content.original_platform in self._disabled_platforms:
590634
raise BeaconPlatformDisabled(content.original_platform)
591635

636+
# Ensure user is not banned
637+
if self.bans.is_banned(author):
638+
raise BeaconIsBanned(author.id)
639+
640+
# Ensure server is not banned
641+
if self.bans.is_banned(author.server):
642+
raise BeaconIsBanned(author.server_id)
643+
592644
# Ensure content is not empty
593645
if len(content.blocks) == 0 and len(content.files) == 0:
594646
return
@@ -704,6 +756,14 @@ async def edit(self, message: beacon_message.BeaconMessage, content: beacon_mess
704756
if content.original_platform in self._disabled_platforms:
705757
raise BeaconPlatformDisabled(content.original_platform)
706758

759+
# Ensure user is not banned
760+
if self.bans.is_banned(message.author):
761+
raise BeaconIsBanned(message.author.id)
762+
763+
# Ensure server is not banned
764+
if self.bans.is_banned(message.server):
765+
raise BeaconIsBanned(message.server.id)
766+
707767
origin_driver: beacon_driver.BeaconDriver = self._drivers.get_driver(message.platform)
708768

709769
# Get message metadata
@@ -772,6 +832,10 @@ async def delete(self, message: beacon_message.BeaconMessage):
772832
if message.platform in self._disabled_platforms:
773833
raise BeaconPlatformDisabled(message.platform)
774834

835+
# Ensure server is not banned
836+
if self.bans.is_banned(message.server):
837+
raise BeaconIsBanned(message.server.id)
838+
775839
# Get message group
776840
message_group: beacon_message.BeaconMessageGroup = self.messages.get_group_from_message(message.id)
777841
if not message_group:
@@ -817,6 +881,10 @@ async def purge(self, messages: list[beacon_message.BeaconMessage]):
817881
if messages[0].platform in self._disabled_platforms:
818882
raise BeaconPlatformDisabled(messages[0].platform)
819883

884+
# Ensure server is not banned
885+
if self.bans.is_banned(messages[0].server):
886+
raise BeaconIsBanned(messages[0].server.id)
887+
820888
# Get message groups
821889
message_groups: list[beacon_message.BeaconMessageGroup] = []
822890
for message in messages:
@@ -859,14 +927,22 @@ async def purge(self, messages: list[beacon_message.BeaconMessage]):
859927
await self.__bot.loop.run_in_executor(None, lambda: self.messages.remove_message_group(message_group))
860928

861929
async def pin(self, message: beacon_message.BeaconMessage, unpin: bool = False):
862-
"""Pins a message sent to a Space."""
930+
"""Pins or unpins a message sent to a Space."""
863931

864932
if not self.initialized:
865933
raise BeaconNotInit()
866934

867935
if message.platform in self._disabled_platforms:
868936
raise BeaconPlatformDisabled(message.platform)
869937

938+
# Ensure user is not banned
939+
if self.bans.is_banned(message.author):
940+
raise BeaconIsBanned(message.author.id)
941+
942+
# Ensure server is not banned
943+
if self.bans.is_banned(message.server):
944+
raise BeaconIsBanned(message.server.id)
945+
870946
# Get message group
871947
message_group: beacon_message.BeaconMessageGroup = self.messages.get_group_from_message(message.id)
872948
if not message_group:
@@ -901,5 +977,6 @@ async def pin(self, message: beacon_message.BeaconMessage, unpin: bool = False):
901977
await self._strategy_async(tasks, return_exceptions=not self.debug)
902978

903979
async def unpin(self, message: beacon_message.BeaconMessage):
904-
"""Unpins a message sent to a Space."""
980+
"""Unpins a message sent to a Space.
981+
Shorthand for Beacon.pin(message, unpin=True)"""
905982
return await self.pin(message, unpin=True)

0 commit comments

Comments
 (0)