|
32 | 32 | except ImportError: |
33 | 33 | pass |
34 | 34 |
|
35 | | -from core import checks |
36 | 35 | from core.changelog import Changelog |
37 | 36 | from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient |
38 | 37 | from core.config import ConfigManager |
39 | 38 | from core.models import ( |
40 | 39 | DMDisabled, |
| 40 | + DummyMessage, |
41 | 41 | HostingMethod, |
42 | 42 | InvalidConfigError, |
43 | 43 | PermissionLevel, |
44 | 44 | SafeFormatter, |
| 45 | + UnseenFormatter, |
45 | 46 | configure_logging, |
46 | 47 | getLogger, |
47 | 48 | ) |
|
59 | 60 |
|
60 | 61 | logger = getLogger(__name__) |
61 | 62 |
|
| 63 | + |
| 64 | +# Automation callbacks are recipient-triggered, even though their text is |
| 65 | +# configured by staff. Keep this list deliberately small and dispatch these |
| 66 | +# actions through Thread.reply instead of the command framework. |
| 67 | +AUTOMATION_REPLY_COMMANDS = { |
| 68 | + "reply": (False, False, False), |
| 69 | + "freply": (True, False, False), |
| 70 | + "fareply": (True, True, False), |
| 71 | + "fpreply": (True, False, True), |
| 72 | + "fpareply": (True, True, True), |
| 73 | + "areply": (False, True, False), |
| 74 | + "preply": (False, False, True), |
| 75 | + "pareply": (False, True, True), |
| 76 | +} |
| 77 | + |
62 | 78 | temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") |
63 | 79 | if not os.path.exists(temp_dir): |
64 | 80 | os.mkdir(temp_dir) |
@@ -1363,59 +1379,192 @@ async def get_contexts(self, message, *, cls=commands.Context): |
1363 | 1379 |
|
1364 | 1380 | return [ctx] |
1365 | 1381 |
|
1366 | | - async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): |
1367 | | - message.author = self.modmail_guild.me |
1368 | | - message.channel = channel |
1369 | | - message.guild = channel.guild |
| 1382 | + def resolve_automation_callback( |
| 1383 | + self, callback: str |
| 1384 | + ) -> typing.Optional[typing.List[typing.Tuple[str, str]]]: |
| 1385 | + """Resolve a configured callback into explicitly supported thread actions. |
1370 | 1386 |
|
1371 | | - view = StringView(message.content) |
1372 | | - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) |
1373 | | - thread = await self.threads.find(channel=ctx.channel) |
| 1387 | + Snippets and aliases remain supported, but aliases are expanded and every |
| 1388 | + resulting action is checked each time it runs. This prevents a safe alias |
| 1389 | + from being retargeted to a privileged command after configuration. |
| 1390 | + """ |
| 1391 | + if not isinstance(callback, str): |
| 1392 | + return None |
1374 | 1393 |
|
1375 | | - invoked_prefix = self.prefix |
1376 | | - invoker = None |
| 1394 | + actions: typing.List[typing.Tuple[str, str]] = [] |
1377 | 1395 |
|
1378 | | - if self.config.get("use_regex_autotrigger"): |
1379 | | - trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) |
1380 | | - if trigger: |
1381 | | - invoker = re.search(trigger, message.content).group(0) |
1382 | | - else: |
1383 | | - trigger = next( |
1384 | | - filter( |
1385 | | - lambda x: x.lower() in message.content.lower(), |
1386 | | - self.auto_triggers.keys(), |
1387 | | - ) |
| 1396 | + def resolve_invocation(invocation: str, seen_aliases: typing.FrozenSet[str]) -> bool: |
| 1397 | + invocation = invocation.strip() |
| 1398 | + if not invocation: |
| 1399 | + return False |
| 1400 | + |
| 1401 | + if invocation in self.snippets: |
| 1402 | + command = self._get_snippet_command() |
| 1403 | + command_name = getattr(command, "qualified_name", None) |
| 1404 | + snippet = self.snippets[invocation] |
| 1405 | + if command_name not in AUTOMATION_REPLY_COMMANDS or not isinstance(snippet, str): |
| 1406 | + return False |
| 1407 | + actions.append((command_name, snippet)) |
| 1408 | + return len(actions) <= 25 |
| 1409 | + |
| 1410 | + parts = invocation.split(maxsplit=1) |
| 1411 | + invoker = parts[0].lower() |
| 1412 | + arguments = parts[1] if len(parts) == 2 else "" |
| 1413 | + |
| 1414 | + alias = self.aliases.get(invoker) |
| 1415 | + if alias is not None: |
| 1416 | + if not isinstance(alias, str) or invoker in seen_aliases or len(seen_aliases) >= 25: |
| 1417 | + return False |
| 1418 | + expanded = normalize_alias(alias, arguments) |
| 1419 | + if not expanded: |
| 1420 | + return False |
| 1421 | + next_seen = seen_aliases | {invoker} |
| 1422 | + return all(resolve_invocation(value, next_seen) for value in expanded) |
| 1423 | + |
| 1424 | + command = self.all_commands.get(invoker) |
| 1425 | + command_name = getattr(command, "qualified_name", None) |
| 1426 | + if command_name not in AUTOMATION_REPLY_COMMANDS: |
| 1427 | + return False |
| 1428 | + |
| 1429 | + actions.append((command_name, arguments)) |
| 1430 | + return len(actions) <= 25 |
| 1431 | + |
| 1432 | + invocations = normalize_alias(callback) |
| 1433 | + if not invocations or not all(resolve_invocation(value, frozenset()) for value in invocations): |
| 1434 | + return None |
| 1435 | + return actions |
| 1436 | + |
| 1437 | + def is_automation_callback_safe(self, callback: str) -> bool: |
| 1438 | + """Return whether a callback resolves only to supported automation actions.""" |
| 1439 | + return self.resolve_automation_callback(callback) is not None |
| 1440 | + |
| 1441 | + @staticmethod |
| 1442 | + def _copy_automation_message(message): |
| 1443 | + """Copy a source message without carrying recipient-provided payloads into a reply.""" |
| 1444 | + while isinstance(message, DummyMessage): |
| 1445 | + message = message._message |
| 1446 | + if message is None: |
| 1447 | + return None |
| 1448 | + message = DummyMessage(copy.copy(message)) |
| 1449 | + message.attachments = [] |
| 1450 | + message.embeds = [] |
| 1451 | + message.stickers = [] |
| 1452 | + message.message_snapshots = [] |
| 1453 | + return message |
| 1454 | + |
| 1455 | + def _format_automation_content(self, command_name: str, content: str, thread) -> str: |
| 1456 | + formatted, _, _ = AUTOMATION_REPLY_COMMANDS[command_name] |
| 1457 | + if command_name == "reply" and self.args: |
| 1458 | + return UnseenFormatter().format(content, **self.args) |
| 1459 | + if formatted: |
| 1460 | + system_author = getattr(self.modmail_guild, "me", None) or self.user |
| 1461 | + return self.formatter.format( |
| 1462 | + content, |
| 1463 | + **self.args, |
| 1464 | + channel=thread.channel, |
| 1465 | + recipient=thread.recipient, |
| 1466 | + author=system_author, |
1388 | 1467 | ) |
1389 | | - if trigger: |
1390 | | - invoker = trigger.lower() |
| 1468 | + return content |
1391 | 1469 |
|
1392 | | - alias = self.auto_triggers[trigger] |
| 1470 | + async def execute_thread_automation(self, thread, callback: str, message, *, source: str) -> bool: |
| 1471 | + """Execute a recipient-triggered callback through the safe server action path.""" |
| 1472 | + actions = self.resolve_automation_callback(callback) |
| 1473 | + if actions is None: |
| 1474 | + logger.warning("Blocked unsafe or invalid %s callback.", source) |
| 1475 | + return False |
1393 | 1476 |
|
1394 | | - ctxs = [] |
| 1477 | + prepared_actions = [] |
| 1478 | + for command_name, content in actions: |
| 1479 | + try: |
| 1480 | + content = self._format_automation_content(command_name, content, thread) |
| 1481 | + except Exception: |
| 1482 | + logger.warning("Failed to format %s callback.", source, exc_info=True) |
| 1483 | + return False |
1395 | 1484 |
|
1396 | | - if alias is not None: |
1397 | | - ctxs = [] |
1398 | | - aliases = normalize_alias(alias) |
1399 | | - if not aliases: |
1400 | | - logger.warning("Alias %s is invalid as called in autotrigger.", invoker) |
| 1485 | + if not content: |
| 1486 | + logger.warning("Blocked empty %s reply callback.", source) |
| 1487 | + return False |
| 1488 | + if len(content) > 4096: |
| 1489 | + logger.warning("Blocked overlong %s reply callback.", source) |
| 1490 | + return False |
1401 | 1491 |
|
1402 | | - message.author = thread.recipient # Allow for get_contexts to work |
| 1492 | + _, anonymous, plain = AUTOMATION_REPLY_COMMANDS[command_name] |
| 1493 | + prepared_actions.append((content, anonymous, plain)) |
1403 | 1494 |
|
1404 | | - for alias in aliases: |
1405 | | - message.content = invoked_prefix + alias |
1406 | | - ctxs += await self.get_contexts(message) |
| 1495 | + system_author = getattr(self.modmail_guild, "me", None) or self.user |
| 1496 | + for content, anonymous, plain in prepared_actions: |
| 1497 | + system_message = self._copy_automation_message(message) |
| 1498 | + if system_message is None: |
| 1499 | + logger.warning("Cannot execute %s callback without a source message.", source) |
| 1500 | + return False |
1407 | 1501 |
|
1408 | | - message.author = self.modmail_guild.me # Fix message so commands execute properly |
| 1502 | + system_message.author = system_author |
| 1503 | + system_message.channel = thread.channel |
| 1504 | + system_message.guild = getattr(thread.channel, "guild", None) |
| 1505 | + system_message.content = content |
| 1506 | + system_message._automation_source = source |
| 1507 | + system_message._automation_triggered_by = thread.recipient |
1409 | 1508 |
|
1410 | | - for ctx in ctxs: |
1411 | | - if ctx.command: |
1412 | | - old_checks = copy.copy(ctx.command.checks) |
1413 | | - ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)] |
| 1509 | + try: |
| 1510 | + await thread.reply(system_message, content, anonymous=anonymous, plain=plain) |
| 1511 | + except Exception: |
| 1512 | + logger.warning("Failed to execute %s reply callback.", source, exc_info=True) |
| 1513 | + return False |
1414 | 1514 |
|
1415 | | - await self.invoke(ctx) |
| 1515 | + return True |
1416 | 1516 |
|
1417 | | - ctx.command.checks = old_checks |
1418 | | - continue |
| 1517 | + def match_auto_trigger(self, content: str) -> typing.Optional[str]: |
| 1518 | + """Return the first configured autotrigger matching ``content``.""" |
| 1519 | + if not isinstance(content, str): |
| 1520 | + return None |
| 1521 | + |
| 1522 | + if self.config.get("use_regex_autotrigger"): |
| 1523 | + for candidate in self.auto_triggers: |
| 1524 | + if not isinstance(candidate, str): |
| 1525 | + logger.warning("Ignoring non-string autotrigger key %r.", candidate) |
| 1526 | + continue |
| 1527 | + try: |
| 1528 | + if re.search(candidate, content): |
| 1529 | + return candidate |
| 1530 | + except re.error: |
| 1531 | + logger.warning("Ignoring invalid autotrigger regex %r.", candidate) |
| 1532 | + return None |
| 1533 | + |
| 1534 | + content = content.casefold() |
| 1535 | + return next( |
| 1536 | + ( |
| 1537 | + candidate |
| 1538 | + for candidate in self.auto_triggers |
| 1539 | + if isinstance(candidate, str) and candidate.casefold() in content |
| 1540 | + ), |
| 1541 | + None, |
| 1542 | + ) |
| 1543 | + |
| 1544 | + async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): |
| 1545 | + del cls # Kept in the public signature for compatibility with existing extensions. |
| 1546 | + |
| 1547 | + trigger = self.match_auto_trigger(message.content) |
| 1548 | + |
| 1549 | + if trigger is None: |
| 1550 | + return False |
| 1551 | + |
| 1552 | + callback = self.auto_triggers.get(trigger) |
| 1553 | + if not callback: |
| 1554 | + logger.warning("Autotrigger %r has an empty callback.", trigger) |
| 1555 | + return False |
| 1556 | + |
| 1557 | + thread = await self.threads.find(channel=channel) |
| 1558 | + if thread is None: |
| 1559 | + logger.warning("Autotrigger %r fired without a matching thread.", trigger) |
| 1560 | + return False |
| 1561 | + |
| 1562 | + return await self.execute_thread_automation( |
| 1563 | + thread, |
| 1564 | + callback, |
| 1565 | + message, |
| 1566 | + source=f"autotrigger {trigger!r}", |
| 1567 | + ) |
1419 | 1568 |
|
1420 | 1569 | async def get_context(self, message, *, cls=commands.Context): |
1421 | 1570 | """ |
@@ -1525,10 +1674,9 @@ async def process_commands(self, message): |
1525 | 1674 | if ctx.command: |
1526 | 1675 | if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): |
1527 | 1676 | logger.debug( |
1528 | | - "Command %s has no permissions check, adding invalid level.", |
| 1677 | + "Command %s has no Modmail permission level; using its declared checks.", |
1529 | 1678 | ctx.command.qualified_name, |
1530 | 1679 | ) |
1531 | | - checks.has_permissions(PermissionLevel.INVALID)(ctx.command) |
1532 | 1680 |
|
1533 | 1681 | # Check if thread is unsnoozing and queue command if so |
1534 | 1682 | thread = await self.threads.find(channel=ctx.channel) |
|
0 commit comments