Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
71315bf
feat(guild): fairly bare-bones message search implementation
shiftinv May 18, 2026
761b717
feat: add pagination
shiftinv May 18, 2026
7b85b52
fix: support multiple channels/authors in query
shiftinv May 18, 2026
ccf3a24
fix: add missing `mentions_everyone`
shiftinv May 18, 2026
1d53b59
feat: handle 202 indexing response
shiftinv May 19, 2026
151cdd2
feat: log if retrying message search
shiftinv May 19, 2026
1b870b5
fix: check for code 110000 specifically
shiftinv May 19, 2026
52670d0
fix: don't rely on `messages` length for pagination, avoid exceeding …
shiftinv May 19, 2026
a2ef2ef
feat: deserialize threads
shiftinv May 19, 2026
36b1387
fix: don't stop iterating when encountering empty page
shiftinv May 19, 2026
f87b9a4
fix: account for <25 item pages in user-specified limit
shiftinv May 19, 2026
24f2d2e
refactor: move message channel handling to separate method
shiftinv May 19, 2026
2982eba
docs: add (chonky) docstring
shiftinv May 19, 2026
1ce5024
docs: document valid elements for literal sequence parameters
shiftinv May 19, 2026
129905e
docs: document MessageSearchSortBy enum
shiftinv May 19, 2026
e0fc4e9
docs: add required permissions/intents, raised errors, yields
shiftinv May 19, 2026
fca0acb
refactor: rename MessageSearchSortBy -> MessageSearchSortMode
shiftinv May 20, 2026
cf98c05
perf: use match/case instead of {...}[self]
shiftinv May 20, 2026
eae5458
fix: set `include_nsfw` default to false, matching api
shiftinv May 20, 2026
a25bcd3
feat(typing): add fully-typed request query dict
shiftinv May 20, 2026
24eaac2
feat: support `T | Sequence[T]` in args, also avoiding str ~ Sequence…
shiftinv May 20, 2026
1ad22d9
chore: attempt to de-clutter query construction
shiftinv May 20, 2026
6ea3cb2
feat: add custom MessageSearchIndexUnavailableError
shiftinv May 20, 2026
346eff4
docs: add missing versionadded
shiftinv May 20, 2026
098fb6d
Merge remote-tracking branch 'upstream/master' into feat/message-search
shiftinv May 20, 2026
392b669
chore: add changelog entry
shiftinv May 20, 2026
5e1bad2
Merge remote-tracking branch 'upstream/master' into feat/message-search
shiftinv Jun 24, 2026
23f9074
Merge remote-tracking branch 'upstream/master' into feat/message-search
shiftinv Jun 30, 2026
1f8f27e
chore: resolve name comment
shiftinv Jun 30, 2026
4721a71
fix: "short delay" is very subjective, let's increase it to 1 second
shiftinv Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/1522.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add :meth:`Guild.search_messages`, which allows searching for messages in a guild or channels using several different query parameters.
35 changes: 35 additions & 0 deletions disnake/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"MessageReferenceType",
"SeparatorSpacing",
"NameplatePalette",
"MessageSearchSortMode",
)

EnumMetaT = TypeVar("EnumMetaT", bound="EnumMeta")
Expand Down Expand Up @@ -2499,6 +2500,40 @@ class NameplatePalette(Enum):
"""White color palette."""


class MessageSearchSortMode(Enum):
"""Represents the sorting algorithm/direction used for :meth:`Guild.search_messages`.

.. versionadded:: |vnext|
"""

timestamp_desc = "timestamp_desc"
"""Sort by message creation time, descending."""
timestamp_asc = "timestamp_asc"
"""Sort by message creation time, ascending."""
relevance = "relevance"
"""Sort by relevance of the message to the search query."""

@property
def sort_key(self) -> str:
match self:
case MessageSearchSortMode.timestamp_desc:
return "timestamp"
case MessageSearchSortMode.timestamp_asc:
return "timestamp"
case MessageSearchSortMode.relevance:
return "relevance"

@property
def sort_order(self) -> str | None:
match self:
case MessageSearchSortMode.timestamp_desc:
return "desc"
case MessageSearchSortMode.timestamp_asc:
return "asc"
case MessageSearchSortMode.relevance:
return None


T = TypeVar("T", bound="Enum")


Expand Down
19 changes: 19 additions & 0 deletions disnake/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from requests import Response

from .client import SessionStartLimit
from .guild import Guild
from .interactions import Interaction, ModalInteraction

_ResponseType: TypeAlias = ClientResponse | Response
Expand All @@ -36,6 +37,7 @@
"ModalChainNotSupported",
"InteractionNotEditable",
"LocalizationKeyError",
"MessageSearchIndexUnavailableError",
)


Expand Down Expand Up @@ -433,3 +435,20 @@ class LocalizationKeyError(DiscordException):
def __init__(self, key: str) -> None:
self.key: str = key
super().__init__(f"No localizations were found for the key '{key}'.")


class MessageSearchIndexUnavailableError(DiscordException):
"""Exception that's raised when the message search target is not yet
indexed, and all retries (if configured) have been exhausted.

.. versionadded:: |vnext|

Attributes
----------
guild: :class:`Guild`
The guild whose messages are not yet indexed.
"""

def __init__(self, guild: Guild) -> None:
self.guild: Guild = guild
super().__init__(f"Message search indexing for guild ID {guild.id} is still in progress.")
208 changes: 207 additions & 1 deletion disnake/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
GuildScheduledEventEntityType,
GuildScheduledEventPrivacyLevel,
Locale,
MessageSearchSortMode,
NotificationLevel,
NSFWLevel,
ThreadLayout,
Expand All @@ -59,7 +60,7 @@
from .guild_scheduled_event import GuildScheduledEvent, GuildScheduledEventMetadata
from .integrations import Integration, _integration_factory
from .invite import Invite
from .iterators import AuditLogIterator, BanIterator, MemberIterator
from .iterators import AuditLogIterator, BanIterator, MemberIterator, MessageSearchIterator
from .member import Member, VoiceState
from .mixins import Hashable
from .object import Object
Expand All @@ -77,6 +78,9 @@
from .widget import Widget, WidgetSettings

__all__ = (
"MessageSearchAuthorType",
"MessageSearchHasType",
"MessageSearchEmbedType",
"IncidentsData",
"Guild",
)
Expand All @@ -102,6 +106,7 @@
MFALevel,
)
from .types.integration import Integration as IntegrationPayload, IntegrationType
from .types.message import MessageSearchQuery
from .types.role import CreateRole as CreateRolePayload
from .types.sticker import CreateGuildSticker as CreateStickerPayload
from .types.threads import Thread as ThreadPayload, ThreadArchiveDurationLiteral
Expand All @@ -116,6 +121,30 @@
ByCategoryItem: TypeAlias = tuple[CategoryChannel | None, list[GuildChannel]]


# These literals are here such that they can (in theory) be used at runtime;
# disnake.types isn't necessarily runtime-importable due to cycles

# fmt: off
MessageSearchAuthorType = Literal[
"user", "-user",
"bot", "-bot",
"webhook", "-webhook"
]
MessageSearchHasType = Literal[
"image", "-image",
"sound", "-sound",
"video", "-video",
"file", "-file",
"sticker", "-sticker",
"embed", "-embed",
"link", "-link",
"poll", "-poll",
"snapshot", "-snapshot",
]
# fmt: on
MessageSearchEmbedType = Literal["image", "video", "gif", "sound", "article"]


class _GuildLimit(NamedTuple):
emoji: int
stickers: int
Expand Down Expand Up @@ -5344,3 +5373,180 @@ async def fetch_soundboard_sounds(self) -> list[GuildSoundboardSound]:
return [
GuildSoundboardSound(data=d, state=self._state, guild_id=self.id) for d in data["items"]
]

def search_messages(
self,
*,
# common iterator params
limit: int | None = 25,
before: SnowflakeTime | None = None,
after: SnowflakeTime | None = None,
sort: MessageSearchSortMode = MessageSearchSortMode.timestamp_desc,
# search filters
content: str | None = None,
slop: int | None = None,
channel: Sequence[Snowflake] | Snowflake | None = None,
author: Sequence[Snowflake] | Snowflake | None = None,
author_type: Sequence[MessageSearchAuthorType] | MessageSearchAuthorType | None = None,
mentions: Sequence[Snowflake] | Snowflake | None = None,
mentions_role: Sequence[Snowflake] | Snowflake | None = None,
mentions_everyone: bool | None = None,
replied_to_user: Sequence[Snowflake] | Snowflake | None = None,
replied_to_message: Sequence[Snowflake] | Snowflake | None = None,
pinned: bool | None = None,
has: Sequence[MessageSearchHasType] | MessageSearchHasType | None = None,
embed_type: Sequence[MessageSearchEmbedType] | MessageSearchEmbedType | None = None,
embed_provider: Sequence[str] | str | None = None,
link_hostname: Sequence[str] | str | None = None,
attachment_filename: Sequence[str] | str | None = None,
attachment_extension: Sequence[str] | str | None = None,
include_nsfw: bool = False,
# for handling indexing errors
retries: int = 3,
) -> MessageSearchIterator:
r"""Returns an :class:`.AsyncIterator` representing the messages matching the query parameters.

Results are returned from newest to oldest by default; this is configurable using
the ``sort`` parameter.

You must have :attr:`~Permissions.read_message_history` permissions to do this,
and the :attr:`~Intents.message_content` intent must be enabled for this bot.

.. versionadded:: |vnext|

Parameters
----------
limit: :class:`int` | :data:`None`
The number of messages to retrieve, up to 10000.
If :data:`None`, retrieves the maximum number of matching messages.
Note, however, that this would make it a slow operation.
Defaults to ``25``.
before: :class:`.abc.Snowflake` | :class:`datetime.datetime` | :data:`None`
Retrieves messages created before this date or object.
If a datetime is provided, it is recommended to use a UTC aware datetime.
If the datetime is naive, it is assumed to be local time.
after: :class:`.abc.Snowflake` | :class:`datetime.datetime` | :data:`None`
Retrieve messages created after this date or object.
If a datetime is provided, it is recommended to use a UTC aware datetime.
If the datetime is naive, it is assumed to be local time.
sort: :class:`MessageSearchSortMode`
The sorting algorithm/direction to use for retrieving search results.
Defaults to :attr:`~MessageSearchSortMode.timestamp_desc`.
content: :class:`str` | :data:`None`
Filter messages by content (up to 1024 characters).
channel: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages by channels (up to 500).
author: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages by authors (up to 100).
author_type: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by author types.

Can be any subset of ``["user", "bot", "webhook"]``. Types can also be negated with a
``-`` prefix to exclude that type, e.g. ``["bot", "-webhook"]`` would be a valid value.
mentions: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages that mention these users (up to 100).
mentions_role: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages that mention these roles (up to 100).
mentions_everyone: :class:`bool` | :data:`None`
Filter messages that do/don't mention ``@everyone``.
replied_to_user: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages that reply to these users (up to 100).
replied_to_message: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :class:`.abc.Snowflake` | :data:`None`
Filter messages that reply to these messages (up to 100).
pinned: :class:`bool` | :data:`None`
Filter messages that are/aren't pinned.
has: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by whether or not they have specific things.

Can be any subset of ``["image", "sound", "video", "file", "sticker", "embed", "link", "poll", "snapshot"]``.
Types can also be negated with a ``-`` prefix to exclude that type,
e.g. ``["image", "-link"]`` would be a valid value.
embed_type: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by embed type.

Can be any subset of ``["image", "video", "gif", "sound", "article"]``.
embed_provider: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by embed provider (up to 100, with up to 256 characters each).
link_hostname: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by link hostname, e.g. ``discordapp.com`` (up to 100, with up to 256 characters each).
attachment_filename: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by attachment filename (up too 100, with up to 1024 characters each).
attachment_extension: :class:`~collections.abc.Sequence`\[:class:`str`] | :class:`str` | :data:`None`
Filter messages by attachment extension, e.g. ``txt`` (up too 100, with up to 256 characters each).
include_nsfw: :class:`bool`
Whether to include results from age-restricted channels. Defaults to ``False``.
retries: :class:`int`
The number of times to wait and retry fetching results in case the guild is still being indexed.
Can be set to 0 to disable retries and raise an error immediately instead of retrying.
Defaults to 3.

Raises
------
Forbidden
You do not have permission to search messages,
or the :attr:`~Intents.message_content` intent is not enabled.
HTTPException
Retrieving the search results failed.
MessageSearchIndexUnavailableError
Exceeded maximum number of retries while waiting for messages to finish indexing.

Yields
------
:class:`.Message`
The message matching the given query parameters.
"""

def listify_snowflakes(arg: abc.Snowflake | Sequence[abc.Snowflake]) -> Sequence[int]:
if isinstance(arg, abc.Snowflake):
return [arg.id]
return [item.id for item in arg]

def listify_strs(arg: str | Sequence[str]) -> Sequence[str]:
if isinstance(arg, str):
return [arg]
return arg

query: MessageSearchQuery = {"include_nsfw": include_nsfw}

query["sort_by"] = sort.sort_key
if sort_order := sort.sort_order:
query["sort_order"] = sort_order

if content is not None:
query["content"] = content
if slop is not None:
query["slop"] = slop
if channel is not None:
query["channel_id"] = listify_snowflakes(channel)
if author is not None:
query["author_id"] = listify_snowflakes(author)
if author_type is not None:
query["author_type"] = listify_strs(author_type)
if mentions is not None:
query["mentions"] = listify_snowflakes(mentions)
if mentions_role is not None:
query["mentions_role"] = listify_snowflakes(mentions_role)
if mentions_everyone is not None:
query["mentions_everyone"] = mentions_everyone
if replied_to_user is not None:
query["replied_to_user_id"] = listify_snowflakes(replied_to_user)
if replied_to_message is not None:
query["replied_to_message_id"] = listify_snowflakes(replied_to_message)
if pinned is not None:
query["pinned"] = pinned
if has is not None:
query["has"] = listify_strs(has)
if embed_type is not None:
query["embed_type"] = listify_strs(embed_type)
if embed_provider is not None:
query["embed_provider"] = listify_strs(embed_provider)
if link_hostname is not None:
query["link_hostname"] = listify_strs(link_hostname)
if attachment_filename is not None:
query["attachment_filename"] = listify_strs(attachment_filename)
if attachment_extension is not None:
query["attachment_extension"] = listify_strs(attachment_extension)

return MessageSearchIterator(
self, query, retries=retries, limit=limit, before=before, after=after
)
11 changes: 10 additions & 1 deletion disnake/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
import sys
import weakref
from collections.abc import Coroutine, Iterable, Sequence
from collections.abc import Coroutine, Iterable, Mapping, Sequence
from errno import ECONNRESET
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -909,6 +909,15 @@ def get_pins(

return self.request(r, params=params)

def search_guild_messages(
self, guild_id: Snowflake, params: Mapping[str, Any]
) -> Response[message.MessageSearchResult | message.MessageSearchNotIndexedResult]:
# turn bools into 0/1
params = {k: (int(v) if isinstance(v, bool) else v) for k, v in params.items()}

r = Route("GET", "/guilds/{guild_id}/messages/search", guild_id=guild_id)
return self.request(r, params=params)

# Member management

def search_guild_members(
Expand Down
Loading
Loading