From ea537b03800f4845774e38cee35cc64be33ba625 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sat, 10 Jan 2026 11:06:58 +0100 Subject: [PATCH 1/6] account for message to be None in for some auto redemptions --- twitchio/models/eventsub_.py | 39 +++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/twitchio/models/eventsub_.py b/twitchio/models/eventsub_.py index 7656a165..4994c038 100644 --- a/twitchio/models/eventsub_.py +++ b/twitchio/models/eventsub_.py @@ -3406,7 +3406,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): The user who redeemed the reward. id: str The ID of the redemption. - text: str + text: str | None The text of the chat message. redeemed_at: datetime.datetime The datetime object of when the reward was redeemed. @@ -3415,7 +3415,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): V2 does not cover Power-ups e.g. `gigantify_an_emote`, `celebration`, and `message_effect`. Please see ChannelBitsUseSubscription for those specific types if using V2. - emotes: list[ChannelPointsEmote] + emotes: list[ChannelPointsEmote] | None A list of ChannelPointsEmote objects that appear in the text. - If using V1, this is populated by Twitch. @@ -3423,7 +3423,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): user_input: str | None The text input by the user if the reward requires input. This is `None` when using V2. `text` is the preferred attribute to use. - fragments: list[ChatMessageFragment] + fragments: list[ChatMessageFragment] | None The ordered list of chat message fragments. This is only populated when using V2. """ @@ -3437,29 +3437,36 @@ def __init__(self, payload: ChannelPointsAutoRewardRedemptionEvent, *, http: HTT ) self.user: PartialUser = PartialUser(payload["user_id"], payload["user_login"], payload["user_name"], http=http) self.id: str = payload["id"] - self.text: str = payload["message"]["text"] + message = payload.get("message") + self.text: str | None = payload["message"].get("text") if message is not None else None self.user_input: str | None = payload.get("user_input") self.redeemed_at: datetime.datetime = parse_timestamp(payload["redeemed_at"]) self.reward: AutoRedeemReward = AutoRedeemReward(payload["reward"]) - fragments = payload["message"].get("fragments", []) - self.fragments: list[ChatMessageFragment] = [ChatMessageFragment(f, http=http) for f in fragments] - self._raw_emotes = payload.get("message", {}).get("emotes", []) + self.fragments: list[ChatMessageFragment] | None = ( + [ChatMessageFragment(f, http=http) for f in payload["message"].get("fragments", [])] + if message is not None and payload["message"].get("fragments") is not None else None + ) + self._raw_emotes: list[ChannelPointsEmote] | None = payload.get("message", {}).get("emotes", []) if message is not None else None + def __repr__(self) -> str: return f"" @property - def emotes(self) -> list[ChannelPointsEmote]: + def emotes(self) -> list[ChannelPointsEmote] | None: if self._raw_emotes: return [ChannelPointsEmote(emote) for emote in self._raw_emotes] - lengths = [len(frag.text) for frag in self.fragments] - offsets = [0, *list(accumulate(lengths))] - - return [ - ChannelPointsEmote({"id": frag.emote.id, "begin": offsets[i], "end": offsets[i + 1] - 1}) - for i, frag in enumerate(self.fragments) - if frag.type == "emote" and frag.emote is not None - ] + if self.fragments: + lengths = [len(frag.text) for frag in self.fragments] + offsets = [0, *list(accumulate(lengths))] + + return [ + ChannelPointsEmote({"id": frag.emote.id, "begin": offsets[i], "end": offsets[i + 1] - 1}) + for i, frag in enumerate(self.fragments) + if frag.type == "emote" and frag.emote is not None + ] + else: + return None class CooldownSettings(NamedTuple): From 3e9e4e94a6a82614da8defa85c6431241633471a Mon Sep 17 00:00:00 2001 From: Jacky Date: Sat, 10 Jan 2026 11:20:08 +0100 Subject: [PATCH 2/6] docu for new None attributes --- twitchio/models/eventsub_.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/twitchio/models/eventsub_.py b/twitchio/models/eventsub_.py index 4994c038..8b2d4ed8 100644 --- a/twitchio/models/eventsub_.py +++ b/twitchio/models/eventsub_.py @@ -3407,7 +3407,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): id: str The ID of the redemption. text: str | None - The text of the chat message. + The text of the chat message. This is `None` for redemptions that do not contain a message (e.g. emote unlocks). redeemed_at: datetime.datetime The datetime object of when the reward was redeemed. reward: AutoRedeemReward @@ -3416,7 +3416,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): V2 does not cover Power-ups e.g. `gigantify_an_emote`, `celebration`, and `message_effect`. Please see ChannelBitsUseSubscription for those specific types if using V2. emotes: list[ChannelPointsEmote] | None - A list of ChannelPointsEmote objects that appear in the text. + A list of ChannelPointsEmote objects that appear in the text. This is `None` for redemptions that do not contain a message. - If using V1, this is populated by Twitch. - If using V2, the emotes can be found in the fragments, but we calculate the index ourselves for this property. @@ -3424,7 +3424,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): user_input: str | None The text input by the user if the reward requires input. This is `None` when using V2. `text` is the preferred attribute to use. fragments: list[ChatMessageFragment] | None - The ordered list of chat message fragments. This is only populated when using V2. + The ordered list of chat message fragments. This is only populated when using V2. This is `None` for redemptions that do not contain a message. """ subscription_type = "channel.channel_points_automatic_reward_redemption.add" @@ -3448,7 +3448,6 @@ def __init__(self, payload: ChannelPointsAutoRewardRedemptionEvent, *, http: HTT ) self._raw_emotes: list[ChannelPointsEmote] | None = payload.get("message", {}).get("emotes", []) if message is not None else None - def __repr__(self) -> str: return f"" From 1d1eab9e855a209c0b52e1f48f99b62675662650 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sat, 10 Jan 2026 11:39:08 +0100 Subject: [PATCH 3/6] changelog added --- docs/getting-started/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index b6d3e6e3..74db26ba 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -26,6 +26,7 @@ Changelog - Bug fixes - Fix :func:`~twitchio.utils.setup_logging` breaking coloured formatting on `CRITICAL` logging level + - Fix :class:`~models.eventsub_.ChannelPointsAutoRedeemAdd` now accounts for attribute message in payload to be None 3.1.0 From ed8d72f4072421ea706a29e33d32316e913b8450 Mon Sep 17 00:00:00 2001 From: Jacky Date: Tue, 13 Jan 2026 18:47:14 +0100 Subject: [PATCH 4/6] returns empty string for text and empty lists when message not provided --- twitchio/models/eventsub_.py | 44 ++++++++++++++++-------------------- twitchio/types_/eventsub.py | 2 +- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/twitchio/models/eventsub_.py b/twitchio/models/eventsub_.py index 8b2d4ed8..de34a09e 100644 --- a/twitchio/models/eventsub_.py +++ b/twitchio/models/eventsub_.py @@ -3406,8 +3406,8 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): The user who redeemed the reward. id: str The ID of the redemption. - text: str | None - The text of the chat message. This is `None` for redemptions that do not contain a message (e.g. emote unlocks). + text: str + The text of the chat message. Empty for redemptions that do not contain a message (e.g. emote unlocks). redeemed_at: datetime.datetime The datetime object of when the reward was redeemed. reward: AutoRedeemReward @@ -3415,16 +3415,16 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): V2 does not cover Power-ups e.g. `gigantify_an_emote`, `celebration`, and `message_effect`. Please see ChannelBitsUseSubscription for those specific types if using V2. - emotes: list[ChannelPointsEmote] | None - A list of ChannelPointsEmote objects that appear in the text. This is `None` for redemptions that do not contain a message. + emotes: list[ChannelPointsEmote] + A list of ChannelPointsEmote objects that appear in the text. Not populated for redemptions that do not contain a message. - If using V1, this is populated by Twitch. - If using V2, the emotes can be found in the fragments, but we calculate the index ourselves for this property. user_input: str | None The text input by the user if the reward requires input. This is `None` when using V2. `text` is the preferred attribute to use. - fragments: list[ChatMessageFragment] | None - The ordered list of chat message fragments. This is only populated when using V2. This is `None` for redemptions that do not contain a message. + fragments: list[ChatMessageFragment] + The ordered list of chat message fragments. This is only populated when using V2 or for redemptions that do not contain a message. """ subscription_type = "channel.channel_points_automatic_reward_redemption.add" @@ -3437,36 +3437,30 @@ def __init__(self, payload: ChannelPointsAutoRewardRedemptionEvent, *, http: HTT ) self.user: PartialUser = PartialUser(payload["user_id"], payload["user_login"], payload["user_name"], http=http) self.id: str = payload["id"] - message = payload.get("message") - self.text: str | None = payload["message"].get("text") if message is not None else None + message = payload.get("message") or {} + self.text: str = message.get("text", "") or "" self.user_input: str | None = payload.get("user_input") self.redeemed_at: datetime.datetime = parse_timestamp(payload["redeemed_at"]) self.reward: AutoRedeemReward = AutoRedeemReward(payload["reward"]) - self.fragments: list[ChatMessageFragment] | None = ( - [ChatMessageFragment(f, http=http) for f in payload["message"].get("fragments", [])] - if message is not None and payload["message"].get("fragments") is not None else None - ) - self._raw_emotes: list[ChannelPointsEmote] | None = payload.get("message", {}).get("emotes", []) if message is not None else None + fragments = message.get("fragments", []) + self.fragments: list[ChatMessageFragment] = [ChatMessageFragment(f, http=http) for f in fragments] + self._raw_emotes = message.get("emotes", []) def __repr__(self) -> str: return f"" @property - def emotes(self) -> list[ChannelPointsEmote] | None: + def emotes(self) -> list[ChannelPointsEmote]: if self._raw_emotes: return [ChannelPointsEmote(emote) for emote in self._raw_emotes] - if self.fragments: - lengths = [len(frag.text) for frag in self.fragments] - offsets = [0, *list(accumulate(lengths))] - - return [ - ChannelPointsEmote({"id": frag.emote.id, "begin": offsets[i], "end": offsets[i + 1] - 1}) - for i, frag in enumerate(self.fragments) - if frag.type == "emote" and frag.emote is not None - ] - else: - return None + lengths = [len(frag.text) for frag in self.fragments] + offsets = [0, *list(accumulate(lengths))] + return [ + ChannelPointsEmote({"id": frag.emote.id, "begin": offsets[i], "end": offsets[i + 1] - 1}) + for i, frag in enumerate(self.fragments) + if frag.type == "emote" and frag.emote is not None + ] class CooldownSettings(NamedTuple): """ diff --git a/twitchio/types_/eventsub.py b/twitchio/types_/eventsub.py index d8b170e1..abaaadb4 100644 --- a/twitchio/types_/eventsub.py +++ b/twitchio/types_/eventsub.py @@ -948,7 +948,7 @@ class BaseChannelPointsRewardData(TypedDict): class ChannelPointsAutoRewardRedemptionEvent(BroadcasterUserEvent): id: str reward: BaseChannelPointsRewardData - message: ChannelPointsMessageData + message: ChannelPointsMessageData | None user_input: str | None redeemed_at: str From 10b67766a2937d33b8d70dad3270a3a3cfaaed7a Mon Sep 17 00:00:00 2001 From: Jacky Date: Tue, 13 Jan 2026 20:34:15 +0100 Subject: [PATCH 5/6] cleanup --- twitchio/models/eventsub_.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/twitchio/models/eventsub_.py b/twitchio/models/eventsub_.py index de34a09e..5911a260 100644 --- a/twitchio/models/eventsub_.py +++ b/twitchio/models/eventsub_.py @@ -3407,7 +3407,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): id: str The ID of the redemption. text: str - The text of the chat message. Empty for redemptions that do not contain a message (e.g. emote unlocks). + The text of the chat message. This will be an empty string for redemptions that do not contain a message (e.g. emote unlocks). redeemed_at: datetime.datetime The datetime object of when the reward was redeemed. reward: AutoRedeemReward @@ -3415,7 +3415,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): V2 does not cover Power-ups e.g. `gigantify_an_emote`, `celebration`, and `message_effect`. Please see ChannelBitsUseSubscription for those specific types if using V2. - emotes: list[ChannelPointsEmote] + emotes: list[ChannelPointsEmote] A list of ChannelPointsEmote objects that appear in the text. Not populated for redemptions that do not contain a message. - If using V1, this is populated by Twitch. @@ -3424,7 +3424,7 @@ class ChannelPointsAutoRedeemAdd(_ResponderEvent): user_input: str | None The text input by the user if the reward requires input. This is `None` when using V2. `text` is the preferred attribute to use. fragments: list[ChatMessageFragment] - The ordered list of chat message fragments. This is only populated when using V2 or for redemptions that do not contain a message. + The ordered list of chat message fragments. This is only populated when using V2, and only for redemptions that contain a message. """ subscription_type = "channel.channel_points_automatic_reward_redemption.add" @@ -3438,13 +3438,13 @@ def __init__(self, payload: ChannelPointsAutoRewardRedemptionEvent, *, http: HTT self.user: PartialUser = PartialUser(payload["user_id"], payload["user_login"], payload["user_name"], http=http) self.id: str = payload["id"] message = payload.get("message") or {} - self.text: str = message.get("text", "") or "" + self.text: str = message.get("text", "") self.user_input: str | None = payload.get("user_input") self.redeemed_at: datetime.datetime = parse_timestamp(payload["redeemed_at"]) self.reward: AutoRedeemReward = AutoRedeemReward(payload["reward"]) fragments = message.get("fragments", []) self.fragments: list[ChatMessageFragment] = [ChatMessageFragment(f, http=http) for f in fragments] - self._raw_emotes = message.get("emotes", []) + self._raw_emotes = message.get("emotes", []) def __repr__(self) -> str: return f"" From ab2ddbc642fd82e6ec99a4b69340cd482030e75c Mon Sep 17 00:00:00 2001 From: Jacky Date: Tue, 13 Jan 2026 20:43:24 +0100 Subject: [PATCH 6/6] ruff format --- twitchio/models/eventsub_.py | 1 + 1 file changed, 1 insertion(+) diff --git a/twitchio/models/eventsub_.py b/twitchio/models/eventsub_.py index 5911a260..90c67335 100644 --- a/twitchio/models/eventsub_.py +++ b/twitchio/models/eventsub_.py @@ -3462,6 +3462,7 @@ def emotes(self) -> list[ChannelPointsEmote]: if frag.type == "emote" and frag.emote is not None ] + class CooldownSettings(NamedTuple): """ NamedTuple that represents a custom reward's cooldown settings.