Integrate Twitch API for ad scheduling and enhance widget support#668
Integrate Twitch API for ad scheduling and enhance widget support#668saebyn wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an ad_timer widget type and wires Twitch ad-break events + periodic polling into the system so widget state can reflect ad breaks in near real time.
Changes:
- Extend StreamWidget schema/types to include
ad_timer. - Add a new widget updater path that polls Twitch’s ad schedule API and writes widget state updates.
- Subscribe to
channel.ad_break.beginvia EventSub and update matching widgets immediately; update CDK to pass secrets and permissions.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| widget_updater_lambda/src/updaters/mod.rs | Exposes the new ad_timer updater module. |
| widget_updater_lambda/src/updaters/ad_timer.rs | Implements Twitch ad schedule polling + state computation for ad_timer widgets. |
| widget_updater_lambda/src/main.rs | Adds Secrets Manager + HTTP client wiring and routes ad_timer updates through async logic. |
| widget_updater_lambda/Cargo.toml | Adds dependencies needed for Twitch polling (Secrets Manager, reqwest, gt_secrets). |
| chat_processor_lambda/src/main.rs | Handles channel.ad_break.begin events and writes immediate widget state changes to DynamoDB. |
| twitch_lambda/src/handlers.rs | Refactors EventSub subscription creation and adds ad-break EventSub subscription. |
| cdk/lib/widgetUpdater.ts | Passes Twitch secret info to widget-updater lambda, grants permissions, and schedules ad_timer polling. |
| cdk/lib/twitchChatProcessing.ts | Injects STREAM_WIDGETS_TABLE and grants DynamoDB permissions for widget updates. |
| cdk/lib/appStack.ts | Wires streamWidgetsTable into chat processing and passes Twitch secret props to widget updater. |
| cdk/lib/api.ts | Exposes twitchAppSecret for downstream constructs. |
| docs/v2/schemas/StreamWidget.schema | Adds ad_timer to the widget type enum. |
| types/src/types.ts | Adds ad_timer to the StreamWidgetType union. |
| types/src/types.rs | Adds StreamWidgetType::AdTimer and minor formatting adjustments. |
| Cargo.lock | Locks new Rust dependencies for widget_updater_lambda. |
|
@copilot apply changes based on the comments in this thread |
…updates, token validation Agent-Logs-Url: https://github.com/saebyn/glowing-telegram/sessions/2fe3d96f-3907-4984-bb6c-888603a5b4fe Co-authored-by: saebyn <185030+saebyn@users.noreply.github.com>
Applied all changes from the review thread in commit
|
| // Query the user_id-index GSI to efficiently find active ad_timer widgets | ||
| // for this broadcaster, and paginate through all results. | ||
| let mut items: Vec<HashMap<String, AttributeValue>> = Vec::new(); | ||
| let mut exclusive_start_key: Option<HashMap<String, AttributeValue>> = None; | ||
|
|
||
| loop { | ||
| let mut query_builder = context | ||
| .dynamodb | ||
| .query() | ||
| .table_name(&context.config.stream_widgets_table) | ||
| .index_name("user_id-index") | ||
| .key_condition_expression("#user_id = :broadcaster_id") | ||
| .filter_expression("#type = :widget_type AND #active = :active") | ||
| .expression_attribute_names("#user_id", "user_id") | ||
| .expression_attribute_names("#type", "type") | ||
| .expression_attribute_names("#active", "active") | ||
| .expression_attribute_values( | ||
| ":broadcaster_id", | ||
| AttributeValue::S(event.broadcaster_user_id.clone()), | ||
| ) |
There was a problem hiding this comment.
user_id-index is described as the primary access pattern for “get all widgets for a user” (i.e., the widget owner). Here the query uses event.broadcaster_user_id (a Twitch broadcaster ID) as user_id, but elsewhere in this PR the widget user_id is treated as a Cognito user id (used for per-user Secrets Manager paths). As written, this query will not find any widgets, so the ad-break push update will never run. Consider storing the Twitch broadcaster id on the widget item (e.g., twitch_broadcaster_id) and querying on a dedicated GSI, or otherwise mapping Twitch broadcaster_id -> Cognito user_id before querying/updating widgets.
| // Compute when the ad break ends so the frontend can derive status correctly. | ||
| let back_from_ads_until = | ||
| chrono::DateTime::parse_from_rfc3339(&event.started_at) | ||
| .ok() | ||
| .map(|t| { | ||
| (t + chrono::Duration::seconds(event.duration_seconds)) | ||
| .to_rfc3339() | ||
| }); |
There was a problem hiding this comment.
backFromAdsUntil is computed here as “ad break ends at” (started_at + duration_seconds), but in the widget updater the same field is documented/used as “until when to show the 'Back from Ads' message after the break ends” (i.e., a post-break window). These conflicting semantics will make the state hard to reason about and can cause the frontend to show the wrong status depending on which writer last updated the field. Align on one meaning (or introduce separate fields such as adBreakEndsAt vs backFromAdsUntil).
| .cloned() | ||
| .unwrap_or_default(); | ||
|
|
There was a problem hiding this comment.
user_id is required for ad_timer updates (Secrets Manager lookup uses it to build the secret path), but missing user_id is silently converted to an empty string here. That can hide data issues and leads to confusing downstream errors (secret path becomes .../). Consider returning an error when user_id is missing so the widget is surfaced as malformed during deserialization.
| .cloned() | |
| .unwrap_or_default(); | |
| .cloned(); | |
| let user_id = match (&stream_widget_type, user_id) { | |
| (StreamWidgetType::AdTimer, Some(id)) if !id.is_empty() => id, | |
| (StreamWidgetType::AdTimer, _) => { | |
| return Err("Missing user_id for ad_timer widget".into()); | |
| } | |
| (_, Some(id)) => id, | |
| (_, None) => String::new(), | |
| }; |
| actions: [ | ||
| 'dynamodb:Scan', | ||
| 'dynamodb:UpdateItem', | ||
| 'dynamodb:GetItem', | ||
| ], | ||
| resources: [ | ||
| props.streamWidgetsTable.tableArn, | ||
| `${props.streamWidgetsTable.tableArn}/index/*`, | ||
| ], | ||
| }), |
There was a problem hiding this comment.
The chat processor now calls DynamoDB Query (including on the user_id-index GSI) to find ad_timer widgets, but this IAM policy does not grant dynamodb:Query. This will fail at runtime with AccessDenied. Add dynamodb:Query (and you can likely drop dynamodb:Scan if it’s no longer used).
AppContext::newto panic on missing/invalid Twitch secret (fail fast)deserialize_widgetto deserializeuser_idfrom DynamoDBcompute_new_stateto returnNonewhen state unchanged (avoid unnecessary writes)/validatecall to get real broadcaster_id (not Cognito sub)TwitchValidateResponseto module level for reusabilitycompute_new_state(nextAdAt, snooze, backFromAdsUntil, no-op)user_id-indexGSI in chat_processor!start_key.is_empty()check in pagination loopcreate_eventsub_subscriptionto returnNonefor 409 (not "already_exists")Option<String>subscription_id correctly⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.