Coverage for integrations / channels / commands / detection.py: 30.5%
131 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Command Detection
4Provides command detection in text messages.
5Ported from HevolveBot's command detection pattern.
6"""
8from dataclasses import dataclass, field
9from typing import List, Optional, Set, Tuple, Pattern
10import re
11import logging
13from .registry import CommandRegistry, CommandDefinition, get_command_registry
15logger = logging.getLogger(__name__)
18@dataclass
19class DetectedCommand:
20 """
21 Result of detecting a command in text.
23 Attributes:
24 command: The detected command definition
25 raw_command: Raw command text as typed
26 args: Arguments after the command
27 prefix: The prefix used (e.g., "/" or "!")
28 full_match: Full matched text
29 start_pos: Start position in original text
30 end_pos: End position in original text
31 """
32 command: CommandDefinition
33 raw_command: str
34 args: Optional[str] = None
35 prefix: str = "/"
36 full_match: str = ""
37 start_pos: int = 0
38 end_pos: int = 0
40 @property
41 def has_args(self) -> bool:
42 """Check if command has arguments."""
43 return bool(self.args and self.args.strip())
46@dataclass
47class CommandDetectorConfig:
48 """
49 Configuration for command detection.
51 Attributes:
52 prefixes: Valid command prefixes (default: ["/", "!"])
53 allow_inline: Allow commands mid-message (default: False)
54 case_sensitive: Case-sensitive matching (default: False)
55 normalize_colons: Treat "/cmd:" like "/cmd " (default: True)
56 strip_bot_mention: Remove @botname from commands (default: True)
57 bot_username: Bot username to strip from commands
58 """
59 prefixes: List[str] = field(default_factory=lambda: ["/", "!"])
60 allow_inline: bool = False
61 case_sensitive: bool = False
62 normalize_colons: bool = True
63 strip_bot_mention: bool = True
64 bot_username: Optional[str] = None
67class CommandDetector:
68 """
69 Detects commands in text messages.
71 Features:
72 - Multiple prefix support (/, !)
73 - Alias resolution
74 - Argument extraction
75 - Bot mention handling
76 - Colon normalization (/cmd: args)
77 """
79 def __init__(
80 self,
81 registry: Optional[CommandRegistry] = None,
82 config: Optional[CommandDetectorConfig] = None,
83 ):
84 self.registry = registry or get_command_registry()
85 self.config = config or CommandDetectorConfig()
86 self._pattern: Optional[Pattern] = None
87 self._pattern_valid = False
89 def _build_pattern(self) -> Pattern:
90 """Build regex pattern for command detection."""
91 # Escape prefixes for regex
92 escaped_prefixes = [re.escape(p) for p in self.config.prefixes]
93 prefix_pattern = f"[{''.join(escaped_prefixes)}]"
95 # Build pattern components
96 # Command: prefix + word characters (letters, numbers, underscore, hyphen)
97 # Args: optional whitespace or colon followed by remaining text
98 pattern = rf"^({prefix_pattern})([a-zA-Z][a-zA-Z0-9_-]*)(?:[@]([^\s]+))?(?:[\s:]+(.*))?$"
100 flags = 0 if self.config.case_sensitive else re.IGNORECASE
101 return re.compile(pattern, flags | re.DOTALL)
103 def _get_pattern(self) -> Pattern:
104 """Get or build the detection pattern."""
105 if self._pattern is None or not self._pattern_valid:
106 self._pattern = self._build_pattern()
107 self._pattern_valid = True
108 return self._pattern
110 def invalidate_pattern(self) -> None:
111 """Invalidate cached pattern (call when config changes)."""
112 self._pattern_valid = False
114 def detect(self, text: str) -> Optional[DetectedCommand]:
115 """
116 Detect a command in text.
118 Args:
119 text: Text to check for commands
121 Returns:
122 DetectedCommand if found, None otherwise
123 """
124 if not text:
125 return None
127 text = text.strip()
128 if not text:
129 return None
131 # Check if text starts with a valid prefix
132 if not any(text.startswith(p) for p in self.config.prefixes):
133 return None
135 # Handle multi-line: only look at first line
136 first_line = text.split("\n")[0].strip()
138 # Match against pattern
139 pattern = self._get_pattern()
140 match = pattern.match(first_line)
142 if not match:
143 return None
145 prefix = match.group(1)
146 command_name = match.group(2)
147 bot_mention = match.group(3)
148 args = match.group(4)
150 # Handle bot mention
151 if bot_mention and self.config.strip_bot_mention:
152 if self.config.bot_username:
153 normalized_mention = bot_mention.lower()
154 normalized_bot = self.config.bot_username.lower()
155 if normalized_mention != normalized_bot:
156 # Not our bot, ignore command
157 return None
159 # Normalize command name for lookup
160 lookup_name = command_name if self.config.case_sensitive else command_name.lower()
162 # Try to find command in registry
163 command = self.registry.get_by_alias(f"{prefix}{lookup_name}")
165 if not command:
166 # Try without prefix (some aliases might not include it)
167 command = self.registry.get_by_alias(lookup_name)
169 if not command:
170 return None
172 # Check if command is enabled
173 if not command.enabled:
174 return None
176 # Clean up args
177 if args:
178 args = args.strip()
179 if not args:
180 args = None
182 # Check if command accepts args
183 if args and not command.accepts_args:
184 # Command doesn't accept args, ignore
185 return None
187 return DetectedCommand(
188 command=command,
189 raw_command=f"{prefix}{command_name}",
190 args=args,
191 prefix=prefix,
192 full_match=match.group(0),
193 start_pos=0,
194 end_pos=len(match.group(0)),
195 )
197 def is_command(self, text: str) -> bool:
198 """
199 Check if text is a command.
201 Args:
202 text: Text to check
204 Returns:
205 True if text is a valid command
206 """
207 return self.detect(text) is not None
209 def extract_command_name(self, text: str) -> Optional[str]:
210 """
211 Extract command name from text without full detection.
213 Args:
214 text: Text to extract from
216 Returns:
217 Command name if found, None otherwise
218 """
219 if not text:
220 return None
222 text = text.strip()
224 # Check prefix
225 if not any(text.startswith(p) for p in self.config.prefixes):
226 return None
228 # Extract first word after prefix
229 first_line = text.split("\n")[0].strip()
231 # Match command pattern
232 for prefix in self.config.prefixes:
233 if first_line.startswith(prefix):
234 remaining = first_line[len(prefix):]
235 # Get command name (up to space, colon, or @)
236 match = re.match(r"([a-zA-Z][a-zA-Z0-9_-]*)", remaining)
237 if match:
238 return match.group(1).lower()
240 return None
242 def normalize_command_text(self, text: str) -> str:
243 """
244 Normalize command text.
246 - Strips leading/trailing whitespace
247 - Normalizes colons to spaces
248 - Strips bot mentions if configured
250 Args:
251 text: Text to normalize
253 Returns:
254 Normalized text
255 """
256 if not text:
257 return text
259 text = text.strip()
261 # Handle colon normalization
262 if self.config.normalize_colons:
263 # Replace /cmd: with /cmd
264 for prefix in self.config.prefixes:
265 # Match /command: or /command:args
266 colon_pattern = rf"^({re.escape(prefix)}[a-zA-Z][a-zA-Z0-9_-]*)\s*:\s*"
267 text = re.sub(colon_pattern, r"\1 ", text, flags=re.IGNORECASE)
269 # Handle bot mention stripping
270 if self.config.strip_bot_mention and self.config.bot_username:
271 # Remove @botname from /cmd@botname
272 for prefix in self.config.prefixes:
273 mention_pattern = rf"^({re.escape(prefix)}[a-zA-Z][a-zA-Z0-9_-]*)@{re.escape(self.config.bot_username)}"
274 text = re.sub(mention_pattern, r"\1", text, flags=re.IGNORECASE)
276 return text.strip()
278 def list_prefixes(self) -> List[str]:
279 """Get configured prefixes."""
280 return list(self.config.prefixes)
282 def add_prefix(self, prefix: str) -> None:
283 """Add a command prefix."""
284 if prefix not in self.config.prefixes:
285 self.config.prefixes.append(prefix)
286 self.invalidate_pattern()
288 def remove_prefix(self, prefix: str) -> bool:
289 """Remove a command prefix."""
290 if prefix in self.config.prefixes and len(self.config.prefixes) > 1:
291 self.config.prefixes.remove(prefix)
292 self.invalidate_pattern()
293 return True
294 return False
297# Global detector instance
298_global_detector: Optional[CommandDetector] = None
301def get_command_detector() -> CommandDetector:
302 """Get the global command detector."""
303 global _global_detector
304 if _global_detector is None:
305 _global_detector = CommandDetector()
306 return _global_detector
309def reset_command_detector() -> None:
310 """Reset the global command detector."""
311 global _global_detector
312 _global_detector = None