feat(integration-twitch): Twitch EventSub webhook ingestion (data lake)#272
feat(integration-twitch): Twitch EventSub webhook ingestion (data lake)#272danielhe4rt wants to merge 5 commits into
Conversation
…d ETL dirs (#271) - Create integration-twitch CONTEXT.md with glossary, structure, and module boundaries - Add Integration Twitch to CONTEXT-MAP.md with dependency rules - Broaden ETL definition in integration-discord CONTEXT.md (batch + real-time) - Scaffold empty ETL directories (Actions, Console, DTOs) for future processing
…Guzzle (#267) - Add TwitchOAuthConnector and TwitchHelixConnector (Saloon ^4.0) - Add 7 Saloon Requests: OAuth (ExchangeCodeForToken, GetAppAccessToken), Users (GetCurrentUser, GetUsers), EventSub (Create/List/DeleteSubscription) - Add TwitchAppTokenService for cached client_credentials app token - Refactor TwitchOAuthClient to use Saloon connectors (keeps OAuthClientContract) - Update IdentityProvider::Twitch to resolve TwitchOAuthClient directly - Add eventsub_secret and eventsub_callback to config/services.php - Delete legacy: TwitchBaseClient, TwitchService, TwitchOAuthService, Subscriber/
- Create twitch_event_logs migration (event_type indexed, broadcaster_user_id indexed, twitch_message_id unique for dedup, payload jsonb) - Add TwitchEventLog model mirroring DiscordEventLog pattern - Add VerifyTwitchSignature middleware (HMAC-SHA256, replay protection 10min) - Add TwitchWebhookController handling verification, notification, revocation - Add webhook route at POST /api/webhooks/twitch/eventsub - Add 7 Pest tests covering security, ingestion, and deduplication
- Add LinkTwitchChannelCommand to link Twitch channel to tenant via ExternalIdentity - Resolves broadcaster ID via Helix API GetUsers request - Creates ExternalIdentity with IdentityProvider::Twitch on the target tenant - Handles duplicate linking, missing user, and missing tenant gracefully - Register command in IntegrationTwitchServiceProvider - Add 4 Pest tests with Saloon MockClient
- Add TwitchEventSubType enum with 36 event types, version() and condition() - Add SubscribeTwitchEventsCommand (twitch:subscribe --all/--type) - Skips already-existing subscriptions, handles 403 gracefully - Register command in IntegrationTwitchServiceProvider - Add 6 Pest tests covering enum, command, dedup, and error handling
📝 WalkthroughWalkthroughThis pull request implements complete Twitch EventSub webhook ingestion with event persistence, CLI tools for broadcaster linking and subscription management, and a full migration from legacy Guzzle-based HTTP clients to Saloon. The implementation includes HMAC-SHA256 signature verification middleware, a data lake table for raw event payloads, Saloon connectors for OAuth and Helix API access, app token caching, and comprehensive feature tests covering security, idempotency, and command behavior. Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php (2)
91-109: 💤 Low valueModerator condition test uses same ID for broadcaster and moderator.
Line 104-108 tests
ChannelModeratorAdd->condition('12345')and expectsmoderator_user_idto equal thebroadcaster_user_id. While this correctly validates the enum's default behavior, in realistic scenarios these would be different users. Consider adding a comment explaining this tests the fallback when no moderator ID is provided, or add a second assertion showing explicit moderator ID usage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php` around lines 91 - 109, The test for ChannelModeratorAdd currently passes the same ID for broadcaster and moderator by calling TwitchEventSubType::ChannelModeratorAdd->condition('12345'); update the test to clarify intent: either add a short inline comment stating this call verifies the fallback behavior when no moderator ID is provided, or add an additional assertion that calls ChannelModeratorAdd->condition('12345', '67890') and expects 'moderator_user_id' => '67890' to demonstrate explicit moderator ID handling; reference the TwitchEventSubType::ChannelModeratorAdd->condition usage to locate where to add the comment or new assertion.
38-60: 💤 Low valueUnused variable
$totalTypessuggests incomplete assertion.Line 51 calculates
$totalTypesbut never uses it in assertions. If the intent is to verify all event types were processed, consider adding an assertion like$mock->assertSentCount(1 + $totalTypes)to validate that oneListSubscriptionsplus oneCreateSubscriptionper type were sent.💡 Strengthen assertion to verify all types processed
test('subscribes to all event types', function (): void { $mock = mockEventSubResponses(); $totalTypes = count(TwitchEventSubType::cases()); $this->artisan('twitch:subscribe', [ 'broadcaster_user_id' => '12345', '--all' => true, ])->assertSuccessful(); $mock->assertSent(ListSubscriptions::class); - $mock->assertSent(CreateSubscription::class); + // 1 ListSubscriptions + 1 CreateSubscription per type + $mock->assertSentCount(1 + $totalTypes); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php` around lines 38 - 60, The test computes $totalTypes using TwitchEventSubType::cases() but never uses it; update the "subscribes to all event types" test (the test that calls mockEventSubResponses() and runs the artisan 'twitch:subscribe' with '--all') to assert that the expected number of HTTP calls were made—e.g. assert that one ListSubscriptions plus one CreateSubscription per event type were sent by using $mock->assertSentCount(1 + $totalTypes) or alternately $mock->assertSent(CreateSubscription::class, $totalTypes) in addition to $mock->assertSent(ListSubscriptions::class) so the test actually validates all event types were processed.app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php (1)
57-68: ⚡ Quick winTest name implies warning verification but doesn't check output.
The test name "warns when channel is already linked" suggests a warning message should be verified, but the test only checks that no duplicate
ExternalIdentityis created. Consider either renaming the test to better reflect what it validates (e.g., "prevents duplicate channel links") or adding an output assertion if the command actually warns.💡 Option: Add warning output verification
test('warns when channel is already linked', function (): void { mockHelixUsersResponse(); $tenant = Tenant::factory()->create(['slug' => 'he4rt-developers']); $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'he4rt-developers']) ->assertSuccessful(); $this->artisan('twitch:link-channel', ['login' => 'danielhe4rt', '--tenant' => 'he4rt-developers']) - ->assertSuccessful(); + ->assertSuccessful() + ->expectsOutputToContain('already linked'); expect(ExternalIdentity::query()->where('provider', IdentityProvider::Twitch)->count())->toBe(1); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php` around lines 57 - 68, The test name says it "warns when channel is already linked" but it never asserts any output; update LinkTwitchChannelCommandTest to either rename the test to reflect that it "prevents duplicate channel links" or add an assertion that the command emits the expected warning message when run the second time (chain an output assertion on the second $this->artisan(...) call to check for the warning text produced by the LinkTwitchChannelCommand, e.g., a message containing "already linked" or the exact string the command logs), while keeping the existing ExternalIdentity count assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app-modules/integration-twitch/CONTEXT.md`:
- Line 19: The fenced code block in CONTEXT.md currently uses plain ``` which
triggers MD040; update the opening fence to include a language identifier (e.g.,
change the opening "```" before the "src/" tree to "```text") so the block
becomes ```text ... ``` while leaving the closing "```" as-is — locate the
triple-backtick surrounding the "src/" tree and add the language tag to the
opening fence.
In
`@app-modules/integration-twitch/database/migrations/2026_05_20_000001_create_twitch_event_logs_table.php`:
- Line 18: The column definition for twitch_message_id in the
CreateTwitchEventLogsTable migration currently allows nulls which breaks
DB-level idempotency; remove the ->nullable() call so the column is defined as
non-nullable (keep ->unique()) in the migration (look for the
$table->string('twitch_message_id')->... line in the
create_twitch_event_logs_table migration). If this migration has already been
run in environments, create a new migration that ALTERs the twitch_event_logs
table: first backfill or reject existing NULL twitch_message_id rows, then ALTER
the twitch_message_id column to NOT NULL and ensure the UNIQUE constraint
remains; update any seeders or code that writes this column to guarantee a value
is provided.
In `@app-modules/integration-twitch/src/Enums/TwitchEventSubType.php`:
- Around line 45-80: Update the condition mappings in the TwitchEventSubType
enum: in condition() keep version() as-is but change cases so channel.follow and
channel.shoutout.create/receive return ['broadcaster_user_id' => $broadcasterId,
'moderator_user_id' => $userId] (use moderator_user_id instead of user_id), make
channel.shield_mode.begin and channel.shield_mode.end return only
['broadcaster_user_id' => $broadcasterId] (remove user_id), and update
channel.moderator.add/channel.moderator.remove to use ['broadcaster_user_id' =>
$broadcasterId, 'moderator_user_id' => $userId] without falling back to
$broadcasterId (validate or throw if $userId is null so a real moderator id is
required); adjust the match arms in condition() for the symbols ChannelFollow,
ChannelShieldModeBegin, ChannelShieldModeEnd, ChannelShoutoutCreate,
ChannelShoutoutReceive, ChannelModeratorAdd, and ChannelModeratorRemove
accordingly.
In
`@app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php`:
- Around line 24-40: The check-then-insert using
TwitchEventLog::query()->where(...)->exists() followed by create() is racy;
change to an atomic insert (e.g. TwitchEventLog::query()->insertOrIgnore([...])
or upsert([...], ['twitch_message_id'], [...])) using the same payload fields
(event_type, broadcaster_user_id, user_id, twitch_message_id, payload) with
messageId, then inspect the returned affected-rows value and treat a 0 (ignored)
result as a duplicate by returning response('', 204); keep the rest of the
handler logic unchanged and remove the separate exists() check to avoid the
unique-key race.
In `@app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php`:
- Around line 19-21: The $clientSecret property on TwitchOAuthConnector is
public and exposes a sensitive secret; change its declaration from public
readonly string $clientSecret to private readonly string $clientSecret inside
the TwitchOAuthConnector class, update all internal method references in that
class to use $this->clientSecret, and if external code currently reads the
property, replace those call sites with a minimal accessor (e.g., a protected
getClientSecret(): string method) or preferably refactor callers to avoid
needing the raw secret; ensure no public getter is added unless strictly
necessary.
---
Nitpick comments:
In
`@app-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.php`:
- Around line 57-68: The test name says it "warns when channel is already
linked" but it never asserts any output; update LinkTwitchChannelCommandTest to
either rename the test to reflect that it "prevents duplicate channel links" or
add an assertion that the command emits the expected warning message when run
the second time (chain an output assertion on the second $this->artisan(...)
call to check for the warning text produced by the LinkTwitchChannelCommand,
e.g., a message containing "already linked" or the exact string the command
logs), while keeping the existing ExternalIdentity count assertion.
In
`@app-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.php`:
- Around line 91-109: The test for ChannelModeratorAdd currently passes the same
ID for broadcaster and moderator by calling
TwitchEventSubType::ChannelModeratorAdd->condition('12345'); update the test to
clarify intent: either add a short inline comment stating this call verifies the
fallback behavior when no moderator ID is provided, or add an additional
assertion that calls ChannelModeratorAdd->condition('12345', '67890') and
expects 'moderator_user_id' => '67890' to demonstrate explicit moderator ID
handling; reference the TwitchEventSubType::ChannelModeratorAdd->condition usage
to locate where to add the comment or new assertion.
- Around line 38-60: The test computes $totalTypes using
TwitchEventSubType::cases() but never uses it; update the "subscribes to all
event types" test (the test that calls mockEventSubResponses() and runs the
artisan 'twitch:subscribe' with '--all') to assert that the expected number of
HTTP calls were made—e.g. assert that one ListSubscriptions plus one
CreateSubscription per event type were sent by using $mock->assertSentCount(1 +
$totalTypes) or alternately $mock->assertSent(CreateSubscription::class,
$totalTypes) in addition to $mock->assertSent(ListSubscriptions::class) so the
test actually validates all event types were processed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Central YAML (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 7dfb4da3-aa1e-4318-a8f3-a818a7b78c0e
📒 Files selected for processing (39)
CONTEXT-MAP.mdapp-modules/identity/src/ExternalIdentity/Enums/IdentityProvider.phpapp-modules/integration-discord/CONTEXT.mdapp-modules/integration-twitch/CONTEXT.mdapp-modules/integration-twitch/database/migrations/2026_05_20_000001_create_twitch_event_logs_table.phpapp-modules/integration-twitch/routes/twitch-webhook-routes.phpapp-modules/integration-twitch/src/Client/TwitchBaseClient.phpapp-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.phpapp-modules/integration-twitch/src/Console/SubscribeTwitchEventsCommand.phpapp-modules/integration-twitch/src/Contracts/TwitchService.phpapp-modules/integration-twitch/src/ETL/Actions/.gitkeepapp-modules/integration-twitch/src/ETL/Console/.gitkeepapp-modules/integration-twitch/src/ETL/DTOs/.gitkeepapp-modules/integration-twitch/src/Enums/TwitchEventSubType.phpapp-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.phpapp-modules/integration-twitch/src/Http/Middleware/VerifyTwitchSignature.phpapp-modules/integration-twitch/src/IntegrationTwitchServiceProvider.phpapp-modules/integration-twitch/src/Models/TwitchEventLog.phpapp-modules/integration-twitch/src/OAuth/Client/TwitchOAuthClient.phpapp-modules/integration-twitch/src/OAuth/Contracts/TwitchOAuthService.phpapp-modules/integration-twitch/src/OAuth/TwitchAppTokenService.phpapp-modules/integration-twitch/src/OAuth/TwitchOAuthClient.phpapp-modules/integration-twitch/src/Subscriber/Client/TwitchSubscribersClient.phpapp-modules/integration-twitch/src/Subscriber/Contracts/TwitchSubscribersService.phpapp-modules/integration-twitch/src/Subscriber/DTO/TwitchSubscriberDTO.phpapp-modules/integration-twitch/src/Subscriber/Enum/SubscriptionTiersEnum.phpapp-modules/integration-twitch/src/Transport/Requests/EventSub/CreateSubscription.phpapp-modules/integration-twitch/src/Transport/Requests/EventSub/DeleteSubscription.phpapp-modules/integration-twitch/src/Transport/Requests/EventSub/ListSubscriptions.phpapp-modules/integration-twitch/src/Transport/Requests/OAuth/ExchangeCodeForToken.phpapp-modules/integration-twitch/src/Transport/Requests/OAuth/GetAppAccessToken.phpapp-modules/integration-twitch/src/Transport/Requests/Users/GetCurrentUser.phpapp-modules/integration-twitch/src/Transport/Requests/Users/GetUsers.phpapp-modules/integration-twitch/src/Transport/TwitchHelixConnector.phpapp-modules/integration-twitch/src/Transport/TwitchOAuthConnector.phpapp-modules/integration-twitch/tests/Feature/LinkTwitchChannelCommandTest.phpapp-modules/integration-twitch/tests/Feature/SubscribeTwitchEventsCommandTest.phpapp-modules/integration-twitch/tests/Feature/TwitchWebhookTest.phpconfig/services.php
💤 Files with no reviewable changes (8)
- app-modules/integration-twitch/src/Contracts/TwitchService.php
- app-modules/integration-twitch/src/Subscriber/Client/TwitchSubscribersClient.php
- app-modules/integration-twitch/src/Subscriber/DTO/TwitchSubscriberDTO.php
- app-modules/integration-twitch/src/OAuth/Client/TwitchOAuthClient.php
- app-modules/integration-twitch/src/OAuth/Contracts/TwitchOAuthService.php
- app-modules/integration-twitch/src/Client/TwitchBaseClient.php
- app-modules/integration-twitch/src/Subscriber/Enum/SubscriptionTiersEnum.php
- app-modules/integration-twitch/src/Subscriber/Contracts/TwitchSubscribersService.php
|
|
||
| ## Structure | ||
|
|
||
| ``` |
There was a problem hiding this comment.
Add a language identifier to the fenced code block.
This currently trips markdownlint MD040 and can fail docs linting in CI.
Proposed fix
-```
+```text
src/
├── Console/
...
-```
+```📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 19-19: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app-modules/integration-twitch/CONTEXT.md` at line 19, The fenced code block
in CONTEXT.md currently uses plain ``` which triggers MD040; update the opening
fence to include a language identifier (e.g., change the opening "```" before
the "src/" tree to "```text") so the block becomes ```text ... ``` while leaving
the closing "```" as-is — locate the triple-backtick surrounding the "src/" tree
and add the language tag to the opening fence.
| $table->string('event_type')->index(); | ||
| $table->string('broadcaster_user_id')->nullable()->index(); | ||
| $table->string('user_id')->nullable(); | ||
| $table->string('twitch_message_id')->nullable()->unique(); |
There was a problem hiding this comment.
Enforce non-null twitch_message_id for hard idempotency.
Line 18 makes twitch_message_id nullable, which weakens DB-level deduplication guarantees. This should be non-null to match the webhook contract and idempotency objective.
Suggested schema tweak
- $table->string('twitch_message_id')->nullable()->unique();
+ $table->string('twitch_message_id')->unique();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| $table->string('twitch_message_id')->nullable()->unique(); | |
| $table->string('twitch_message_id')->unique(); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@app-modules/integration-twitch/database/migrations/2026_05_20_000001_create_twitch_event_logs_table.php`
at line 18, The column definition for twitch_message_id in the
CreateTwitchEventLogsTable migration currently allows nulls which breaks
DB-level idempotency; remove the ->nullable() call so the column is defined as
non-nullable (keep ->unique()) in the migration (look for the
$table->string('twitch_message_id')->... line in the
create_twitch_event_logs_table migration). If this migration has already been
run in environments, create a new migration that ALTERs the twitch_event_logs
table: first backfill or reject existing NULL twitch_message_id rows, then ALTER
the twitch_message_id column to NOT NULL and ensure the UNIQUE constraint
remains; update any seeders or code that writes this column to guarantee a value
is provided.
| public function version(): string | ||
| { | ||
| return match ($this) { | ||
| self::ChannelUpdate, | ||
| self::ChannelFollow => '2', | ||
| default => '1', | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * @return array<string, string> | ||
| */ | ||
| public function condition(string $broadcasterId, ?string $userId = null): array | ||
| { | ||
| return match ($this) { | ||
| self::ChannelFollow, | ||
| self::ChannelShieldModeBegin, | ||
| self::ChannelShieldModeEnd, | ||
| self::ChannelShoutoutCreate, | ||
| self::ChannelShoutoutReceive, | ||
| self::ChannelChatMessage => [ | ||
| 'broadcaster_user_id' => $broadcasterId, | ||
| 'user_id' => $userId ?? $broadcasterId, | ||
| ], | ||
| self::ChannelModeratorAdd, | ||
| self::ChannelModeratorRemove => [ | ||
| 'broadcaster_user_id' => $broadcasterId, | ||
| 'moderator_user_id' => $userId ?? $broadcasterId, | ||
| ], | ||
| self::ChannelRaid => [ | ||
| 'to_broadcaster_user_id' => $broadcasterId, | ||
| ], | ||
| default => [ | ||
| 'broadcaster_user_id' => $broadcasterId, | ||
| ], | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
`According to the official Twitch EventSub docs, what are the required subscription versions and condition fields for:
- channel.follow
- channel.hype_train.begin / progress / end
- channel.shield_mode.begin / end
- channel.shoutout.create / receive
- channel.moderator.add / remove`
💡 Result:
Required subscription versions and condition fields (from Twitch official EventSub docs): 1) channel.follow - version: 2 [1] - condition (required fields): - broadcaster_user_id [2] - moderator_user_id [2] 2) channel.hype_train.begin / progress / end - channel.hype_train.begin - version: 1 [3] - condition (required fields): broadcaster_user_id [3] - channel.hype_train.progress - version: 1 [3] - condition (required fields): broadcaster_user_id [3] - channel.hype_train.end - version: 1 [3] - condition (required fields): broadcaster_user_id [3] 3) channel.shield_mode.begin / end - channel.shield_mode.begin - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) - channel.shield_mode.end - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) 4) channel.shoutout.create / receive - channel.shoutout.create - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) - channel.shoutout.receive - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) 5) channel.moderator.add / remove - channel.moderator.add - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) - channel.moderator.remove - version: (not found in the retrieved official docs snippets) - condition fields: (not found in the retrieved official docs snippets) Note: With the available tool output, the official docs snippets captured complete details for channel.follow and the hype train begin/progress/end condition fields (broadcaster_user_id) and versions (1 for hype_train types). However, the specific official-doc snippets for shield_mode, shoutout, and moderator add/remove (including their required version and condition field lists) were not retrieved before search limits ended, so I can’t quote them with confidence from the official EventSub docs for those topics. If you want, I can redo the remaining lookups in a follow-up and fill in the missing shield_mode/shoutout/moderator details directly from Twitch’s pages.
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 2: https://dev.twitch.tv/docs/eventsub/eventsub-reference/
- 3: https://dev.twitch.tv/docs/eventsub/eventsub-reference
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="app-modules/integration-twitch/src/Enums/TwitchEventSubType.php"
echo "---- file: $FILE ----"
nl -ba "$FILE" | sed -n '1,220p'
echo "---- grep relevant enum cases ----"
rg -n "Channel(HypeTrainBegin|HypeTrainProgress|HypeTrainEnd|Follow|ShieldModeBegin|ShieldModeEnd|ShoutoutCreate|ShoutoutReceive|ModeratorAdd|ModeratorRemove)" "$FILE" || trueRepository: he4rt/heartdevs.com
Length of output: 204
🌐 Web query:
Twitch EventSub subscription type channel.shield_mode.begin version condition broadcaster_user_id moderator_user_id
💡 Result:
The Twitch EventSub subscription type channel.shield_mode.begin uses version 1 [1]. The required condition for this subscription is the broadcaster_user_id [1][2]. The subscription type allows applications to receive notifications when a broadcaster activates Shield Mode [1][2]. While the event payload itself includes both the broadcaster_user_id and the moderator_user_id (the moderator who activated the mode) [3], the subscription condition only requires the broadcaster_user_id to define the scope of the subscription [1][2]. Applications must possess the moderator:read:shield_mode or moderator:manage:shield_mode scope to subscribe to this event [2][4].
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 2: https://twitchio.dev/en/stable/references/eventsub_subscriptions.html
- 3: https://github.com/twurple/twurple/blob/versions/8.0/packages/eventsub-base/src/events/EventSubChannelShieldModeBeginEvent.ts
- 4: https://twitch4j.github.io/javadoc/com/github/twitch4j/eventsub/subscriptions/ShieldModeBeginType.html
🌐 Web query:
Twitch EventSub subscription type channel.shield_mode.end version condition broadcaster_user_id moderator_user_id
💡 Result:
The Twitch EventSub subscription type channel.shield_mode.end is used to receive notifications when a broadcaster deactivates Shield Mode on their channel [1]. Subscription Details: - Subscription Type: channel.shield_mode.end [1] - Version: 1 [1] - Required Condition: The condition object for this subscription requires the broadcaster_user_id [2]. - Note on moderator_user_id: While the event payload itself contains the moderator_user_id (the ID of the moderator who deactivated Shield Mode) [3], the moderator_user_id is not a required parameter in the subscription condition itself [2]. You only need to provide the broadcaster_user_id to subscribe to this event [2]. To successfully create this subscription, your condition object should be structured as follows: { "broadcaster_user_id": "YOUR_BROADCASTER_USER_ID" } This subscription requires either the moderator:read:shield_mode or moderator:manage:shield_mode scope [4].
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 2: https://dev.twitch.tv/docs/eventsub/eventsub-reference/
- 3: https://github.com/twurple/twurple/blob/versions/8.0/packages/eventsub-base/src/events/EventSubChannelShieldModeEndEvent.ts
- 4: https://twitch4j.github.io/javadoc/com/github/twitch4j/eventsub/subscriptions/ShieldModeEndType.html
🌐 Web query:
Twitch EventSub subscription type channel.shoutout.create version condition broadcaster_user_id moderator_user_id
💡 Result:
The Twitch EventSub subscription type channel.shoutout.create is used to receive notifications when a broadcaster sends a shoutout [1]. The current version for this subscription type is 1 [1][2]. The subscription requires a condition object containing the following parameters [1][2]: broadcaster_user_id (string, required): The user ID of the broadcaster (channel) where the shoutout was sent [2]. moderator_user_id (string, required): The user ID of the moderator who sent the shoutout [3][2]. Additionally, this subscription type requires a user access token that includes the moderator:read:shoutouts or moderator:manage:shoutouts scope [3]. For WebSockets, the moderator_user_id must match the user ID associated with the user access token used to create the subscription [3]. For webhooks, the user identified by moderator_user_id must have previously granted the application the necessary permissions [3].
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 2: https://dev.twitch.tv/docs/eventsub/eventsub-reference/
- 3: https://twitch4j.github.io/javadoc/com/github/twitch4j/eventsub/subscriptions/ShoutoutCreateType.html
🌐 Web query:
Twitch EventSub subscription type channel.shoutout.receive version condition broadcaster_user_id moderator_user_id
💡 Result:
The channel.shoutout.receive EventSub subscription type, which triggers when a broadcaster receives a shoutout, currently uses version 1 [1]. Regarding the condition object for this subscription type: - broadcaster_user_id: Required. The ID of the broadcaster who is receiving the shoutout [2][3]. - moderator_user_id: Required. The ID of the moderator who has granted the necessary permissions (moderator:read:shoutouts or moderator:manage:shoutouts) for the application to receive these events [2]. When using WebSockets, the moderator_user_id must match the user ID associated with the access token [2]. For webhooks, the specified moderator must have previously granted your application the required scope [2].
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 2: https://twitch4j.github.io/javadoc/com/github/twitch4j/eventsub/subscriptions/ShoutoutReceiveType.html
- 3: https://github.com/twurple/twurple/blob/versions/8.0/packages/eventsub-base/src/events/EventSubChannelShoutoutReceiveEvent.ts
🌐 Web query:
Twitch EventSub subscription type channel.moderator.add version condition broadcaster_user_id moderator_user_id
💡 Result:
The Twitch EventSub subscription type channel.moderator.add notifies your application when a user is granted moderator privileges on a specified channel [1][2]. Version and Condition Requirements As of May 2026, the current version for this subscription type is 1 [2]. The subscription requires the following fields within the condition object [2][3]: broadcaster_user_id (string, required): The user ID of the broadcaster whose channel you wish to monitor for moderator additions [3]. moderator_user_id (string, required): The user ID of the moderator [3]. Authorization Requirements To subscribe to this event, you must use a user access token that includes the moderator:manage:automod scope [2]. Additionally, the ID specified in the moderator_user_id condition parameter must match the user ID associated with the access token [2]. If an app access token is used, it also requires the moderator:manage:automod scope for the specified moderator [2].
Citations:
- 1: https://twitch4j.github.io/javadoc/com/github/twitch4j/eventsub/subscriptions/ChannelModeratorAddType.html
- 2: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
- 3: https://dev.twitch.tv/docs/eventsub/eventsub-reference/
🌐 Web query:
Twitch EventSub subscription type channel.moderator.remove version condition broadcaster_user_id moderator_user_id
💡 Result:
For the EventSub subscription type channel.moderator.remove, the condition fields include: - broadcaster_user_id: broadcaster’s (channel) user ID [1] - moderator_user_id: user ID of the moderator whose privileges were removed [1] Version condition: The official Twitch documentation lists channel.moderator.remove as a subscription type with version 1 [2].
Citations:
- 1: https://dev.twitch.tv/docs/eventsub/eventsub-reference/
- 2: https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="app-modules/integration-twitch/src/Enums/TwitchEventSubType.php"
echo "---- file: $FILE (lines 1-140) ----"
sed -n '1,160p' "$FILE" | cat -n
echo "---- match arms for version() ----"
rg -n "function version\\(" -n "$FILE" && rg -n "return match \\(\\$this\\)" -n "$FILE" -n
sed -n '35,90p' "$FILE" | cat -n
echo "---- match arms for condition() ----"
rg -n "function condition\\(" "$FILE"
sed -n '90,170p' "$FILE" | cat -n
echo "---- grep for enum cases ----"
rg -n "enum|case " "$FILE" | sed -n '1,260p'Repository: he4rt/heartdevs.com
Length of output: 4137
Fix EventSub EventSub condition field mappings for channel.follow, channel.shield_mode.*, and channel.shoutout.* (Hype Train version is already correct)
version()is already correct forchannel.hype_train.*(currently falls back to'1', which matches the required v1).condition()uses wrong keys for several types:channel.followshould usemoderator_user_id(notuser_id).channel.shield_mode.begin/endshould include onlybroadcaster_user_id(nouser_id).channel.shoutout.create/receiveshould usemoderator_user_id(notuser_id).
channel.moderator.add/removealready usesmoderator_user_id; ensure a real moderator id is provided (the current?? $broadcasterIdfallback will be incorrect ifuserIdis null).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app-modules/integration-twitch/src/Enums/TwitchEventSubType.php` around lines
45 - 80, Update the condition mappings in the TwitchEventSubType enum: in
condition() keep version() as-is but change cases so channel.follow and
channel.shoutout.create/receive return ['broadcaster_user_id' => $broadcasterId,
'moderator_user_id' => $userId] (use moderator_user_id instead of user_id), make
channel.shield_mode.begin and channel.shield_mode.end return only
['broadcaster_user_id' => $broadcasterId] (remove user_id), and update
channel.moderator.add/channel.moderator.remove to use ['broadcaster_user_id' =>
$broadcasterId, 'moderator_user_id' => $userId] without falling back to
$broadcasterId (validate or throw if $userId is null so a real moderator id is
required); adjust the match arms in condition() for the symbols ChannelFollow,
ChannelShieldModeBegin, ChannelShieldModeEnd, ChannelShoutoutCreate,
ChannelShoutoutReceive, ChannelModeratorAdd, and ChannelModeratorRemove
accordingly.
| if ($messageId && TwitchEventLog::query()->where('twitch_message_id', $messageId)->exists()) { | ||
| return response('', 204); | ||
| } | ||
|
|
||
| $body = $request->all(); | ||
| $subscription = $body['subscription'] ?? []; | ||
| $event = $body['event'] ?? []; | ||
|
|
||
| TwitchEventLog::query()->create([ | ||
| 'event_type' => $subscription['type'] ?? $messageType, | ||
| 'broadcaster_user_id' => $event['broadcaster_user_id'] | ||
| ?? $subscription['condition']['broadcaster_user_id'] | ||
| ?? null, | ||
| 'user_id' => $event['user_id'] ?? null, | ||
| 'twitch_message_id' => $messageId, | ||
| 'payload' => $body, | ||
| ]); |
There was a problem hiding this comment.
Make dedup atomic to avoid race-condition 500s on concurrent duplicates.
Line 24 + Line 32 is a check-then-insert race. Two concurrent deliveries with the same message ID can pass exists() and the second insert can fail with a unique-key exception. Use a single atomic insert path and treat ignored inserts as duplicates.
Suggested atomic approach
- if ($messageId && TwitchEventLog::query()->where('twitch_message_id', $messageId)->exists()) {
- return response('', 204);
- }
-
$body = $request->all();
$subscription = $body['subscription'] ?? [];
$event = $body['event'] ?? [];
- TwitchEventLog::query()->create([
+ $inserted = TwitchEventLog::query()->insertOrIgnore([
'event_type' => $subscription['type'] ?? $messageType,
'broadcaster_user_id' => $event['broadcaster_user_id']
?? $subscription['condition']['broadcaster_user_id']
?? null,
'user_id' => $event['user_id'] ?? null,
'twitch_message_id' => $messageId,
'payload' => $body,
+ 'created_at' => now(),
+ 'updated_at' => now(),
]);
+ if ($inserted === 0) {
+ return response('', 204);
+ }
+
return response('', 204);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@app-modules/integration-twitch/src/Http/Controllers/TwitchWebhookController.php`
around lines 24 - 40, The check-then-insert using
TwitchEventLog::query()->where(...)->exists() followed by create() is racy;
change to an atomic insert (e.g. TwitchEventLog::query()->insertOrIgnore([...])
or upsert([...], ['twitch_message_id'], [...])) using the same payload fields
(event_type, broadcaster_user_id, user_id, twitch_message_id, payload) with
messageId, then inspect the returned affected-rows value and treat a 0 (ignored)
result as a duplicate by returning response('', 204); keep the rest of the
handler logic unchanged and remove the separate exists() check to avoid the
unique-key race.
| public readonly string $clientId, | ||
| public readonly string $clientSecret, | ||
| public readonly string $redirectUri, |
There was a problem hiding this comment.
Avoid exposing OAuth client secret as a public property.
$clientSecret is sensitive and should not be publicly readable; this increases accidental leakage risk (debug dumps, logs, object inspection).
Suggested fix
public function __construct(
public readonly string $clientId,
- public readonly string $clientSecret,
+ private readonly string $clientSecret,
public readonly string $redirectUri,
) {}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app-modules/integration-twitch/src/Transport/TwitchOAuthConnector.php` around
lines 19 - 21, The $clientSecret property on TwitchOAuthConnector is public and
exposes a sensitive secret; change its declaration from public readonly string
$clientSecret to private readonly string $clientSecret inside the
TwitchOAuthConnector class, update all internal method references in that class
to use $this->clientSecret, and if external code currently reads the property,
replace those call sites with a minimal accessor (e.g., a protected
getClientSecret(): string method) or preferably refactor callers to avoid
needing the raw secret; ensure no public getter is added unless strictly
necessary.
Summary
integration-discord. Two connectors:TwitchHelixConnector(Helix API, app token) andTwitchOAuthConnector(OAuth token exchange).POST /api/webhooks/twitch/eventsubthat receives Twitch EventSub notifications, verifies HMAC-SHA256 signatures, and stores raw payloads intwitch_event_logs(data lake approach).twitch:link-channelto connect a Twitch channel to a tenant via ExternalIdentity, andtwitch:subscribeto create EventSub subscriptions for all 36 event types.TwitchBaseClientfacade,TwitchServiceinterface,Subscriber/module,TwitchOAuthServiceinterface — all replaced by Saloon transport layer.Closes #266, #267, #268, #269, #270, #271
Test plan
php artisan test --filter=Twitch)twitch_message_id→ no duplicate recordphp artisan twitch:link-channel danielhe4rt --tenant=he4rt-developerscreates ExternalIdentityphp artisan twitch:subscribe <id> --allcreates subscriptions for all event typesDescription
This pull request implements a complete Twitch integration overhaul, migrating the HTTP layer from Guzzle to SaloonPHP and adding EventSub webhook ingestion for real-time event processing. The implementation includes a secured webhook endpoint with HMAC-SHA256 signature verification, replay protection, and data-lake storage via a new
twitch_event_logstable. New Artisan commands enable channel linking to tenants and EventSub subscription management across all 36 event types. Legacy Guzzle-based clients and service interfaces are removed, with comprehensive Pest tests ensuring webhook security, signature validation, and proper event deduplication.References
#266,#267,#268,#269,#270,#271Dependencies & Requirements
New Dependencies:
Configuration Changes:
config/services.php: Addedtwitch.eventsub_secretandtwitch.eventsub_callback(sourced from environment variablesTWITCH_EVENTSUB_SECRETandTWITCH_EVENTSUB_CALLBACK)Environment Variables:
TWITCH_EVENTSUB_SECRET- Webhook signature secret from Twitch consoleTWITCH_EVENTSUB_CALLBACK- Public HTTPS callback URL for EventSub deliveriesDatabase:
twitch_event_logstable with columns for event_type, broadcaster_user_id, user_id, twitch_message_id, payload (JSONB), and timestampsContributor Summary
Changes Summary