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

1""" 

2Command Detection 

3 

4Provides command detection in text messages. 

5Ported from HevolveBot's command detection pattern. 

6""" 

7 

8from dataclasses import dataclass, field 

9from typing import List, Optional, Set, Tuple, Pattern 

10import re 

11import logging 

12 

13from .registry import CommandRegistry, CommandDefinition, get_command_registry 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18@dataclass 

19class DetectedCommand: 

20 """ 

21 Result of detecting a command in text. 

22 

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 

39 

40 @property 

41 def has_args(self) -> bool: 

42 """Check if command has arguments.""" 

43 return bool(self.args and self.args.strip()) 

44 

45 

46@dataclass 

47class CommandDetectorConfig: 

48 """ 

49 Configuration for command detection. 

50 

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 

65 

66 

67class CommandDetector: 

68 """ 

69 Detects commands in text messages. 

70 

71 Features: 

72 - Multiple prefix support (/, !) 

73 - Alias resolution 

74 - Argument extraction 

75 - Bot mention handling 

76 - Colon normalization (/cmd: args) 

77 """ 

78 

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 

88 

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)}]" 

94 

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:]+(.*))?$" 

99 

100 flags = 0 if self.config.case_sensitive else re.IGNORECASE 

101 return re.compile(pattern, flags | re.DOTALL) 

102 

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 

109 

110 def invalidate_pattern(self) -> None: 

111 """Invalidate cached pattern (call when config changes).""" 

112 self._pattern_valid = False 

113 

114 def detect(self, text: str) -> Optional[DetectedCommand]: 

115 """ 

116 Detect a command in text. 

117 

118 Args: 

119 text: Text to check for commands 

120 

121 Returns: 

122 DetectedCommand if found, None otherwise 

123 """ 

124 if not text: 

125 return None 

126 

127 text = text.strip() 

128 if not text: 

129 return None 

130 

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 

134 

135 # Handle multi-line: only look at first line 

136 first_line = text.split("\n")[0].strip() 

137 

138 # Match against pattern 

139 pattern = self._get_pattern() 

140 match = pattern.match(first_line) 

141 

142 if not match: 

143 return None 

144 

145 prefix = match.group(1) 

146 command_name = match.group(2) 

147 bot_mention = match.group(3) 

148 args = match.group(4) 

149 

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 

158 

159 # Normalize command name for lookup 

160 lookup_name = command_name if self.config.case_sensitive else command_name.lower() 

161 

162 # Try to find command in registry 

163 command = self.registry.get_by_alias(f"{prefix}{lookup_name}") 

164 

165 if not command: 

166 # Try without prefix (some aliases might not include it) 

167 command = self.registry.get_by_alias(lookup_name) 

168 

169 if not command: 

170 return None 

171 

172 # Check if command is enabled 

173 if not command.enabled: 

174 return None 

175 

176 # Clean up args 

177 if args: 

178 args = args.strip() 

179 if not args: 

180 args = None 

181 

182 # Check if command accepts args 

183 if args and not command.accepts_args: 

184 # Command doesn't accept args, ignore 

185 return None 

186 

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 ) 

196 

197 def is_command(self, text: str) -> bool: 

198 """ 

199 Check if text is a command. 

200 

201 Args: 

202 text: Text to check 

203 

204 Returns: 

205 True if text is a valid command 

206 """ 

207 return self.detect(text) is not None 

208 

209 def extract_command_name(self, text: str) -> Optional[str]: 

210 """ 

211 Extract command name from text without full detection. 

212 

213 Args: 

214 text: Text to extract from 

215 

216 Returns: 

217 Command name if found, None otherwise 

218 """ 

219 if not text: 

220 return None 

221 

222 text = text.strip() 

223 

224 # Check prefix 

225 if not any(text.startswith(p) for p in self.config.prefixes): 

226 return None 

227 

228 # Extract first word after prefix 

229 first_line = text.split("\n")[0].strip() 

230 

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() 

239 

240 return None 

241 

242 def normalize_command_text(self, text: str) -> str: 

243 """ 

244 Normalize command text. 

245 

246 - Strips leading/trailing whitespace 

247 - Normalizes colons to spaces 

248 - Strips bot mentions if configured 

249 

250 Args: 

251 text: Text to normalize 

252 

253 Returns: 

254 Normalized text 

255 """ 

256 if not text: 

257 return text 

258 

259 text = text.strip() 

260 

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) 

268 

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) 

275 

276 return text.strip() 

277 

278 def list_prefixes(self) -> List[str]: 

279 """Get configured prefixes.""" 

280 return list(self.config.prefixes) 

281 

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() 

287 

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 

295 

296 

297# Global detector instance 

298_global_detector: Optional[CommandDetector] = None 

299 

300 

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 

307 

308 

309def reset_command_detector() -> None: 

310 """Reset the global command detector.""" 

311 global _global_detector 

312 _global_detector = None