From 71315bf4321b326799e5e5faf966f8c8243a6a6d Mon Sep 17 00:00:00 2001 From: vi Date: Mon, 18 May 2026 18:56:37 +0200 Subject: [PATCH 01/27] feat(guild): fairly bare-bones message search implementation --- disnake/enums.py | 7 ++++ disnake/guild.py | 84 ++++++++++++++++++++++++++++++++++++++++ disnake/http.py | 10 +++++ disnake/types/message.py | 11 +++++- 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/disnake/enums.py b/disnake/enums.py index f5a02b2cea..a5f636cf48 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -76,6 +76,7 @@ "MessageReferenceType", "SeparatorSpacing", "NameplatePalette", + "MessageSearchSortBy", ) EnumMetaT = TypeVar("EnumMetaT", bound="EnumMeta") @@ -2516,6 +2517,12 @@ class NameplatePalette(Enum): """White color palette.""" +class MessageSearchSortBy(Enum): + timestamp_desc = "timestamp_desc" + timestamp_asc = "timestamp_asc" + relevance = "relevance" + + T = TypeVar("T", bound="Enum") diff --git a/disnake/guild.py b/disnake/guild.py index 455100c58e..f521ae478c 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -44,6 +44,7 @@ GuildScheduledEventEntityType, GuildScheduledEventPrivacyLevel, Locale, + MessageSearchSortBy, NotificationLevel, NSFWLevel, ThreadLayout, @@ -78,6 +79,9 @@ from .widget import Widget, WidgetSettings __all__ = ( + "MessageSearchAuthorType", + "MessageSearchHasThing", + "MessageSearchEmbedType", "IncidentsData", "Guild", "GuildBuilder", @@ -123,6 +127,29 @@ ByCategoryItem: TypeAlias = tuple[CategoryChannel | None, list[GuildChannel]] +# XXX: these 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" +] +# TODO: name +MessageSearchHasThing = 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 @@ -5413,6 +5440,63 @@ async def fetch_soundboard_sounds(self) -> list[GuildSoundboardSound]: GuildSoundboardSound(data=d, state=self._state, guild_id=self.id) for d in data["items"] ] + async def search_messages( + self, + *, + # common iterator params + limit: int | None = 25, + before: SnowflakeTime | None = None, + after: SnowflakeTime | None = None, + sort: MessageSearchSortBy = MessageSearchSortBy.timestamp_desc, + # search filters + # TODO: consider MISSING instead of None defaults? + content: str | None = None, + slop: int | None = None, + channel: Snowflake | None = None, + author: Snowflake | None = None, + author_type: Sequence[MessageSearchAuthorType] | None = None, + mentions: Sequence[Snowflake] | None = None, + mentions_role: Sequence[Snowflake] | None = None, + replied_to_user: Sequence[Snowflake] | None = None, + replied_to_message: Sequence[Snowflake] | None = None, + pinned: bool | None = None, + has: Sequence[MessageSearchHasThing] | None = None, + embed_type: Sequence[MessageSearchEmbedType] | None = None, + embed_provider: Sequence[str] | None = None, + link_hostname: Sequence[str] | None = None, + attachment_filename: Sequence[str] | None = None, + attachment_extension: Sequence[str] | None = None, + include_nsfw: bool | None = None, + ) -> None: + """|coro| + + TODO + """ + query: dict[str, str | int | bool | Sequence[str | int] | None] = { + "content": content, + "slop": slop, + "channel": channel.id if channel else None, + "author": author.id if author else None, + "author_type": author_type, + "mentions": [m.id for m in mentions] if mentions else None, + "mentions_role": [r.id for r in mentions_role] if mentions_role else None, + "replied_to_user": [u.id for u in replied_to_user] if replied_to_user else None, + "replied_to_message": ( + [m.id for m in replied_to_message] if replied_to_message else None + ), + "pinned": pinned, + "has": has, + "embed_type": embed_type, + "embed_provider": embed_provider, + "link_hostname": link_hostname, + "attachment_filename": attachment_filename, + "attachment_extension": attachment_extension, + "include_nsfw": include_nsfw, + } + await self._state.http.search_guild_messages( + self.id, **{k: v for k, v in query.items() if v is not None} + ) + PlaceholderID = NewType("PlaceholderID", int) diff --git a/disnake/http.py b/disnake/http.py index 0b941b593a..8cbd69aca1 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -909,6 +909,16 @@ def get_pins( return self.request(r, params=params) + def search_guild_messages( + self, guild_id: Snowflake, **query: Any + ) -> Response[message.MessageSearchResult]: + # turn bools into 0/1 + query = {k: (int(v) if isinstance(v, bool) else v) for k, v in query.items()} + + r = Route("GET", "/guilds/{guild_id}/messages/search", guild_id=guild_id) + # TODO: handle 202? + return self.request(r, params=query) + # Member management def search_guild_members( diff --git a/disnake/types/message.py b/disnake/types/message.py index c4b0376208..1d7e915282 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -15,7 +15,7 @@ from .poll import Poll from .snowflake import Snowflake, SnowflakeList from .sticker import StickerItem -from .threads import Thread +from .threads import Thread, ThreadMember from .user import User @@ -169,3 +169,12 @@ class MessagePin(TypedDict): class MessageCall(TypedDict): participants: SnowflakeList ended_timestamp: NotRequired[str | None] + + +class MessageSearchResult(TypedDict): + doing_deep_historical_index: bool + documents_indexed: NotRequired[int] + total_results: int + messages: list[list[Message]] + threads: NotRequired[list[Thread]] + members: NotRequired[list[ThreadMember]] From 761b7174af63fa1c47a7da29441bf7f38d4543d3 Mon Sep 17 00:00:00 2001 From: vi Date: Mon, 18 May 2026 19:46:38 +0200 Subject: [PATCH 02/27] feat: add pagination --- disnake/enums.py | 15 ++++++++ disnake/guild.py | 18 +++++++--- disnake/http.py | 6 ++-- disnake/iterators.py | 82 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/disnake/enums.py b/disnake/enums.py index a5f636cf48..543039cfdf 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -2522,6 +2522,21 @@ class MessageSearchSortBy(Enum): timestamp_asc = "timestamp_asc" relevance = "relevance" + @property + def sort_key(self) -> str: + return { + MessageSearchSortBy.timestamp_desc: "timestamp", + MessageSearchSortBy.timestamp_asc: "timestamp", + MessageSearchSortBy.relevance: "relevance", + }[self] + + @property + def sort_order(self) -> str | None: + return { + MessageSearchSortBy.timestamp_desc: "desc", + MessageSearchSortBy.timestamp_asc: "asc", + }.get(self) + T = TypeVar("T", bound="Enum") diff --git a/disnake/guild.py b/disnake/guild.py index f521ae478c..c95f94efdf 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -61,7 +61,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 @@ -5440,7 +5440,7 @@ async def fetch_soundboard_sounds(self) -> list[GuildSoundboardSound]: GuildSoundboardSound(data=d, state=self._state, guild_id=self.id) for d in data["items"] ] - async def search_messages( + def search_messages( self, *, # common iterator params @@ -5467,7 +5467,7 @@ async def search_messages( attachment_filename: Sequence[str] | None = None, attachment_extension: Sequence[str] | None = None, include_nsfw: bool | None = None, - ) -> None: + ) -> MessageSearchIterator: """|coro| TODO @@ -5493,8 +5493,16 @@ async def search_messages( "attachment_extension": attachment_extension, "include_nsfw": include_nsfw, } - await self._state.http.search_guild_messages( - self.id, **{k: v for k, v in query.items() if v is not None} + + query["sort_by"] = sort.sort_key + query["sort_order"] = sort.sort_order + + return MessageSearchIterator( + self, + {k: v for k, v in query.items() if v is not None}, + limit=limit, + before=before, + after=after, ) diff --git a/disnake/http.py b/disnake/http.py index 8cbd69aca1..0f287dfacb 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -910,14 +910,14 @@ def get_pins( return self.request(r, params=params) def search_guild_messages( - self, guild_id: Snowflake, **query: Any + self, guild_id: Snowflake, params: dict[str, Any] ) -> Response[message.MessageSearchResult]: # turn bools into 0/1 - query = {k: (int(v) if isinstance(v, bool) else v) for k, v in query.items()} + 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) # TODO: handle 202? - return self.request(r, params=query) + return self.request(r, params=params) # Member management diff --git a/disnake/iterators.py b/disnake/iterators.py index da41199738..6261bd871b 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -4,7 +4,7 @@ import asyncio import datetime -from collections.abc import AsyncIterator, Awaitable, Callable, Generator +from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Sequence from typing import ( TYPE_CHECKING, Any, @@ -37,6 +37,7 @@ "EntitlementIterator", "SubscriptionIterator", "PollAnswerIterator", + "MessageSearchIterator", ) if TYPE_CHECKING: @@ -1398,3 +1399,82 @@ async def fill_messages(self) -> None: message = self._state.create_message(channel=self.channel, data=element["message"]) message._pinned_at = parse_time(element["pinned_at"]) await self.messages.put(message) + + +class MessageSearchIterator(_AsyncIterator["Message"]): + def __init__( + self, + guild: Guild, + query: dict[str, str | int | bool | Sequence[str | int]], + *, + limit: int | None, + before: Snowflake | datetime.datetime | None = None, + after: Snowflake | datetime.datetime | None = None, + ) -> None: + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.guild = guild + self._state = guild._state + + self.limit = limit + self.offset: int = 0 + + self.query = query + # since the given `query` dict should always be ephemeral and only created by the lib, + # we can just mutate it directly + if before is not None: + self.query["max_id"] = before.id + if after is not None: + self.query["min_id"] = after.id + + self.getter = self._state.http.search_guild_messages + self.messages: asyncio.Queue[Message] = asyncio.Queue() + + async def next(self) -> Message: + if self.messages.empty(): + await self.fill_messages() + + try: + return self.messages.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems from None + + def _get_retrieve(self) -> bool: + self.retrieve = min(self.limit, 25) if self.limit is not None else 25 + return self.retrieve > 0 + + async def fill_messages(self) -> None: + if not self._get_retrieve(): + return + + self.query["limit"] = self.retrieve + self.query["offset"] = self.offset + + data = await self.getter( + guild_id=self.guild.id, + params=self.query, + ) + + messages = [m for ms in data["messages"] for m in ms] + + if messages: + if self.limit is not None: + self.limit -= self.retrieve + self.offset += self.retrieve + + if len(messages) < 25: + self.limit = 0 # terminate loop + + from .abc import Messageable + + for element in messages: + channel = self.guild.get_channel_or_thread(int(element["channel_id"])) + # TODO: take `data["threads"]` into account + if not isinstance(channel, Messageable): + continue + + message = self._state.create_message(channel=channel, data=element) + await self.messages.put(message) From 7b85b52028604ac50f2c16332e41cfa07e75e816 Mon Sep 17 00:00:00 2001 From: vi Date: Mon, 18 May 2026 19:58:46 +0200 Subject: [PATCH 03/27] fix: support multiple channels/authors in query --- disnake/guild.py | 9 +++++---- disnake/iterators.py | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index c95f94efdf..cf43b501b2 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5452,8 +5452,9 @@ def search_messages( # TODO: consider MISSING instead of None defaults? content: str | None = None, slop: int | None = None, - channel: Snowflake | None = None, - author: Snowflake | None = None, + # TODO: channel/author vs channels/authors + channel: Sequence[Snowflake] | None = None, + author: Sequence[Snowflake] | None = None, author_type: Sequence[MessageSearchAuthorType] | None = None, mentions: Sequence[Snowflake] | None = None, mentions_role: Sequence[Snowflake] | None = None, @@ -5475,8 +5476,8 @@ def search_messages( query: dict[str, str | int | bool | Sequence[str | int] | None] = { "content": content, "slop": slop, - "channel": channel.id if channel else None, - "author": author.id if author else None, + "channel": [c.id for c in channel] if channel else None, + "author": [a.id for a in author] if author else None, "author_type": author_type, "mentions": [m.id for m in mentions] if mentions else None, "mentions_role": [r.id for r in mentions_role] if mentions_role else None, diff --git a/disnake/iterators.py b/disnake/iterators.py index 6261bd871b..2325acf5b1 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1465,6 +1465,7 @@ async def fill_messages(self) -> None: self.limit -= self.retrieve self.offset += self.retrieve + # FIXME: "Clients should not rely on the length of the `messages` array to paginate results." if len(messages) < 25: self.limit = 0 # terminate loop From ccf3a2489ea1811bd89fe0b313ab1e3636a47777 Mon Sep 17 00:00:00 2001 From: vi Date: Mon, 18 May 2026 19:59:34 +0200 Subject: [PATCH 04/27] fix: add missing `mentions_everyone` --- disnake/guild.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/disnake/guild.py b/disnake/guild.py index cf43b501b2..7d5e6b167e 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5458,6 +5458,7 @@ def search_messages( author_type: Sequence[MessageSearchAuthorType] | None = None, mentions: Sequence[Snowflake] | None = None, mentions_role: Sequence[Snowflake] | None = None, + mentions_everyone: bool | None = None, replied_to_user: Sequence[Snowflake] | None = None, replied_to_message: Sequence[Snowflake] | None = None, pinned: bool | None = None, @@ -5481,6 +5482,7 @@ def search_messages( "author_type": author_type, "mentions": [m.id for m in mentions] if mentions else None, "mentions_role": [r.id for r in mentions_role] if mentions_role else None, + "mentions_everyone": mentions_everyone, "replied_to_user": [u.id for u in replied_to_user] if replied_to_user else None, "replied_to_message": ( [m.id for m in replied_to_message] if replied_to_message else None From 1d53b5944b1e7c51f8a9ac55540132e6f368b3ff Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 12:10:37 +0200 Subject: [PATCH 05/27] feat: handle 202 indexing response --- disnake/guild.py | 4 +++- disnake/http.py | 3 +-- disnake/iterators.py | 36 +++++++++++++++++++++++++++++++----- disnake/types/message.py | 7 +++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index 7d5e6b167e..f7b881c3d1 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5449,7 +5449,6 @@ def search_messages( after: SnowflakeTime | None = None, sort: MessageSearchSortBy = MessageSearchSortBy.timestamp_desc, # search filters - # TODO: consider MISSING instead of None defaults? content: str | None = None, slop: int | None = None, # TODO: channel/author vs channels/authors @@ -5469,6 +5468,8 @@ def search_messages( attachment_filename: Sequence[str] | None = None, attachment_extension: Sequence[str] | None = None, include_nsfw: bool | None = None, + # for handling indexing errors + retries: int = 3, ) -> MessageSearchIterator: """|coro| @@ -5503,6 +5504,7 @@ def search_messages( return MessageSearchIterator( self, {k: v for k, v in query.items() if v is not None}, + retries=retries, limit=limit, before=before, after=after, diff --git a/disnake/http.py b/disnake/http.py index 0f287dfacb..798f679a68 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -911,12 +911,11 @@ def get_pins( def search_guild_messages( self, guild_id: Snowflake, params: dict[str, Any] - ) -> Response[message.MessageSearchResult]: + ) -> 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) - # TODO: handle 202? return self.request(r, params=params) # Member management diff --git a/disnake/iterators.py b/disnake/iterators.py index 2325acf5b1..a718353398 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -59,7 +59,7 @@ GuildScheduledEventUser as GuildScheduledEventUserPayload, ) from .types.member import MemberWithUser as MemberWithUserPayload - from .types.message import Message as MessagePayload + from .types.message import Message as MessagePayload, MessageSearchResult from .types.subscription import Subscription as SubscriptionPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload @@ -1407,6 +1407,7 @@ def __init__( guild: Guild, query: dict[str, str | int | bool | Sequence[str | int]], *, + retries: int, limit: int | None, before: Snowflake | datetime.datetime | None = None, after: Snowflake | datetime.datetime | None = None, @@ -1430,6 +1431,7 @@ def __init__( if after is not None: self.query["min_id"] = after.id + self.max_retries = retries self.getter = self._state.http.search_guild_messages self.messages: asyncio.Queue[Message] = asyncio.Queue() @@ -1446,6 +1448,33 @@ def _get_retrieve(self) -> bool: self.retrieve = min(self.limit, 25) if self.limit is not None else 25 return self.retrieve > 0 + # this endpoint is somewhat special in that the guild/channel may still be in the + # process of being indexed, in which case we receive a `202 Accepted` with a `retry_after` field. + async def _try_fetch(self) -> MessageSearchResult: + retries = 0 + while True: + data = await self.getter( + guild_id=self.guild.id, + params=self.query, + ) + + if "code" not in data: + return data + + # if we have a `code`, this was a 202 response and message indexing is likely still in progress + if retries >= self.max_retries: + # TODO: custom error? + msg = "Exhausted retries while message search indexing is still in progress" + raise RuntimeError(msg) + + retry_after = data["retry_after"] + # "If the retry_after field is 0, you should retry the request after a short delay." + retry_after = max(retry_after, 0.25) + # TODO: log + + await asyncio.sleep(retry_after) + retries += 1 + async def fill_messages(self) -> None: if not self._get_retrieve(): return @@ -1453,10 +1482,7 @@ async def fill_messages(self) -> None: self.query["limit"] = self.retrieve self.query["offset"] = self.offset - data = await self.getter( - guild_id=self.guild.id, - params=self.query, - ) + data = await self._try_fetch() messages = [m for ms in data["messages"] for m in ms] diff --git a/disnake/types/message.py b/disnake/types/message.py index 1d7e915282..a8649a7e29 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -178,3 +178,10 @@ class MessageSearchResult(TypedDict): messages: list[list[Message]] threads: NotRequired[list[Thread]] members: NotRequired[list[ThreadMember]] + + +class MessageSearchNotIndexedResult(TypedDict): + message: str + code: int + documents_indexed: int + retry_after: int From 151cdd20fab0eb1a9f03bd03409b2383cb0cb339 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 12:15:54 +0200 Subject: [PATCH 06/27] feat: log if retrying message search --- disnake/iterators.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index a718353398..f7fa05a0f5 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -4,6 +4,7 @@ import asyncio import datetime +import logging from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Sequence from typing import ( TYPE_CHECKING, @@ -71,6 +72,8 @@ OLDEST_OBJECT = Object(id=0) +_log = logging.getLogger(__name__) + class _AsyncIterator(AsyncIterator[T]): __slots__ = () @@ -1470,7 +1473,11 @@ async def _try_fetch(self) -> MessageSearchResult: retry_after = data["retry_after"] # "If the retry_after field is 0, you should retry the request after a short delay." retry_after = max(retry_after, 0.25) - # TODO: log + _log.info( + "Message search index for guild ID %d is not yet available. Retrying in %.2fs.", + self.guild.id, + retry_after, + ) await asyncio.sleep(retry_after) retries += 1 From 1b870b5452d44e98a388c8d7c4658fd58627602d Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 12:39:23 +0200 Subject: [PATCH 07/27] fix: check for code 110000 specifically --- disnake/iterators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index f7fa05a0f5..99e9a53801 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1463,8 +1463,11 @@ async def _try_fetch(self) -> MessageSearchResult: if "code" not in data: return data + if data["code"] != 110000: + msg = f"Received unexpected error code {data['code']}" + raise RuntimeError(msg) - # if we have a `code`, this was a 202 response and message indexing is likely still in progress + # if we have `"code": 110000`, this was a 202 response and message indexing is likely still in progress if retries >= self.max_retries: # TODO: custom error? msg = "Exhausted retries while message search indexing is still in progress" From 52670d086830a6fce68caafd03577f1a997fb55f Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 13:10:10 +0200 Subject: [PATCH 08/27] fix: don't rely on `messages` length for pagination, avoid exceeding max offset Some result pages can have fewer (or even zero) results, so just continue paginating until we either hit the max offset of 9975, or the total number of available results --- disnake/iterators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 99e9a53801..efeb09e598 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1501,8 +1501,8 @@ async def fill_messages(self) -> None: self.limit -= self.retrieve self.offset += self.retrieve - # FIXME: "Clients should not rely on the length of the `messages` array to paginate results." - if len(messages) < 25: + # if the next offset would exceed the total number of results or maximum allowed offset, stop + if self.offset >= data["total_results"] or self.offset > 9975: self.limit = 0 # terminate loop from .abc import Messageable From a2ef2ef9b7dbe5e4f2d8e59c8245db39da8ede52 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 14:39:25 +0200 Subject: [PATCH 09/27] feat: deserialize threads --- disnake/iterators.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index efeb09e598..a763ee4542 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1491,12 +1491,15 @@ async def fill_messages(self) -> None: self.query["limit"] = self.retrieve self.query["offset"] = self.offset - data = await self._try_fetch() - messages = [m for ms in data["messages"] for m in ms] + threads = { + int(t["id"]): Thread(guild=self.guild, state=self._state, data=t) + for t in data.get("threads") or [] + } + message_data = [m for ms in data["messages"] for m in ms] - if messages: + if message_data: if self.limit is not None: self.limit -= self.retrieve self.offset += self.retrieve @@ -1507,10 +1510,12 @@ async def fill_messages(self) -> None: from .abc import Messageable - for element in messages: - channel = self.guild.get_channel_or_thread(int(element["channel_id"])) - # TODO: take `data["threads"]` into account + for element in message_data: + channel_id = int(element["channel_id"]) + channel = self.guild.get_channel_or_thread(channel_id) or threads.get(channel_id) if not isinstance(channel, Messageable): + # this should never happen. we're here either because the channel resolved to a + # non-messageable guild channel, or because we can't find the channel/thread continue message = self._state.create_message(channel=channel, data=element) From 36b138767d7a96750b8e01494f043be08cb92cb7 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 15:02:53 +0200 Subject: [PATCH 10/27] fix: don't stop iterating when encountering empty page --- disnake/iterators.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index a763ee4542..e2a7025f4c 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1439,7 +1439,10 @@ def __init__( self.messages: asyncio.Queue[Message] = asyncio.Queue() async def next(self) -> Message: - if self.messages.empty(): + # note: unlike other endpoints, this one can return empty pages, + # especially with higher offsets. therefore, continue iterating empty pages + # until we either get some results or reach the definitive end + while self.messages.empty() and self.limit != 0: await self.fill_messages() try: @@ -1499,10 +1502,9 @@ async def fill_messages(self) -> None: } message_data = [m for ms in data["messages"] for m in ms] - if message_data: - if self.limit is not None: - self.limit -= self.retrieve - self.offset += self.retrieve + if self.limit is not None: + self.limit -= self.retrieve + self.offset += self.retrieve # if the next offset would exceed the total number of results or maximum allowed offset, stop if self.offset >= data["total_results"] or self.offset > 9975: From f87b9a4698c142583c3102f82b19c74675bfeeaa Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 15:03:49 +0200 Subject: [PATCH 11/27] fix: account for <25 item pages in user-specified limit --- disnake/iterators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index e2a7025f4c..72f72702e6 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1503,7 +1503,7 @@ async def fill_messages(self) -> None: message_data = [m for ms in data["messages"] for m in ms] if self.limit is not None: - self.limit -= self.retrieve + self.limit -= len(message_data) self.offset += self.retrieve # if the next offset would exceed the total number of results or maximum allowed offset, stop From 24f2d2edeb4e3bc4a7c446807a6dd5402bee8475 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 15:14:07 +0200 Subject: [PATCH 12/27] refactor: move message channel handling to separate method --- disnake/iterators.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 72f72702e6..faa4c47e40 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1510,15 +1510,18 @@ async def fill_messages(self) -> None: if self.offset >= data["total_results"] or self.offset > 9975: self.limit = 0 # terminate loop + for element in message_data: + if message := self.create_message(element, threads): + await self.messages.put(message) + + def create_message(self, data: MessagePayload, threads: dict[int, Thread]) -> Message | None: from .abc import Messageable - for element in message_data: - channel_id = int(element["channel_id"]) - channel = self.guild.get_channel_or_thread(channel_id) or threads.get(channel_id) - if not isinstance(channel, Messageable): - # this should never happen. we're here either because the channel resolved to a - # non-messageable guild channel, or because we can't find the channel/thread - continue - - message = self._state.create_message(channel=channel, data=element) - await self.messages.put(message) + channel_id = int(data["channel_id"]) + channel = self.guild.get_channel_or_thread(channel_id) or threads.get(channel_id) + if not isinstance(channel, Messageable): + # this should never happen. we're here either because the channel resolved to a + # non-messageable guild channel, or because we can't find the channel/thread + return None + + return self._state.create_message(channel=channel, data=data) From 2982ebafaeafa34e190ce959b95291b95f624662 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 18:31:07 +0200 Subject: [PATCH 13/27] docs: add (chonky) docstring --- disnake/guild.py | 64 +++++++++++++++++++++++++++++++++++++++++++-- docs/api/guilds.rst | 5 +++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index f7b881c3d1..c97afa8b48 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5463,17 +5463,77 @@ def search_messages( pinned: bool | None = None, has: Sequence[MessageSearchHasThing] | None = None, embed_type: Sequence[MessageSearchEmbedType] | None = None, + # XXX: `str` is also `Sequence[str]`, account for this embed_provider: Sequence[str] | None = None, link_hostname: Sequence[str] | None = None, attachment_filename: Sequence[str] | None = None, attachment_extension: Sequence[str] | None = None, + # TODO: make this not default to None include_nsfw: bool | None = None, # for handling indexing errors retries: int = 3, ) -> MessageSearchIterator: - """|coro| + 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. - TODO + 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:`MessageSearchSortBy` + The sorting algorithm/direction to use for retrieving search results. + Defaults to :attr:`~MessageSearchSortBy.timestamp_desc`. + content: :class:`str` | :data:`None` + Filter messages by content (up to 1024 characters). + channel: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :data:`None` + Filter messages by channels (up to 500). + author: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :data:`None` + Filter messages by authors (up to 100). + author_type: TODO | :data:`None` + Filter messages by author types. + mentions: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :data:`None` + Filter messages that mention these users (up to 100). + mentions_role: :class:`~collections.abc.Sequence`\[: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`] | :data:`None` + Filter messages that reply to these users (up to 100). + replied_to_message: :class:`~collections.abc.Sequence`\[: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: TODO | :data:`None` + Filter messages by whether or not they have specific things. + embed_type: TODO | :data:`None` + Filter messages by embed type. + embed_provider: :class:`~collections.abc.Sequence`\[: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`] | :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`] | :data:`None` + Filter messages by attachment filename (up too 100, with up to 1024 characters each). + attachment_extension: :class:`~collections.abc.Sequence`\[: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` | :data:`None` + 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``. """ query: dict[str, str | int | bool | Sequence[str | int] | None] = { "content": content, diff --git a/docs/api/guilds.rst b/docs/api/guilds.rst index 12498f8d63..ccd1c98639 100644 --- a/docs/api/guilds.rst +++ b/docs/api/guilds.rst @@ -19,7 +19,7 @@ Guild .. autoclass:: Guild() :members: - :exclude-members: fetch_members, audit_logs + :exclude-members: fetch_members, audit_logs, search_messages .. automethod:: fetch_members :async-for: @@ -27,6 +27,9 @@ Guild .. automethod:: audit_logs :async-for: + .. automethod:: search_messages + :async-for: + GuildPreview ~~~~~~~~~~~~ From 1ce5024d12585c014f456222f040dfc017b699eb Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 20:05:05 +0200 Subject: [PATCH 14/27] docs: document valid elements for literal sequence parameters --- disnake/guild.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index c97afa8b48..10112292ba 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5502,8 +5502,11 @@ def search_messages( Filter messages by channels (up to 500). author: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :data:`None` Filter messages by authors (up to 100). - author_type: TODO | :data:`None` + author_type: :class:`~collections.abc.Sequence`\[: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`] | :data:`None` Filter messages that mention these users (up to 100). mentions_role: :class:`~collections.abc.Sequence`\[:class:`.abc.Snowflake`] | :data:`None` @@ -5516,10 +5519,16 @@ def search_messages( Filter messages that reply to these messages (up to 100). pinned: :class:`bool` | :data:`None` Filter messages that are/aren't pinned. - has: TODO | :data:`None` + has: :class:`~collections.abc.Sequence`\[:class:`str`] | :data:`None` Filter messages by whether or not they have specific things. - embed_type: TODO | :data:`None` + + 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`] | :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`] | :data:`None` Filter messages by embed provider (up to 100, with up to 256 characters each). link_hostname: :class:`~collections.abc.Sequence`\[:class:`str`] | :data:`None` From 129905ea318dd32acc899a0511336a8416a7f919 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 20:21:32 +0200 Subject: [PATCH 15/27] docs: document MessageSearchSortBy enum --- disnake/enums.py | 8 ++++++++ docs/api/guilds.rst | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/disnake/enums.py b/disnake/enums.py index 543039cfdf..97a23eb467 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -2518,9 +2518,17 @@ class NameplatePalette(Enum): class MessageSearchSortBy(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: diff --git a/docs/api/guilds.rst b/docs/api/guilds.rst index ccd1c98639..09cc5ea584 100644 --- a/docs/api/guilds.rst +++ b/docs/api/guilds.rst @@ -184,6 +184,12 @@ OnboardingPromptType .. autoclass:: OnboardingPromptType() :members: +MessageSearchSortBy +~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: MessageSearchSortBy() + :members: + Events ------ From e0fc4e9f45ecbfde1345bc071e96735b49f5dd6b Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 19 May 2026 20:21:53 +0200 Subject: [PATCH 16/27] docs: add required permissions/intents, raised errors, yields --- disnake/guild.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/disnake/guild.py b/disnake/guild.py index 10112292ba..18ea56d48d 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5478,6 +5478,9 @@ def search_messages( 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. + Parameters ---------- limit: :class:`int` | :data:`None` @@ -5542,7 +5545,23 @@ def search_messages( 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``. + 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. + RuntimeError + FIXME + Exceeded maximum number of retries while waiting for messages to finish indexing. + + Yields + ------ + :class:`.Message` + The message matching the given query parameters. """ query: dict[str, str | int | bool | Sequence[str | int] | None] = { "content": content, From fca0acb3865c1d2e0305d2753cd6f86ee376e6ae Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 13:11:43 +0200 Subject: [PATCH 17/27] refactor: rename MessageSearchSortBy -> MessageSearchSortMode --- disnake/enums.py | 14 +++++++------- disnake/guild.py | 8 ++++---- docs/api/guilds.rst | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/disnake/enums.py b/disnake/enums.py index 97a23eb467..a5801423e7 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -76,7 +76,7 @@ "MessageReferenceType", "SeparatorSpacing", "NameplatePalette", - "MessageSearchSortBy", + "MessageSearchSortMode", ) EnumMetaT = TypeVar("EnumMetaT", bound="EnumMeta") @@ -2517,7 +2517,7 @@ class NameplatePalette(Enum): """White color palette.""" -class MessageSearchSortBy(Enum): +class MessageSearchSortMode(Enum): """Represents the sorting algorithm/direction used for :meth:`Guild.search_messages`. .. versionadded:: |vnext| @@ -2533,16 +2533,16 @@ class MessageSearchSortBy(Enum): @property def sort_key(self) -> str: return { - MessageSearchSortBy.timestamp_desc: "timestamp", - MessageSearchSortBy.timestamp_asc: "timestamp", - MessageSearchSortBy.relevance: "relevance", + MessageSearchSortMode.timestamp_desc: "timestamp", + MessageSearchSortMode.timestamp_asc: "timestamp", + MessageSearchSortMode.relevance: "relevance", }[self] @property def sort_order(self) -> str | None: return { - MessageSearchSortBy.timestamp_desc: "desc", - MessageSearchSortBy.timestamp_asc: "asc", + MessageSearchSortMode.timestamp_desc: "desc", + MessageSearchSortMode.timestamp_asc: "asc", }.get(self) diff --git a/disnake/guild.py b/disnake/guild.py index 18ea56d48d..dad8c41e91 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -44,7 +44,7 @@ GuildScheduledEventEntityType, GuildScheduledEventPrivacyLevel, Locale, - MessageSearchSortBy, + MessageSearchSortMode, NotificationLevel, NSFWLevel, ThreadLayout, @@ -5447,7 +5447,7 @@ def search_messages( limit: int | None = 25, before: SnowflakeTime | None = None, after: SnowflakeTime | None = None, - sort: MessageSearchSortBy = MessageSearchSortBy.timestamp_desc, + sort: MessageSearchSortMode = MessageSearchSortMode.timestamp_desc, # search filters content: str | None = None, slop: int | None = None, @@ -5496,9 +5496,9 @@ def search_messages( 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:`MessageSearchSortBy` + sort: :class:`MessageSearchSortMode` The sorting algorithm/direction to use for retrieving search results. - Defaults to :attr:`~MessageSearchSortBy.timestamp_desc`. + 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`] | :data:`None` diff --git a/docs/api/guilds.rst b/docs/api/guilds.rst index 09cc5ea584..17c25e285c 100644 --- a/docs/api/guilds.rst +++ b/docs/api/guilds.rst @@ -184,10 +184,10 @@ OnboardingPromptType .. autoclass:: OnboardingPromptType() :members: -MessageSearchSortBy -~~~~~~~~~~~~~~~~~~~ +MessageSearchSortMode +~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: MessageSearchSortBy() +.. autoclass:: MessageSearchSortMode() :members: Events From cf98c05e8107b36c770d1d7e6bb89a8bb35ed401 Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 13:19:27 +0200 Subject: [PATCH 18/27] perf: use match/case instead of {...}[self] --- disnake/enums.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/disnake/enums.py b/disnake/enums.py index a5801423e7..aac8df8f62 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -2532,18 +2532,23 @@ class MessageSearchSortMode(Enum): @property def sort_key(self) -> str: - return { - MessageSearchSortMode.timestamp_desc: "timestamp", - MessageSearchSortMode.timestamp_asc: "timestamp", - MessageSearchSortMode.relevance: "relevance", - }[self] + 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: - return { - MessageSearchSortMode.timestamp_desc: "desc", - MessageSearchSortMode.timestamp_asc: "asc", - }.get(self) + match self: + case MessageSearchSortMode.timestamp_desc: + return "desc" + case MessageSearchSortMode.timestamp_asc: + return "asc" + case MessageSearchSortMode.relevance: + return None T = TypeVar("T", bound="Enum") From eae54583e2ba51a35ec7c253285825c3cb047a8e Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 13:22:31 +0200 Subject: [PATCH 19/27] fix: set `include_nsfw` default to false, matching api --- disnake/guild.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index dad8c41e91..c46d4aa385 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5468,8 +5468,7 @@ def search_messages( link_hostname: Sequence[str] | None = None, attachment_filename: Sequence[str] | None = None, attachment_extension: Sequence[str] | None = None, - # TODO: make this not default to None - include_nsfw: bool | None = None, + include_nsfw: bool = False, # for handling indexing errors retries: int = 3, ) -> MessageSearchIterator: @@ -5540,7 +5539,7 @@ def search_messages( Filter messages by attachment filename (up too 100, with up to 1024 characters each). attachment_extension: :class:`~collections.abc.Sequence`\[: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` | :data:`None` + 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. From a25bcd3e6bf0843604a7f3150f47e3e572d3bb59 Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 13:49:53 +0200 Subject: [PATCH 20/27] feat(typing): add fully-typed request query dict --- disnake/guild.py | 69 +++++++++++++++++++++++----------------- disnake/http.py | 4 +-- disnake/iterators.py | 6 ++-- disnake/types/message.py | 30 +++++++++++++++++ 4 files changed, 75 insertions(+), 34 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index c46d4aa385..a44a72ec3c 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -113,6 +113,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 @@ -5562,39 +5563,49 @@ def search_messages( :class:`.Message` The message matching the given query parameters. """ - query: dict[str, str | int | bool | Sequence[str | int] | None] = { - "content": content, - "slop": slop, - "channel": [c.id for c in channel] if channel else None, - "author": [a.id for a in author] if author else None, - "author_type": author_type, - "mentions": [m.id for m in mentions] if mentions else None, - "mentions_role": [r.id for r in mentions_role] if mentions_role else None, - "mentions_everyone": mentions_everyone, - "replied_to_user": [u.id for u in replied_to_user] if replied_to_user else None, - "replied_to_message": ( - [m.id for m in replied_to_message] if replied_to_message else None - ), - "pinned": pinned, - "has": has, - "embed_type": embed_type, - "embed_provider": embed_provider, - "link_hostname": link_hostname, - "attachment_filename": attachment_filename, - "attachment_extension": attachment_extension, - "include_nsfw": include_nsfw, - } + query: MessageSearchQuery = {"include_nsfw": include_nsfw} query["sort_by"] = sort.sort_key - query["sort_order"] = sort.sort_order + 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"] = [c.id for c in channel] + if author is not None: + query["author_id"] = [a.id for a in author] + if author_type is not None: + query["author_type"] = author_type + if mentions is not None: + query["mentions"] = [m.id for m in mentions] + if mentions_role is not None: + query["mentions_role"] = [r.id for r in 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"] = [u.id for u in replied_to_user] + if replied_to_message is not None: + query["replied_to_message_id"] = [m.id for m in replied_to_message] + if pinned is not None: + query["pinned"] = pinned + if has is not None: + query["has"] = has + if embed_type is not None: + query["embed_type"] = embed_type + if embed_provider is not None: + query["embed_provider"] = embed_provider + if link_hostname is not None: + query["link_hostname"] = link_hostname + if attachment_filename is not None: + query["attachment_filename"] = attachment_filename + if attachment_extension is not None: + query["attachment_extension"] = attachment_extension return MessageSearchIterator( - self, - {k: v for k, v in query.items() if v is not None}, - retries=retries, - limit=limit, - before=before, - after=after, + self, query, retries=retries, limit=limit, before=before, after=after ) diff --git a/disnake/http.py b/disnake/http.py index 798f679a68..388e307a67 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -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, @@ -910,7 +910,7 @@ def get_pins( return self.request(r, params=params) def search_guild_messages( - self, guild_id: Snowflake, params: dict[str, Any] + 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()} diff --git a/disnake/iterators.py b/disnake/iterators.py index faa4c47e40..6f8456818e 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -5,7 +5,7 @@ import asyncio import datetime import logging -from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Generator from typing import ( TYPE_CHECKING, Any, @@ -60,7 +60,7 @@ GuildScheduledEventUser as GuildScheduledEventUserPayload, ) from .types.member import MemberWithUser as MemberWithUserPayload - from .types.message import Message as MessagePayload, MessageSearchResult + from .types.message import Message as MessagePayload, MessageSearchQuery, MessageSearchResult from .types.subscription import Subscription as SubscriptionPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload @@ -1408,7 +1408,7 @@ class MessageSearchIterator(_AsyncIterator["Message"]): def __init__( self, guild: Guild, - query: dict[str, str | int | bool | Sequence[str | int]], + query: MessageSearchQuery, *, retries: int, limit: int | None, diff --git a/disnake/types/message.py b/disnake/types/message.py index a8649a7e29..d079100251 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Literal, TypedDict from typing_extensions import NotRequired @@ -171,6 +172,35 @@ class MessageCall(TypedDict): ended_timestamp: NotRequired[str | None] +class MessageSearchQuery(TypedDict, total=False): + # pagination + limit: int + offset: int + max_id: Snowflake + min_id: Snowflake + # query + slop: int + content: str + channel_id: Sequence[Snowflake] + author_type: Sequence[str] + author_id: Sequence[Snowflake] + mentions: Sequence[Snowflake] + mentions_role: Sequence[Snowflake] + mentions_everyone: bool + replied_to_user_id: Sequence[Snowflake] + replied_to_message_id: Sequence[Snowflake] + pinned: bool + has: Sequence[str] + embed_type: Sequence[str] + embed_provider: Sequence[str] + link_hostname: Sequence[str] + attachment_filename: Sequence[str] + attachment_extension: Sequence[str] + sort_by: str + sort_order: str + include_nsfw: bool + + class MessageSearchResult(TypedDict): doing_deep_historical_index: bool documents_indexed: NotRequired[int] From 24eaac2677de626c56dacfdb6c91c24648275537 Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 14:01:43 +0200 Subject: [PATCH 21/27] feat: support `T | Sequence[T]` in args, also avoiding str ~ Sequence[str] footgun --- disnake/guild.py | 79 +++++++++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index a44a72ec3c..6472be1c32 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5453,22 +5453,21 @@ def search_messages( content: str | None = None, slop: int | None = None, # TODO: channel/author vs channels/authors - channel: Sequence[Snowflake] | None = None, - author: Sequence[Snowflake] | None = None, - author_type: Sequence[MessageSearchAuthorType] | None = None, - mentions: Sequence[Snowflake] | None = None, - mentions_role: Sequence[Snowflake] | 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] | None = None, - replied_to_message: Sequence[Snowflake] | None = None, + replied_to_user: Sequence[Snowflake] | Snowflake | None = None, + replied_to_message: Sequence[Snowflake] | Snowflake | None = None, pinned: bool | None = None, - has: Sequence[MessageSearchHasThing] | None = None, - embed_type: Sequence[MessageSearchEmbedType] | None = None, - # XXX: `str` is also `Sequence[str]`, account for this - embed_provider: Sequence[str] | None = None, - link_hostname: Sequence[str] | None = None, - attachment_filename: Sequence[str] | None = None, - attachment_extension: Sequence[str] | None = None, + has: Sequence[MessageSearchHasThing] | MessageSearchHasThing | 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, @@ -5501,44 +5500,44 @@ def search_messages( 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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`] | :data:`None` + 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``. @@ -5574,34 +5573,60 @@ def search_messages( if slop is not None: query["slop"] = slop if channel is not None: + if isinstance(channel, abc.Snowflake): + channel = [channel] query["channel_id"] = [c.id for c in channel] if author is not None: + if isinstance(author, abc.Snowflake): + author = [author] query["author_id"] = [a.id for a in author] if author_type is not None: + if isinstance(author_type, str): + author_type = [author_type] query["author_type"] = author_type if mentions is not None: + if isinstance(mentions, abc.Snowflake): + mentions = [mentions] query["mentions"] = [m.id for m in mentions] if mentions_role is not None: + if isinstance(mentions_role, abc.Snowflake): + mentions_role = [mentions_role] query["mentions_role"] = [r.id for r in mentions_role] if mentions_everyone is not None: query["mentions_everyone"] = mentions_everyone if replied_to_user is not None: + if isinstance(replied_to_user, abc.Snowflake): + replied_to_user = [replied_to_user] query["replied_to_user_id"] = [u.id for u in replied_to_user] if replied_to_message is not None: + if isinstance(replied_to_message, abc.Snowflake): + replied_to_message = [replied_to_message] query["replied_to_message_id"] = [m.id for m in replied_to_message] if pinned is not None: query["pinned"] = pinned if has is not None: + if isinstance(has, str): + has = [has] query["has"] = has if embed_type is not None: + if isinstance(embed_type, str): + embed_type = [embed_type] query["embed_type"] = embed_type if embed_provider is not None: + if isinstance(embed_provider, str): + embed_provider = [embed_provider] query["embed_provider"] = embed_provider if link_hostname is not None: + if isinstance(link_hostname, str): + link_hostname = [link_hostname] query["link_hostname"] = link_hostname if attachment_filename is not None: + if isinstance(attachment_filename, str): + attachment_filename = [attachment_filename] query["attachment_filename"] = attachment_filename if attachment_extension is not None: + if isinstance(attachment_extension, str): + attachment_extension = [attachment_extension] query["attachment_extension"] = attachment_extension return MessageSearchIterator( From 1ad22d98c9cf044c2239ce56798729d0d6aefd8d Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 14:07:21 +0200 Subject: [PATCH 22/27] chore: attempt to de-clutter query construction --- disnake/guild.py | 64 ++++++++++++++++++------------------------------ 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index 6472be1c32..ad26d94fe4 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5452,7 +5452,6 @@ def search_messages( # search filters content: str | None = None, slop: int | None = None, - # TODO: channel/author vs channels/authors channel: Sequence[Snowflake] | Snowflake | None = None, author: Sequence[Snowflake] | Snowflake | None = None, author_type: Sequence[MessageSearchAuthorType] | MessageSearchAuthorType | None = None, @@ -5562,6 +5561,17 @@ def search_messages( :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 @@ -5573,61 +5583,35 @@ def search_messages( if slop is not None: query["slop"] = slop if channel is not None: - if isinstance(channel, abc.Snowflake): - channel = [channel] - query["channel_id"] = [c.id for c in channel] + query["channel_id"] = listify_snowflakes(channel) if author is not None: - if isinstance(author, abc.Snowflake): - author = [author] - query["author_id"] = [a.id for a in author] + query["author_id"] = listify_snowflakes(author) if author_type is not None: - if isinstance(author_type, str): - author_type = [author_type] - query["author_type"] = author_type + query["author_type"] = listify_strs(author_type) if mentions is not None: - if isinstance(mentions, abc.Snowflake): - mentions = [mentions] - query["mentions"] = [m.id for m in mentions] + query["mentions"] = listify_snowflakes(mentions) if mentions_role is not None: - if isinstance(mentions_role, abc.Snowflake): - mentions_role = [mentions_role] - query["mentions_role"] = [r.id for r in mentions_role] + 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: - if isinstance(replied_to_user, abc.Snowflake): - replied_to_user = [replied_to_user] - query["replied_to_user_id"] = [u.id for u in replied_to_user] + query["replied_to_user_id"] = listify_snowflakes(replied_to_user) if replied_to_message is not None: - if isinstance(replied_to_message, abc.Snowflake): - replied_to_message = [replied_to_message] - query["replied_to_message_id"] = [m.id for m in replied_to_message] + query["replied_to_message_id"] = listify_snowflakes(replied_to_message) if pinned is not None: query["pinned"] = pinned if has is not None: - if isinstance(has, str): - has = [has] - query["has"] = has + query["has"] = listify_strs(has) if embed_type is not None: - if isinstance(embed_type, str): - embed_type = [embed_type] - query["embed_type"] = embed_type + query["embed_type"] = listify_strs(embed_type) if embed_provider is not None: - if isinstance(embed_provider, str): - embed_provider = [embed_provider] - query["embed_provider"] = embed_provider + query["embed_provider"] = listify_strs(embed_provider) if link_hostname is not None: - if isinstance(link_hostname, str): - link_hostname = [link_hostname] - query["link_hostname"] = link_hostname + query["link_hostname"] = listify_strs(link_hostname) if attachment_filename is not None: - if isinstance(attachment_filename, str): - attachment_filename = [attachment_filename] - query["attachment_filename"] = attachment_filename + query["attachment_filename"] = listify_strs(attachment_filename) if attachment_extension is not None: - if isinstance(attachment_extension, str): - attachment_extension = [attachment_extension] - query["attachment_extension"] = attachment_extension + query["attachment_extension"] = listify_strs(attachment_extension) return MessageSearchIterator( self, query, retries=retries, limit=limit, before=before, after=after From 6ea3cb280db80e39e591bb3b34efcd3afa9481c9 Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 14:22:34 +0200 Subject: [PATCH 23/27] feat: add custom MessageSearchIndexUnavailableError --- disnake/errors.py | 19 +++++++++++++++++++ disnake/guild.py | 3 +-- disnake/iterators.py | 8 ++++---- docs/api/exceptions.rst | 6 ++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/disnake/errors.py b/disnake/errors.py index 69bd9d5c16..40e5b5ec4f 100644 --- a/disnake/errors.py +++ b/disnake/errors.py @@ -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 @@ -36,6 +37,7 @@ "ModalChainNotSupported", "InteractionNotEditable", "LocalizationKeyError", + "MessageSearchIndexUnavailableError", ) @@ -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.") diff --git a/disnake/guild.py b/disnake/guild.py index ad26d94fe4..14caef9aae 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5552,8 +5552,7 @@ def search_messages( or the :attr:`~Intents.message_content` intent is not enabled. HTTPException Retrieving the search results failed. - RuntimeError - FIXME + MessageSearchIndexUnavailableError Exceeded maximum number of retries while waiting for messages to finish indexing. Yields diff --git a/disnake/iterators.py b/disnake/iterators.py index 6f8456818e..d4b2a33ee7 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -19,7 +19,7 @@ from .automod import AutoModRule from .bans import BanEntry from .entitlement import Entitlement -from .errors import NoMoreItems +from .errors import MessageSearchIndexUnavailableError, NoMoreItems from .guild_scheduled_event import GuildScheduledEvent from .integrations import PartialIntegration from .object import Object @@ -1465,16 +1465,16 @@ async def _try_fetch(self) -> MessageSearchResult: ) if "code" not in data: + # success return data + if data["code"] != 110000: msg = f"Received unexpected error code {data['code']}" raise RuntimeError(msg) # if we have `"code": 110000`, this was a 202 response and message indexing is likely still in progress if retries >= self.max_retries: - # TODO: custom error? - msg = "Exhausted retries while message search indexing is still in progress" - raise RuntimeError(msg) + raise MessageSearchIndexUnavailableError(self.guild) retry_after = data["retry_after"] # "If the retry_after field is 0, you should retry the request after a short delay." diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst index be2e773ae9..b9f2a723de 100644 --- a/docs/api/exceptions.rst +++ b/docs/api/exceptions.rst @@ -111,6 +111,11 @@ LocalizationKeyError .. autoexception:: LocalizationKeyError +MessageSearchIndexUnavailableError +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoexception:: MessageSearchIndexUnavailableError + OpusError ~~~~~~~~~ @@ -146,6 +151,7 @@ Exception Hierarchy - :exc:`NotFound` - :exc:`DiscordServerError` - :exc:`LocalizationKeyError` + - :exc:`MessageSearchIndexUnavailableError` - :exc:`WebhookTokenMissing` From 346eff40880df801a395948fe9b70c53cd1b19b6 Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 14:23:01 +0200 Subject: [PATCH 24/27] docs: add missing versionadded --- disnake/guild.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/disnake/guild.py b/disnake/guild.py index 14caef9aae..4b3ac43add 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -5479,6 +5479,8 @@ def search_messages( 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` From 392b6690e26de05c4f3839d350a53c49f4867e8d Mon Sep 17 00:00:00 2001 From: vi Date: Wed, 20 May 2026 15:05:22 +0200 Subject: [PATCH 25/27] chore: add changelog entry --- changelog/1522.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/1522.feature.rst diff --git a/changelog/1522.feature.rst b/changelog/1522.feature.rst new file mode 100644 index 0000000000..18b54c793d --- /dev/null +++ b/changelog/1522.feature.rst @@ -0,0 +1 @@ +Add :meth:`Guild.search_messages`, which allows searching for messages in a guild or channels using several different query parameters. From 1f8f27ef37d1e63884359453600c7afac8408972 Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 30 Jun 2026 21:41:10 +0200 Subject: [PATCH 26/27] chore: resolve name comment --- disnake/guild.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/disnake/guild.py b/disnake/guild.py index 81e9d1ee34..e4a5eabb71 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -79,7 +79,7 @@ __all__ = ( "MessageSearchAuthorType", - "MessageSearchHasThing", + "MessageSearchHasType", "MessageSearchEmbedType", "IncidentsData", "Guild", @@ -121,15 +121,16 @@ ByCategoryItem: TypeAlias = tuple[CategoryChannel | None, list[GuildChannel]] -# XXX: these are here such that they can (in theory) be used at runtime; disnake.types isn't necessarily runtime-importable due to cycles +# 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" ] -# TODO: name -MessageSearchHasThing = Literal[ +MessageSearchHasType = Literal[ "image", "-image", "sound", "-sound", "video", "-video", @@ -5393,7 +5394,7 @@ def search_messages( replied_to_user: Sequence[Snowflake] | Snowflake | None = None, replied_to_message: Sequence[Snowflake] | Snowflake | None = None, pinned: bool | None = None, - has: Sequence[MessageSearchHasThing] | MessageSearchHasThing | 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, From 4721a711819f9ee60c59c5cc03b0190c4fe631bd Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 30 Jun 2026 21:41:52 +0200 Subject: [PATCH 27/27] fix: "short delay" is very subjective, let's increase it to 1 second --- disnake/iterators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 2a6452c6a1..d60e4bfb2b 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1482,7 +1482,7 @@ async def _try_fetch(self) -> MessageSearchResult: retry_after = data["retry_after"] # "If the retry_after field is 0, you should retry the request after a short delay." - retry_after = max(retry_after, 0.25) + retry_after = max(retry_after, 1) _log.info( "Message search index for guild ID %d is not yet available. Retrying in %.2fs.", self.guild.id,