Coverage for slidge/command/chat_command.py: 71%
230 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
1# Handle slidge commands by exchanging chat messages with the gateway components.
3# Ad-hoc methods should provide a better UX, but some clients do not support them,
4# so this is mostly a fallback.
5import asyncio
6import inspect
7import logging
8from collections.abc import Awaitable, Callable
9from typing import (
10 TYPE_CHECKING,
11 Any,
12 Literal,
13 Never,
14 ParamSpec,
15 TypeVar,
16 cast,
17 overload,
18)
19from urllib.parse import quote as url_quote
21from slixmpp import JID, CoroutineCallback, Message, StanzaPath
22from slixmpp.exceptions import XMPPError
23from slixmpp.types import JidStr, MessageTypes
25from slidge.command.base import (
26 CommandResponseRecipientType,
27 CommandResponseSessionType,
28 ConfirmationRecipient,
29 ConfirmationSession,
30 FormRecipient,
31 FormSession,
32)
33from slidge.contact import LegacyContact
34from slidge.group import LegacyMUC
35from slidge.util.types import AnyMUC, AnyRecipient, AnySession
37from . import Command, CommandResponseType, Confirmation, Form, TableResult
38from .categories import CommandCategory
40if TYPE_CHECKING:
41 from ..core.gateway import BaseGateway
43T = TypeVar("T")
44P = ParamSpec("P")
47class ChatCommandProvider:
48 UNKNOWN = "Wut? I don't know that command: {}"
49 xmpp: "BaseGateway"
51 def __init__(self, xmpp: "BaseGateway") -> None:
52 self.xmpp = xmpp
53 self._keywords = list[str]()
54 self._commands: dict[str, Command[AnySession]] = {}
55 self._input_futures = dict[str, asyncio.Future[str]]()
56 self.xmpp.register_handler(
57 CoroutineCallback(
58 "chat_command_handler",
59 StanzaPath(f"message@to={self.xmpp.boundjid.bare}"),
60 self._handle_message, # type: ignore
61 )
62 )
64 def register(self, command: Command[AnySession]) -> None:
65 """
66 Register a command to be used via chat messages with the gateway
68 Plugins should not call this, any class subclassing Command should be
69 automatically added by slidge core.
71 :param command: the new command
72 """
73 t = command.CHAT_COMMAND
74 if t in self._commands:
75 raise RuntimeError("There is already a command triggered by '%s'", t)
76 self._commands[t] = command
78 @overload
79 async def input(self, jid: JidStr, text: str | None = None) -> str: ...
81 @overload
82 async def input(
83 self, jid: JidStr, text: str | None = None, *, blocking: Literal[False] = ...
84 ) -> asyncio.Future[str]: ...
86 @overload
87 async def input(
88 self,
89 jid: JidStr,
90 text: str | None = None,
91 *,
92 mtype: MessageTypes = "chat",
93 timeout: int = 60,
94 blocking: Literal[True] = True,
95 **msg_kwargs: Any, # noqa:ANN401
96 ) -> str: ...
98 async def input(
99 self,
100 jid: JidStr,
101 text: str | None = None,
102 *,
103 mtype: MessageTypes = "chat",
104 timeout: int = 60,
105 blocking: bool = True,
106 **msg_kwargs: Any,
107 ) -> str | asyncio.Future[str]:
108 """
109 Request arbitrary user input using a simple chat message, and await the result.
111 You shouldn't need to call directly bust instead use :meth:`.BaseSession.input`
112 to directly target a user.
114 NB: When using this, the next message that the user sent to the component will
115 not be transmitted to :meth:`.BaseGateway.on_gateway_message`, but rather intercepted.
116 Await the coroutine to get its content.
118 :param jid: The JID we want input from
119 :param text: A prompt to display for the user
120 :param mtype: Message type
121 :param timeout:
122 :param blocking: If set to False, timeout has no effect and an :class:`asyncio.Future`
123 is returned instead of a str
124 :return: The user's reply
125 """
126 jid = JID(jid)
127 if text is not None:
128 self.xmpp.send_message(
129 mto=jid,
130 mbody=text,
131 mtype=mtype,
132 mfrom=self.xmpp.boundjid.bare,
133 **msg_kwargs,
134 )
135 f: asyncio.Future[str] = asyncio.get_event_loop().create_future()
136 self._input_futures[jid.bare] = f
137 if not blocking:
138 return f
139 try:
140 await asyncio.wait_for(f, timeout)
141 except TimeoutError:
142 self.xmpp.send_message(
143 mto=jid,
144 mbody="You took too much time to reply",
145 mtype=mtype,
146 mfrom=self.xmpp.boundjid.bare,
147 )
148 del self._input_futures[jid.bare]
149 raise XMPPError("remote-server-timeout", "You took too much time to reply")
151 return f.result()
153 async def _handle_message(self, msg: Message) -> None:
154 if not msg["body"]:
155 return
157 if not msg.get_from().node:
158 return # ignore component and server messages
160 f = self._input_futures.pop(msg.get_from().bare, None)
161 if f is not None:
162 f.set_result(msg["body"])
163 return
165 c = msg["body"]
166 first_word, *rest = c.split(" ")
167 first_word = first_word.lower()
169 if first_word == "help":
170 return self._handle_help(msg, *rest)
172 if first_word in ("contact", "room"):
173 return await self._handle_recipient(first_word, msg, *rest)
175 mfrom = msg.get_from()
177 command = self._commands.get(first_word)
178 if command is None:
179 self._not_found(msg, first_word)
180 return
182 try:
183 session = command.raise_if_not_authorized(mfrom)
184 except XMPPError as e:
185 reply = msg.reply()
186 reply["body"] = e.text
187 reply.send()
188 raise
190 result: CommandResponseSessionType[Any] = await self.__wrap_handler(
191 msg, command.run, session, mfrom, *rest
192 )
193 self.xmpp.delivery_receipt.ack(msg)
194 await self._handle_result(result, msg, session)
196 def __make_uri(self, body: str) -> str:
197 return f"xmpp:{self.xmpp.boundjid.bare}?message;body={body}"
199 async def _handle_result(
200 self,
201 result: CommandResponseSessionType[Any] | CommandResponseRecipientType[Any],
202 msg: Message,
203 session: "AnySession | None",
204 recipient: AnyRecipient | None = None,
205 ) -> CommandResponseSessionType[Any] | CommandResponseRecipientType[Any]:
206 if isinstance(result, str) or result is None:
207 reply = msg.reply()
208 reply["body"] = result or "End of command."
209 reply.send()
210 return None
212 if isinstance(result, Form):
213 if recipient is None:
214 result = cast(FormSession[AnySession], result)
215 else:
216 result = cast(FormRecipient[AnyRecipient], result)
217 try:
218 return await self.__handle_form( # type:ignore[return-value]
219 result, msg, session, recipient=recipient
220 )
221 except XMPPError as e:
222 if (
223 result.timeout_handler is None
224 or e.condition != "remote-server-timeout"
225 ):
226 raise
227 return result.timeout_handler()
229 if isinstance(result, Confirmation):
230 yes_or_no = await self.input(msg.get_from(), result.prompt)
231 if not yes_or_no.lower().startswith("y"):
232 reply = msg.reply()
233 reply["body"] = "Canceled"
234 reply.send()
235 return None
236 if recipient is None:
237 result = cast(ConfirmationSession[AnySession], result)
238 result = await self.__wrap_handler(
239 msg,
240 result.handler,
241 session,
242 msg.get_from(),
243 *result.handler_args,
244 **result.handler_kwargs,
245 )
246 else:
247 result = cast(ConfirmationRecipient[AnyRecipient], result)
248 result = await self.__wrap_handler(
249 msg,
250 result.handler,
251 recipient,
252 *result.handler_args,
253 **result.handler_kwargs,
254 )
255 return await self._handle_result(result, msg, session, recipient=recipient)
257 if isinstance(result, TableResult):
258 if len(result.items) == 0:
259 msg.reply("Empty results").send()
260 return None
262 body = result.description + "\n"
263 for item in result.items:
264 for f in result.fields:
265 if f.type == "jid-single":
266 j = JID(item[f.var])
267 value = f"xmpp:{percent_encode(j)}"
268 if result.jids_are_mucs:
269 value += "?join"
270 else:
271 value = item[f.var] # type:ignore
272 body += f"\n{f.label or f.var}: {value}"
273 msg.reply(body).send()
274 return None
276 raise RuntimeError
278 async def __handle_form(
279 self,
280 result: Form,
281 msg: Message,
282 session: "AnySession | None",
283 recipient: AnyRecipient | None = None,
284 ) -> CommandResponseType:
285 form_values = {}
286 for t in result.title, result.instructions:
287 if t:
288 msg.reply(t).send()
289 for f in result.fields:
290 if f.type == "fixed":
291 msg.reply(f"{f.label or f.var}: {f.value}").send()
292 else:
293 if f.type == "list-multi":
294 msg.reply(
295 "Multiple selection allowed, use new lines as a separator, ie, "
296 "one selected item per line. To select no item, reply with a space "
297 "(the punctuation)."
298 ).send()
299 if f.options:
300 for o in f.options:
301 msg.reply(f"{o['label']}: {self.__make_uri(o['value'])}").send()
302 if f.value:
303 msg.reply(f"Default: {f.value}").send()
304 if f.type == "boolean":
305 msg.reply("yes: " + self.__make_uri("yes")).send()
306 msg.reply("no: " + self.__make_uri("no")).send()
308 ans = await self.xmpp.input(
309 msg.get_from(),
310 (f.label or f.var) + "? (or 'abort')",
311 mtype="chat",
312 )
313 if ans.lower() == "abort":
314 return await self._handle_result("Command aborted", msg, session)
315 if f.type == "boolean":
316 ans = "true" if ans.lower() == "yes" else "false"
318 if f.type.endswith("multi"):
319 choices = [] if ans == " " else ans.split("\n")
320 form_values[f.var] = f.validate(choices)
321 else:
322 form_values[f.var] = f.validate(ans)
323 if recipient is None:
324 new_result = await self.__wrap_handler(
325 msg,
326 result.handler,
327 form_values,
328 session,
329 msg.get_from(),
330 *result.handler_args,
331 **result.handler_kwargs,
332 )
333 new_result = cast(CommandResponseSessionType[Any], new_result)
334 else:
335 new_result = await self.__wrap_handler(
336 msg,
337 result.handler,
338 recipient,
339 form_values,
340 *result.handler_args,
341 **result.handler_kwargs,
342 )
343 new_result = cast(CommandResponseRecipientType[Any], new_result)
345 return await self._handle_result(new_result, msg, session, recipient=recipient)
347 @staticmethod
348 async def __wrap_handler(
349 msg: Message,
350 f: Callable[P, Awaitable[T] | T],
351 *a: P.args,
352 **k: P.kwargs,
353 ) -> T | None:
354 try:
355 if inspect.iscoroutinefunction(f):
356 return await f(*a, **k) # type:ignore[no-any-return]
357 elif hasattr(f, "func") and inspect.iscoroutinefunction(f.func):
358 return await f(*a, **k) # type:ignore[misc,no-any-return]
359 else:
360 return f(*a, **k) # type:ignore[return-value]
361 except Exception as e:
362 log.debug("Error in %s", f, exc_info=e)
363 reply = msg.reply()
364 reply["body"] = f"Error: {e}"
365 reply.send()
366 return None
368 def _handle_help(self, msg: Message, *rest: str) -> None:
369 if len(rest) == 0:
370 reply = msg.reply()
371 reply["body"] = self._help(msg.get_from())
372 reply.send()
373 elif len(rest) == 1 and (command := self._commands.get(rest[0])):
374 reply = msg.reply()
375 reply["body"] = f"{command.CHAT_COMMAND}: {command.NAME}\n{command.HELP}"
376 reply.send()
377 else:
378 self._not_found(msg, str(rest))
380 def _help(self, mfrom: JID) -> str:
381 session = self.xmpp.get_session_from_jid(mfrom)
383 msg = "Available commands:"
384 for c in sorted(
385 self._commands.values(),
386 key=lambda co: (
387 (
388 co.CATEGORY
389 if isinstance(co.CATEGORY, str)
390 else (
391 co.CATEGORY.name
392 if isinstance(co.CATEGORY, CommandCategory)
393 else ""
394 )
395 ),
396 co.CHAT_COMMAND,
397 ),
398 ):
399 try:
400 c.raise_if_not_authorized(mfrom, fetch_session=False, session=session)
401 except XMPPError:
402 continue
403 msg += f"\n{c.CHAT_COMMAND} -- {c.NAME}"
404 return msg
406 def _not_found(self, msg: Message, word: str) -> Never:
407 e = self.UNKNOWN.format(word)
408 msg.reply(e).send()
409 raise XMPPError("item-not-found", e)
411 async def _handle_recipient(
412 self, recipient_str: Literal["contact", "room"], msg: Message, *args: str
413 ) -> None:
414 session = self.xmpp.get_session_from_jid(msg.get_from())
416 recipient_cls = LegacyContact if recipient_str == "contact" else LegacyMUC
418 if session is None:
419 raise XMPPError("subscription-required")
421 if len(args) == 0 or args[0] == "help":
422 self.xmpp.delivery_receipt.ack(msg)
423 self._help_recipient(msg, recipient_cls)
424 return
426 if len(args) == 1:
427 self._help_recipient(msg, recipient_cls)
428 raise XMPPError(
429 "bad-request",
430 f"Contact commands require at least two parameters: {recipient_str}_jid_username and command_name",
431 )
433 jid_username, command_name, *rest = args
435 command = recipient_cls.commands_chat.get(command_name)
436 if command is None:
437 raise XMPPError("item-not-found")
439 if recipient_cls is LegacyContact:
440 legacy_id = await session.contacts.jid_username_to_legacy_id(jid_username)
441 recipient = await session.contacts.by_legacy_id(legacy_id)
442 else:
443 legacy_id = await session.bookmarks.jid_username_to_legacy_id(jid_username)
444 recipient = await session.bookmarks.by_legacy_id(legacy_id)
446 result = await self.__wrap_handler(msg, command.run, recipient, *rest) # type:ignore[arg-type,func-returns-value]
447 self.xmpp.delivery_receipt.ack(msg)
448 await self._handle_result(result, msg, session, recipient)
450 def _help_recipient(
451 self, msg: Message, recipient_cls: "type[LegacyContact | AnyMUC]"
452 ) -> None:
453 msg.reply(
454 "Available commands:\n"
455 + "\n".join(
456 f"{co.CHAT_COMMAND} ({co.NAME}): {co.HELP}"
457 for co in recipient_cls.commands_chat.values()
458 )
459 ).send()
462def percent_encode(jid: JID) -> str:
463 return f"{url_quote(jid.user)}@{jid.server}"
466log = logging.getLogger(__name__)