endstone-clans-api 1.0.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,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ # url: https://pypi.org/p/YOURPROJECT
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,3 @@
1
+ .venv
2
+ bedrock_server
3
+ **__pycache__
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: endstone-clans-api
3
+ Version: 1.0.0
4
+ Author: niko-at-chalupa
5
+ Requires-Dist: endstone
6
+ Requires-Dist: pydantic
7
+ Requires-Dist: ruamel-yaml
8
+ Requires-Dist: xxhash
@@ -0,0 +1,68 @@
1
+ <div align="center">
2
+
3
+ # Endstone Clans API
4
+
5
+ </div>
6
+
7
+ A plugin that manages clans (or teams, if you will). This is meant to be used in conjunction with one of your own plugins, so using it on its own isn't recommended (this genuinely does not do anything without it).
8
+
9
+ ## Features
10
+
11
+ > - Portable SQLite database
12
+ > You can test this plugin in staging, and bring the `clans.db` file to production. While it's not recommended to modify the database while it's still running, you can do it and most of the time you'll be fine.
13
+
14
+ > - User-facing commands that let them manage their own clans
15
+ > Users may create clans, rename their own clans, whatever. They must stay in only one clan, though.
16
+
17
+ > - API that _respects_ your language server
18
+ > No more blindly throwing methods, `getattr`s, and `setattr`s at an `Any` type like in certain other APIs. As long as you import `get_clans_api` and use that, then your language server gets to have all the information to do things like full autocomplete and typehints.
19
+
20
+ > - Event system
21
+ > You can cancel events, do whatever. You can even use this to do things like replace the invite UI with your own.
22
+
23
+ > - Extensive config
24
+ > The config lets you configure a lot.
25
+ >
26
+ > Every single piece of front-end facing text translatable through the config.
27
+
28
+ ## Documentation
29
+
30
+ > [!IMPORTANT]
31
+ > Always use this by installing the plugin into your environment, not putting it into the server's `plugins/` directory as a `.whl`. Installing will give you the typehints that you're meant to have.
32
+
33
+ ### Quick Start
34
+ ```python
35
+ from endstone_clans_api import (
36
+ get_clans_api,
37
+ ClanCreateEvent,
38
+ clan_event_handler,
39
+ ClansApi
40
+ )
41
+ from endstone.plugin import Plugin
42
+
43
+ class ExamplePlugin(Plugin):
44
+ def on_enable(self):
45
+ # This will get the plugin, and then the plugin's API.
46
+ api: ClansApi | None = get_clans_api(self.server.plugin_manager)
47
+
48
+ # Since we're doing everything right, there is little-to-no reason that
49
+ # this should return None.
50
+ assert api, "`get_clans_api` returned `None`"
51
+ self.api: ClansApi = api
52
+
53
+ # Just like Endstone's register_events, you can make a sperate listener
54
+ # class in a different module to make everything cleaner.
55
+ self.api.register_events(self)
56
+
57
+ @clan_event_handler
58
+ def on_clan_create(self, event: ClanCreateEvent):
59
+ # Simple test log so you can see that the plugin's API is funcitonal.
60
+ self.logger.info(f"Event: ClanCreateEvent - Name: {event.clan.display_name}, Creator: {event.creator.name}")
61
+
62
+ # Let's say that we wanted to ban spaces from clan names. Just handle it
63
+ # here.
64
+ if " " in event.clan.display_name:
65
+ event.cancel()
66
+ event.creator.send_error_message("Clan names cannot have spaces in them!")
67
+ ```
68
+ I think you've noticed by now that this feels really native to Endstone. Feels like we're re-implementing their design, and that's a good thing--Endstone's design is *exceptional*.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "endstone-clans-api"
7
+ version = "1.0.0"
8
+ description = ""
9
+ authors = [
10
+ { name = "niko-at-chalupa"},
11
+ ]
12
+ dependencies = [
13
+ "endstone",
14
+ "pydantic",
15
+ "ruamel.yaml",
16
+ "xxhash",
17
+ ]
18
+
19
+ [project.entry-points."endstone"]
20
+ clans-api = "endstone_clans_api:ClansApiPlugin"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/endstone_clans_api"]
@@ -0,0 +1,53 @@
1
+ from endstone.plugin import PluginManager
2
+ from .main import ClansApiPlugin
3
+ from .types import Clan
4
+ from .database import Database as ClansDatabase
5
+ from .api import ClansApi
6
+ from .events import (
7
+ ClanEvent,
8
+ ClanCancellableEvent,
9
+ ClanCreateEvent,
10
+ ClanDeleteEvent,
11
+ ClanJoinEvent,
12
+ ClanLeaveEvent,
13
+ ClanKickEvent,
14
+ ClanRenameEvent,
15
+ ClanInviteEvent,
16
+ clan_event_handler,
17
+ )
18
+ from typing import cast
19
+
20
+ def get_clans_api(plugin_manager: PluginManager) -> ClansApi | None:
21
+ """
22
+ Get the ClansApi object the plugin uses. Will return None if an error occours.
23
+
24
+ ## Note: Make sure you call this in on_enable or something similar. Internally, what this returns (ClansApiPlugin._api) is unset until the plugin's on_load() method gets called by the PluginManager
25
+ """
26
+ try:
27
+ plugin = cast(ClansApiPlugin, plugin_manager.get_plugin("ClansApiPlugin"))
28
+ # I don't trust how it returns "Plugin." For all I know, this could return
29
+ # Plugin | None, just like get_player.
30
+ if not plugin:
31
+ return None
32
+
33
+ return plugin.api
34
+ except Exception:
35
+ return None
36
+
37
+ __all__ = [
38
+ "ClansApiPlugin",
39
+ "Clan",
40
+ "ClansDatabase",
41
+ "ClansApi",
42
+ "get_clans_api",
43
+ "ClanEvent",
44
+ "ClanCancellableEvent",
45
+ "ClanCreateEvent",
46
+ "ClanDeleteEvent",
47
+ "ClanJoinEvent",
48
+ "ClanLeaveEvent",
49
+ "ClanKickEvent",
50
+ "ClanRenameEvent",
51
+ "ClanInviteEvent",
52
+ "clan_event_handler",
53
+ ]
@@ -0,0 +1,29 @@
1
+ from .types import Clan
2
+ from .database import Database
3
+ from .events import ClanEventManager, ClanEvent
4
+ from endstone.plugin import PluginManager
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from .main import ClansApiPlugin
9
+
10
+ class ClansApi:
11
+ # I _should_ go for a service, but it's confusing, and services do
12
+ # not provide type hints for the end user's langage server so it's
13
+ # better if I provide it as a function somewhere.
14
+
15
+ def __init__(self, clans_api_plugin: 'ClansApiPlugin'):
16
+ self.plugin = clans_api_plugin
17
+ self._event_manager = ClanEventManager(clans_api_plugin)
18
+
19
+ @property
20
+ def db(self) -> Database:
21
+ return self.plugin.db
22
+
23
+ def register_events(self, listener: Any) -> None:
24
+ """Registers clan event handlers."""
25
+ self._event_manager.register_events(listener)
26
+
27
+ def call_event(self, event: ClanEvent) -> None:
28
+ """Calls clan event handlers."""
29
+ self._event_manager.call_event(event)
@@ -0,0 +1,430 @@
1
+ from endstone import Logger
2
+ from endstone.scheduler import Scheduler
3
+ from endstone.form import MessageForm, ModalForm, Toggle
4
+ from .database import Database
5
+ from abc import ABC
6
+ from typing import TYPE_CHECKING, Callable, TypeVar
7
+ from endstone.command import CommandSender, Command
8
+ from endstone.asyncio import submit
9
+ from endstone import Player
10
+ from typing import Awaitable
11
+ import concurrent.futures
12
+ import time
13
+ import json
14
+ from .events import (
15
+ ClanCreateEvent,
16
+ ClanDeleteEvent,
17
+ ClanJoinEvent,
18
+ ClanLeaveEvent,
19
+ ClanKickEvent,
20
+ ClanRenameEvent,
21
+ ClanInviteEvent,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from .main import ClansConfig, ClansApiPlugin
26
+ from .api import ClansApi
27
+
28
+ _T = TypeVar("_T")
29
+
30
+ class Subcommands(ABC):
31
+ # def a_function(self, sender: CommandSender, command: Command, args: list[str]) -> bool: ...
32
+ # { "a": self.a_function }
33
+ # ^ this is what we're asking for (the dict)
34
+ # This should also accept unbound methods (methods that aren't object.method, but rather method(object)), but
35
+ # we won't be using unbound methods anyways so it doesn't matter
36
+ subcommand_map: dict[str, Callable[[CommandSender, Command, list[str]], bool]]
37
+ plugin: 'ClansApiPlugin'
38
+
39
+ @property
40
+ def config(self) -> 'ClansConfig':
41
+ return self.plugin.config
42
+
43
+ @property
44
+ def messages(self) -> dict[str, str]:
45
+ return self.plugin.config.messages
46
+
47
+ @property
48
+ def db(self) -> Database:
49
+ return self.plugin.db
50
+
51
+ @property
52
+ def scheduler(self) -> Scheduler:
53
+ return self.plugin.server.scheduler
54
+
55
+ @property
56
+ def logger(self) -> Logger:
57
+ return self.plugin.logger
58
+
59
+ def _handle_future_result(self, future) -> None:
60
+ try:
61
+ future.result()
62
+ except Exception as e:
63
+ self.logger.error(str(e))
64
+
65
+ def _submit_and_handle_future_result(self, coro: Awaitable[_T]) -> concurrent.futures.Future[_T]:
66
+ future = submit(coro)
67
+ future.add_done_callback(self._handle_future_result)
68
+ return future
69
+
70
+ class ClansCommands(Subcommands):
71
+ def help(self, sender: CommandSender, command: Command, args: list[str]):
72
+ help_messages = self.plugin.config.help
73
+
74
+ sender.send_message(self.messages.get("help_header", ""))
75
+ for subcommand in self.subcommand_map:
76
+ if subcommand in help_messages:
77
+ description = help_messages.get(subcommand)
78
+ else:
79
+ description = "[no description]"
80
+ sender.send_message(f"{subcommand} - {description}")
81
+
82
+ return True
83
+
84
+ def create(self, sender: CommandSender, command: Command, args: list[str]):
85
+ if not isinstance(sender, Player):
86
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
87
+ return True
88
+
89
+ if len(args) == 0:
90
+ sender.send_error_message(self.messages.get("usage_create", "Usage: /clan create <name: str>"))
91
+ return True
92
+
93
+ clan_name = " ".join(args)
94
+
95
+ async def create_task():
96
+ try:
97
+ xuid = int(sender.xuid)
98
+ member_clan = self.db.get_member_clan(xuid)
99
+ if member_clan:
100
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("already_in_clan", "already in clan")))
101
+ return
102
+
103
+ from .types import _Clan
104
+ temp_clan = _Clan(self.plugin, clan_name, xuid)
105
+ event = ClanCreateEvent(temp_clan, sender)
106
+ self.api.call_event(event)
107
+ if event.cancelled:
108
+ return
109
+
110
+ try:
111
+ self.db.create_clan(clan_name, xuid)
112
+ msg = self.messages.get("clan_created", "clan created")
113
+ msg = msg.replace("[clan_name]", clan_name)
114
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
115
+ except RuntimeError as e:
116
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(str(e)))
117
+ except Exception as e:
118
+ # What is the point
119
+ raise e
120
+
121
+ self._submit_and_handle_future_result(create_task())
122
+ return True
123
+
124
+ def rename(self, sender: CommandSender, command: Command, args: list[str]):
125
+ if not isinstance(sender, Player):
126
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
127
+ return True
128
+
129
+ if len(args) == 0:
130
+ sender.send_error_message(self.messages.get("usage_create", "Usage: /clan rename <name: str>"))
131
+ return True
132
+
133
+ player = sender
134
+
135
+ async def rename_task():
136
+ try:
137
+ xuid = int(player.xuid)
138
+ clan = self.db.get_member_clan(xuid)
139
+
140
+ if not clan:
141
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
142
+ return
143
+
144
+ if clan.owner_xuid != xuid:
145
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_the_owner", "not the owner")))
146
+ return
147
+
148
+ try:
149
+ new_name = " ".join(args)
150
+
151
+ event = ClanRenameEvent(clan, clan.display_name, new_name)
152
+ self.api.call_event(event)
153
+ if event.cancelled:
154
+ return
155
+
156
+ self.db.rename_clan(xuid, new_name)
157
+ msg = self.messages.get("clan_renamed", "clan renamed to [clan_name]")
158
+ msg = msg.replace("[clan_name]", new_name)
159
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
160
+ except RuntimeError as e:
161
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(str(e)))
162
+
163
+ except Exception as e:
164
+ raise e
165
+
166
+ self._submit_and_handle_future_result(rename_task())
167
+ return True
168
+
169
+ def config_command(self, sender: CommandSender, command: Command, args: list[str]):
170
+ if not isinstance(sender, Player):
171
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
172
+ return True
173
+
174
+ player = sender
175
+
176
+ async def config_task():
177
+ xuid = int(player.xuid)
178
+ # Default to "true" if not set
179
+ allow_invites_str = self.db.get_player_preference(xuid, "allow_invites")
180
+ allow_invites = allow_invites_str.lower() == "true" if allow_invites_str else True
181
+
182
+ def on_form_submit(p: Player, data_json: str):
183
+ try:
184
+ data = json.loads(data_json)
185
+ new_allow_invites = data[0]
186
+
187
+ async def save_task():
188
+ self.db.set_player_preference(int(p.xuid), "allow_invites", str(new_allow_invites).lower())
189
+ self.scheduler.run_task(self.plugin, lambda: p.send_message(self.plugin.config.config_form.get("success", "Preferences updated!")))
190
+
191
+ submit(save_task())
192
+ except Exception as e:
193
+ self.logger.error(f"Error handling config form submission: {e}")
194
+
195
+ form = ModalForm(
196
+ title=self.plugin.config.config_form.get("title", "Clan Preferences"),
197
+ on_submit=on_form_submit
198
+ )
199
+ form.add_control(Toggle(
200
+ label=self.plugin.config.config_form.get("allow_invites", "Allow Clan Invitations"),
201
+ default_value=allow_invites
202
+ ))
203
+
204
+ self.scheduler.run_task(self.plugin, lambda: player.send_form(form))
205
+
206
+ submit(config_task())
207
+ return True
208
+
209
+ def invite(self, sender: CommandSender, command: Command, args: list[str]):
210
+ if not isinstance(sender, Player):
211
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
212
+ return True
213
+
214
+ if len(args) == 0:
215
+ sender.send_error_message(self.messages.get("usage_invite", "Usage: /clan invite <player: player>"))
216
+ return True
217
+
218
+ target_name = args[0]
219
+ target = self.plugin.server.get_player(target_name)
220
+ if not target:
221
+ msg = self.messages.get("player_not_found", "Player [player_name] not found.")
222
+ msg = msg.replace("[player_name]", target_name)
223
+ sender.send_error_message(msg)
224
+ return True
225
+
226
+ player = sender
227
+
228
+ async def invite_task():
229
+ try:
230
+ xuid = int(player.xuid)
231
+ clan = self.db.get_member_clan(xuid)
232
+
233
+ if not clan:
234
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
235
+ return
236
+
237
+ if clan.owner_xuid != xuid:
238
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_the_owner", "not the owner")))
239
+ return
240
+
241
+ target_xuid = int(target.xuid)
242
+ target_clan = self.db.get_member_clan(target_xuid)
243
+ if target_clan:
244
+ msg = self.messages.get("player_already_in_clan", "[player_name] is already in a clan.")
245
+ msg = msg.replace("[player_name]", target.name)
246
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
247
+ return
248
+
249
+ allow_invites_str = self.db.get_player_preference(target_xuid, "allow_invites")
250
+ allow_invites = allow_invites_str.lower() == "true" if allow_invites_str else True
251
+ if not allow_invites:
252
+ msg = self.messages.get("privacy_no_invites", "[player_name] does not allow clan invitations.")
253
+ msg = msg.replace("[player_name]", target.name)
254
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
255
+ return
256
+
257
+ event = ClanInviteEvent(clan, player, target)
258
+ self.api.call_event(event)
259
+ if event.cancelled:
260
+ return
261
+
262
+ cooldown_key = (str(xuid), str(target_xuid))
263
+ now = time.time()
264
+ if cooldown_key in self.plugin.invite_cooldowns:
265
+ if now - self.plugin.invite_cooldowns[cooldown_key] < self.plugin.config.invite_cooldown:
266
+ msg = self.messages.get("invite_cooldown", "You must wait before inviting [player_name] again.")
267
+ msg = msg.replace("[player_name]", target.name)
268
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
269
+ return
270
+
271
+ def on_form_submit(p: Player, index: int):
272
+ if index == 0:
273
+ async def accept_task():
274
+ if self.db.get_member_clan(int(p.xuid)):
275
+ return
276
+
277
+ join_event = ClanJoinEvent(clan, p)
278
+ self.api.call_event(join_event)
279
+ if join_event.cancelled:
280
+ return
281
+
282
+ self.db.add_member(clan.owner_xuid, int(p.xuid))
283
+
284
+ def finalize_acceptance():
285
+ msg = self.messages.get("invite_accepted", "You have joined [clan_name]!")
286
+ msg = msg.replace("[clan_name]", clan.display_name)
287
+ p.send_message(msg)
288
+
289
+ inviter = self.plugin.server.get_player(player.name)
290
+ if inviter:
291
+ inviter.send_message(f"{p.name} joined your clan.")
292
+
293
+ self.scheduler.run_task(self.plugin, finalize_acceptance)
294
+
295
+ submit(accept_task())
296
+ else:
297
+ self.plugin.invite_cooldowns[cooldown_key] = time.time()
298
+
299
+ def notify_decline():
300
+ msg = self.messages.get("invite_declined", "[player_name] declined your invitation.")
301
+ msg = msg.replace("[player_name]", p.name)
302
+ inviter = self.plugin.server.get_player(player.name)
303
+ if inviter:
304
+ inviter.send_message(msg)
305
+
306
+ self.scheduler.run_task(self.plugin, notify_decline)
307
+
308
+ form = MessageForm(
309
+ title=self.messages.get("invite_received_title", "Clan Invitation"),
310
+ content=self.messages.get("invite_received_content", "[player_name] invited you to join [clan_name].")
311
+ .replace("[player_name]", player.name)
312
+ .replace("[clan_name]", clan.display_name),
313
+ button1=self.messages.get("invite_yes", "Yes"),
314
+ button2=self.messages.get("invite_no", "No"),
315
+ on_submit=on_form_submit
316
+ )
317
+
318
+ self.scheduler.run_task(self.plugin, lambda: target.send_form(form))
319
+
320
+ msg = self.messages.get("invite_sent", "Invitation sent to [player_name].")
321
+ msg = msg.replace("[player_name]", target.name)
322
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
323
+
324
+ except Exception as e:
325
+ raise e
326
+
327
+ self._submit_and_handle_future_result(invite_task())
328
+ return True
329
+
330
+ def kick(self, sender: CommandSender, command: Command, args: list[str]):
331
+ if not isinstance(sender, Player):
332
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
333
+ return True
334
+
335
+ if len(args) == 0:
336
+ sender.send_error_message(self.messages.get("usage_kick", "Usage: /clan kick <player: player>"))
337
+ return True
338
+
339
+ target_name = args[0]
340
+ target = self.plugin.server.get_player(target_name)
341
+ if not target:
342
+ msg = self.messages.get("player_not_found", "Player [player_name] not found.")
343
+ msg = msg.replace("[player_name]", target_name)
344
+ sender.send_error_message(msg)
345
+ return True
346
+
347
+ async def kick_task():
348
+ xuid = int(sender.xuid)
349
+ clan = self.db.get_clan_by_xuid(xuid)
350
+ target_xuid = int(target.xuid)
351
+
352
+ if not clan or target_xuid not in clan.members_xuids or xuid == target_xuid:
353
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("cannot_kick", "Cannot kick this player.")))
354
+ return
355
+
356
+ event = ClanKickEvent(clan, target, sender)
357
+ self.api.call_event(event)
358
+ if event.cancelled:
359
+ return
360
+
361
+ self.db.remove_member(xuid, target_xuid)
362
+
363
+ def notify():
364
+ msg = self.messages.get("player_kicked", "Kicked [player_name].").replace("[player_name]", target.name)
365
+ sender.send_message(msg)
366
+ target.send_message(self.messages.get("you_were_kicked", "You have been kicked from the clan."))
367
+
368
+ self.scheduler.run_task(self.plugin, notify)
369
+
370
+ self._submit_and_handle_future_result(kick_task())
371
+ return True
372
+
373
+ def leave(self, sender: CommandSender, command: Command, args: list[str]):
374
+ if not isinstance(sender, Player):
375
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
376
+ return True
377
+
378
+ async def leave_task():
379
+ try:
380
+ xuid = int(sender.xuid)
381
+ clan = self.db.get_member_clan(xuid)
382
+
383
+ if not clan:
384
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
385
+ return
386
+
387
+ if clan.owner_xuid == xuid:
388
+ event = ClanDeleteEvent(clan)
389
+ self.api.call_event(event)
390
+ if event.cancelled:
391
+ return
392
+
393
+ self.db.delete_clan(xuid)
394
+ msg = self.messages.get("clan_disbanded", "clan disbanded")
395
+ msg = msg.replace("[clan_name]", clan.display_name)
396
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
397
+ else:
398
+ event = ClanLeaveEvent(clan, sender)
399
+ self.api.call_event(event)
400
+ if event.cancelled:
401
+ return
402
+
403
+ self.db.remove_member(clan.owner_xuid, xuid)
404
+ msg = self.messages.get("clan_left", "clan disbanded")
405
+ msg = msg.replace("[clan_name]", clan.display_name)
406
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
407
+ except Exception as e:
408
+ raise e
409
+
410
+ self._submit_and_handle_future_result(leave_task())
411
+ return True
412
+
413
+ def __init__(self, plugin: 'ClansApiPlugin'):
414
+ self.plugin = plugin
415
+
416
+ self.subcommand_map = {
417
+ "help": self.help,
418
+ "create": self.create,
419
+ "rename": self.rename,
420
+ "config": self.config_command,
421
+ "invite": self.invite,
422
+ "kick": self.kick,
423
+ "leave": self.leave,
424
+ }
425
+
426
+ @property
427
+ def api(self) -> 'ClansApi':
428
+ api = self.plugin.api
429
+ assert api
430
+ return api