Skip to content

Commit e12ab1f

Browse files
authored
Merge pull request #35 from lbartoletti/feature/slash_commands
feat: Add Discord-style slash commands to QChat
2 parents 2814da3 + 558da97 commit e12ab1f

File tree

2 files changed

+307
-1
lines changed

2 files changed

+307
-1
lines changed

qchat/gui/dck_qchat.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
from qgis.core import Qgis, QgsApplication, QgsJsonExporter, QgsMapLayer, QgsProject
1414
from qgis.gui import QgisInterface, QgsDockWidget
1515
from qgis.PyQt import uic
16-
from qgis.PyQt.QtCore import QPoint, Qt
16+
from qgis.PyQt.QtCore import QPoint, Qt, QTimer
1717
from qgis.PyQt.QtGui import QCursor, QIcon
1818
from qgis.PyQt.QtWidgets import (
1919
QAction,
20+
QCompleter,
2021
QFileDialog,
2122
QMenu,
2223
QMessageBox,
@@ -69,6 +70,7 @@
6970
QChatUncompliantMessage,
7071
)
7172
from qchat.logic.qchat_websocket import QChatWebsocket
73+
from qchat.logic.slash_commands import SlashCommandHandler
7274
from qchat.tasks.dizzy import DizzyTask
7375
from qchat.toolbelt import PlgLogger, PlgOptionsManager
7476
from qchat.toolbelt.commons import open_url_in_browser, play_resource_sound
@@ -107,6 +109,21 @@ def __init__(
107109
self.plg_settings = PlgOptionsManager()
108110
uic.loadUi(Path(__file__).parent / f"{Path(__file__).stem}.ui", self)
109111

112+
# Initialize slash commands handler
113+
self.slash_command_handler = SlashCommandHandler()
114+
115+
# Setup autocomplete for slash commands
116+
self.command_completer = QCompleter(
117+
self.slash_command_handler.get_command_list()
118+
)
119+
self.command_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
120+
self.command_completer.setCompletionMode(
121+
QCompleter.CompletionMode.PopupCompletion
122+
)
123+
self.lne_message.setCompleter(self.command_completer)
124+
# Connect to activated signal to handle completion selection
125+
self.command_completer.activated.connect(self.on_command_activated)
126+
110127
# set channel to autoreconnect to when widget will open
111128
self.auto_reconnect_channel = auto_reconnect_channel
112129

@@ -830,6 +847,11 @@ def on_send_button_clicked(self) -> None:
830847
Action called when the send button is clicked
831848
"""
832849

850+
# If the completer popup is visible, ignore this call
851+
# The activated signal will handle it instead
852+
if self.command_completer.popup().isVisible():
853+
return
854+
833855
# retrieve nickname and message
834856
nickname = self.settings.nickname
835857
avatar = self.settings.avatar
@@ -864,6 +886,38 @@ def on_send_button_clicked(self) -> None:
864886
if not message_text:
865887
return
866888

889+
# Check if message is a slash command
890+
if self.slash_command_handler.is_command(message_text):
891+
result = self.slash_command_handler.execute(message_text)
892+
893+
if not result.success:
894+
# Show error
895+
self.log(
896+
message=result.error or self.tr("Error executing command"),
897+
log_level=Qgis.MessageLevel.Warning,
898+
push=True,
899+
duration=3,
900+
)
901+
self.lne_message.setText("")
902+
return
903+
904+
# Clear input
905+
self.lne_message.setText("")
906+
907+
# If there's a local action, execute it
908+
if result.local_action:
909+
action_result = result.local_action()
910+
if action_result and action_result[0] == "show_message":
911+
# Show message locally via QMessageBox
912+
QMessageBox.information(self, self.tr("QChat"), action_result[1])
913+
return
914+
915+
# If there's a message text, send it to chat
916+
if result.message_text:
917+
message_text = result.message_text
918+
else:
919+
return
920+
867921
# send message to websocket
868922
message = QChatTextMessage(
869923
type=QCHAT_MESSAGE_TYPE_TEXT,
@@ -876,6 +930,15 @@ def on_send_button_clicked(self) -> None:
876930
self.qchat_ws.send_message(message)
877931
self.lne_message.setText("")
878932

933+
def on_command_activated(self, completion: str) -> None:
934+
"""
935+
Handle when user selects a completion from QCompleter.
936+
This is called when the user presses Enter while the completion popup is active.
937+
We defer the send action slightly to ensure QCompleter finishes its text update.
938+
"""
939+
# Use QTimer.singleShot to defer the action, allowing QCompleter to finish
940+
QTimer.singleShot(0, self.on_send_button_clicked)
941+
879942
def on_send_image_button_clicked(self) -> None:
880943
"""
881944
Action called when the send image button is clicked

qchat/logic/slash_commands.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""Slash commands handler for QChat.
2+
3+
Provides Discord-style slash commands for fun interactions and utilities.
4+
"""
5+
6+
import random
7+
from dataclasses import dataclass
8+
from typing import Callable, Optional
9+
10+
11+
@dataclass
12+
class SlashCommandResult:
13+
"""Result of a slash command execution."""
14+
15+
success: bool
16+
message_text: Optional[str] = None # Text to send to chat
17+
local_action: Optional[Callable] = None # Local action to execute
18+
error: Optional[str] = None
19+
20+
21+
class SlashCommandHandler:
22+
"""Handler for slash commands in QChat."""
23+
24+
# Magic 8-ball responses (classic answers)
25+
EIGHT_BALL_RESPONSES = [
26+
"It is certain",
27+
"It is decidedly so",
28+
"Without a doubt",
29+
"Yes definitely",
30+
"You may rely on it",
31+
"As I see it, yes",
32+
"Most likely",
33+
"Outlook good",
34+
"Yes",
35+
"Signs point to yes",
36+
"Reply hazy, try again",
37+
"Ask again later",
38+
"Better not tell you now",
39+
"Cannot predict now",
40+
"Concentrate and ask again",
41+
"Don't count on it",
42+
"My reply is no",
43+
"My sources say no",
44+
"Outlook not so good",
45+
"Very doubtful",
46+
]
47+
48+
# Command descriptions for help/autocomplete
49+
COMMAND_DESCRIPTIONS = {
50+
"list": "List all available commands",
51+
"shrug": "Send ¯\\_(ツ)_/¯",
52+
"tableflip": "Send (╯°□°)╯︵ ┻━┻",
53+
"lenny": "Send ( ͡° ͜ʖ ͡°)",
54+
"yolo": "Convert message to UPPERCASE + 🎲",
55+
"flip": "Flip a coin (heads or tails)",
56+
"roll": "Roll dice (e.g. /roll 2d20)",
57+
"8ball": "Ask the magic 8-ball",
58+
}
59+
60+
def __init__(self):
61+
"""Initialize the slash command handler."""
62+
self.commands = {
63+
"list": self.cmd_list,
64+
"shrug": self.cmd_shrug,
65+
"tableflip": self.cmd_tableflip,
66+
"lenny": self.cmd_lenny,
67+
"yolo": self.cmd_yolo,
68+
"flip": self.cmd_flip,
69+
"roll": self.cmd_roll,
70+
"8ball": self.cmd_8ball,
71+
}
72+
73+
def get_command_list(self) -> list[str]:
74+
"""Get list of available commands (for autocomplete).
75+
76+
:return: list of command names with leading /
77+
"""
78+
return [f"/{cmd}" for cmd in sorted(self.commands.keys())]
79+
80+
def is_command(self, text: str) -> bool:
81+
"""Check if the text is a slash command.
82+
83+
:param text: text to check
84+
:return: True if text starts with /
85+
"""
86+
return text.strip().startswith("/")
87+
88+
def execute(self, text: str) -> SlashCommandResult:
89+
"""Execute a slash command.
90+
91+
:param text: command text (e.g., "/shrug" or "/roll")
92+
:return: SlashCommandResult with the result
93+
"""
94+
text = text.strip()
95+
if not self.is_command(text):
96+
return SlashCommandResult(success=False, error="Not a command")
97+
98+
# Parse command and arguments
99+
parts = text[1:].split(maxsplit=1) # Remove leading /
100+
command = parts[0].lower()
101+
args = parts[1] if len(parts) > 1 else ""
102+
103+
# Execute command
104+
if command in self.commands:
105+
return self.commands[command](args)
106+
else:
107+
return SlashCommandResult(
108+
success=False,
109+
error=f"Unknown command: /{command}",
110+
)
111+
112+
def cmd_shrug(self, args: str) -> SlashCommandResult:
113+
"""Shrug command: ¯\\_(ツ)_/¯
114+
115+
:param args: optional message after shrug
116+
:return: SlashCommandResult
117+
"""
118+
message = \\_(ツ)_/¯"
119+
if args:
120+
message = f"{message} {args}"
121+
return SlashCommandResult(success=True, message_text=message)
122+
123+
def cmd_tableflip(self, args: str) -> SlashCommandResult:
124+
"""Table flip command: (╯°□°)╯︵ ┻━┻
125+
126+
:param args: optional message after tableflip
127+
:return: SlashCommandResult
128+
"""
129+
message = "(╯°□°)╯︵ ┻━┻"
130+
if args:
131+
message = f"{message} {args}"
132+
return SlashCommandResult(success=True, message_text=message)
133+
134+
def cmd_lenny(self, args: str) -> SlashCommandResult:
135+
"""Lenny face command: ( ͡° ͜ʖ ͡°)
136+
137+
:param args: optional message after lenny
138+
:return: SlashCommandResult
139+
"""
140+
message = "( ͡° ͜ʖ ͡°)"
141+
if args:
142+
message = f"{message} {args}"
143+
return SlashCommandResult(success=True, message_text=message)
144+
145+
def cmd_yolo(self, args: str) -> SlashCommandResult:
146+
"""YOLO command: converts message to CAPS + emoji
147+
148+
:param args: message to yolo-fy
149+
:return: SlashCommandResult
150+
"""
151+
if not args:
152+
message = "YOLO! 🎲"
153+
else:
154+
message = f"{args.upper()} 🎲"
155+
return SlashCommandResult(success=True, message_text=message)
156+
157+
def cmd_flip(self, args: str) -> SlashCommandResult:
158+
"""Coin flip command: heads or tails
159+
160+
:param args: unused
161+
:return: SlashCommandResult
162+
"""
163+
result = random.choice(["Heads", "Tails"])
164+
emoji = "🪙"
165+
message = f"{emoji} {result}!"
166+
return SlashCommandResult(success=True, message_text=message)
167+
168+
def cmd_roll(self, args: str) -> SlashCommandResult:
169+
"""Dice roll command: 1-6
170+
171+
:param args: optional dice specification (e.g., "2d20" for 2 dice of 20 sides)
172+
:return: SlashCommandResult
173+
"""
174+
# Parse args for custom dice (e.g., "2d20")
175+
num_dice = 1
176+
num_sides = 6
177+
178+
if args:
179+
try:
180+
if "d" in args.lower():
181+
parts = args.lower().split("d")
182+
num_dice = int(parts[0]) if parts[0] else 1
183+
num_sides = int(parts[1]) if parts[1] else 6
184+
else:
185+
num_sides = int(args)
186+
except (ValueError, IndexError):
187+
return SlashCommandResult(
188+
success=False,
189+
error="Invalid format. Use /roll [faces] or /roll [number]d[faces]",
190+
)
191+
192+
# Validate
193+
if num_dice < 1 or num_dice > 100:
194+
return SlashCommandResult(
195+
success=False, error="Invalid number of dice [1-100]"
196+
)
197+
if num_sides < 2 or num_sides > 1000:
198+
return SlashCommandResult(
199+
success=False, error="Invalid number of sides (2-1000)"
200+
)
201+
202+
# Roll
203+
rolls = [random.randint(1, num_sides) for _ in range(num_dice)]
204+
total = sum(rolls)
205+
206+
if num_dice == 1:
207+
message = f"🎲 {rolls[0]}"
208+
else:
209+
rolls_str = ", ".join(str(r) for r in rolls)
210+
message = f"🎲 {rolls_str} (total: {total})"
211+
212+
return SlashCommandResult(success=True, message_text=message)
213+
214+
def cmd_8ball(self, args: str) -> SlashCommandResult:
215+
"""Magic 8-ball command: random answer
216+
217+
:param args: the question (optional)
218+
:return: SlashCommandResult
219+
"""
220+
answer = random.choice(self.EIGHT_BALL_RESPONSES)
221+
emoji = "🎱"
222+
message = f"{emoji} {answer}"
223+
return SlashCommandResult(success=True, message_text=message)
224+
225+
def cmd_list(self, args: str) -> SlashCommandResult:
226+
"""List all available commands.
227+
228+
:param args: unused
229+
:return: SlashCommandResult (local action, not sent to chat)
230+
"""
231+
# Build command list with descriptions
232+
lines = ["📋 Available commands:"]
233+
for cmd in sorted(self.commands.keys()):
234+
desc = self.COMMAND_DESCRIPTIONS.get(cmd, "")
235+
lines.append(f" /{cmd} - {desc}")
236+
237+
message = "\n".join(lines)
238+
239+
# Return as local action (display locally, don't send to chat)
240+
return SlashCommandResult(
241+
success=True,
242+
local_action=lambda: ("show_message", message),
243+
)

0 commit comments

Comments
 (0)