Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL |
| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication |
| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing |
| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks |

## Relationships

Expand All @@ -28,15 +29,16 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
└────────┬────────┘
│ resolves identities
┌─────────────────┐
│ Identity │
│ (users/tenants) │
└─────────────────┘
┌─────────────────┐ ┌──────────────────────┐
│ Identity │◀────────│ Integration Twitch │
│ (users/tenants) │ │ (transport/webhooks) │
└─────────────────┘ └──────────────────────┘
```

### Dependency rules

- **Moderation** is platform-agnostic. It never imports from `bot-discord` or `integration-discord`.
- **Moderation** is platform-agnostic. It never imports from `bot-discord`, `integration-discord`, or `integration-twitch`.
- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport).
- **Integration Discord** depends on Identity (OAuth user resolution). It never imports from Moderation.
- **Integration Twitch** depends on Identity (OAuth user resolution, ExternalIdentity for tenant linking). It never imports from Moderation, Integration Discord, or Bot Discord.
- **Identity** has no upstream dependencies on other contexts listed here.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use He4rt\IntegrationDevTo\OAuth\DevToOAuthClient;
use He4rt\IntegrationDiscord\ETL\Adapters\DiscordMessageAdapter;
use He4rt\IntegrationDiscord\OAuth\DiscordOAuthClient;
use He4rt\IntegrationTwitch\OAuth\Contracts\TwitchOAuthService;
use He4rt\IntegrationTwitch\OAuth\TwitchOAuthClient;
use LogicException;

enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasLabel
Expand Down Expand Up @@ -51,7 +51,7 @@ enum IdentityProvider: string implements HasColor, HasDescription, HasIcon, HasL
public function getClient(): ?OAuthClientContract
{
return match ($this) {
self::Twitch => resolve(TwitchOAuthService::class),
self::Twitch => resolve(TwitchOAuthClient::class),
self::Discord => resolve(DiscordOAuthClient::class),
self::DevTo => resolve(DevToOAuthClient::class),
default => null,
Expand Down
14 changes: 7 additions & 7 deletions app-modules/integration-discord/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ Transport and integration layer for the Discord platform. Owns all HTTP communic

## Glossary

| Term | Definition | Not to be confused with |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to Discord's REST API v10. All Discord HTTP goes through here. | The bot runtime (which uses Laracord websocket in `bot-discord`) |
| **DiscordConnector** | Saloon connector authenticated with Bot token. Used for guild operations, messages, moderation actions. | `DiscordOAuthConnector` (which uses client credentials for OAuth) |
| **DiscordOAuthConnector** | Saloon connector for OAuth2 token exchange and user info retrieval. | — |
| **DiscordRoleResolver** | Utility that fetches a member's roles via `GetMember` and determines their protection tier (admin/mod/none) based on configured role IDs. | Authorization (which is about panel access) |
| **ETL** | Historical data import from legacy Discord bots (messages, profiles, voice logs, moderation events). | Real-time bot events (which are in `bot-discord`) |
| Term | Definition | Not to be confused with |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to Discord's REST API v10. All Discord HTTP goes through here. | The bot runtime (which uses Laracord websocket in `bot-discord`) |
| **DiscordConnector** | Saloon connector authenticated with Bot token. Used for guild operations, messages, moderation actions. | `DiscordOAuthConnector` (which uses client credentials for OAuth) |
| **DiscordOAuthConnector** | Saloon connector for OAuth2 token exchange and user info retrieval. | — |
| **DiscordRoleResolver** | Utility that fetches a member's roles via `GetMember` and determines their protection tier (admin/mod/none) based on configured role IDs. | Authorization (which is about panel access) |
| **ETL** | Layer that transforms external data (raw payloads) into domain entities. In this module: historical import from legacy Discord bots (messages, profiles, voice logs, moderation events). | Real-time bot events (which are in `bot-discord`) |

## Structure

Expand Down
73 changes: 73 additions & 0 deletions app-modules/integration-twitch/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Integration Twitch Context

Transport and integration layer for the Twitch platform. Owns all HTTP communication with Twitch APIs (via Saloon), OAuth flows, EventSub webhook ingestion, and ETL for transforming raw event data into domain entities.

## Glossary

| Term | Definition | Not to be confused with |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to Twitch's Helix API and OAuth endpoints. All outbound HTTP goes here. | Inbound webhooks (which are received in `Http/`) |
| **TwitchHelixConnector** | Saloon connector authenticated with App Access Token + Client-Id. Used for Helix API calls (EventSub, Users). | `TwitchOAuthConnector` (which handles token exchange, no auth) |
| **TwitchOAuthConnector** | Saloon connector for OAuth2 token exchange and app access token retrieval. Base URL: `id.twitch.tv/oauth2`. | — |
| **App Access Token** | Server-to-server token obtained via client_credentials grant. Cached. Used as default auth on `TwitchHelixConnector`. | User Access Token (obtained via authorization_code, per-user) |
| **EventSub** | Twitch's unified event notification system. We receive events via webhook transport (HTTP POST to our endpoint). | PubSub (deprecated, shut down April 2025) |
| **TwitchEventLog** | Raw event record in `twitch_event_logs`. Stores the full EventSub payload as JSONB. Data lake — no processing on write. | Processed domain entities (future ETL output) |
| **ETL** | Layer that transforms external data (raw payloads) into domain entities. Covers both historical imports and real-time event processing. | Transport (which is outbound HTTP) or Http (which is ingestion) |

## Structure

```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
```
🧰 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.

src/
├── Console/
│ ├── LinkTwitchChannelCommand.php ← Links channel to tenant via ExternalIdentity
│ └── SubscribeTwitchEventsCommand.php ← Creates EventSub subscriptions via Helix API
├── Enums/
│ └── TwitchEventSubType.php ← All EventSub subscription types with version/condition
├── ETL/ ← Empty in MVP, ready for processing
│ ├── Actions/
│ ├── Console/
│ └── DTOs/
├── Http/
│ ├── Controllers/
│ │ └── TwitchWebhookController.php ← Receives EventSub webhooks, persists to TwitchEventLog
│ └── Middleware/
│ └── VerifyTwitchSignature.php ← HMAC-SHA256 signature verification
├── Models/
│ └── TwitchEventLog.php ← Raw event data lake
├── OAuth/
│ ├── TwitchOAuthClient.php ← Implements OAuthClientContract (uses Transport)
│ ├── TwitchAppTokenService.php ← Client credentials flow, cached app token
│ └── DTO/
│ ├── TwitchOAuthAccessDTO.php
│ └── TwitchOAuthDTO.php
└── Transport/
├── TwitchHelixConnector.php ← App token auth, base URL: api.twitch.tv/helix
├── TwitchOAuthConnector.php ← No default auth, base URL: id.twitch.tv/oauth2
└── Requests/
├── OAuth/ ← ExchangeCodeForToken, GetAppAccessToken
├── Users/ ← GetCurrentUser, GetUsers
└── EventSub/ ← CreateSubscription, ListSubscriptions, DeleteSubscription
```

## Module Boundaries

### This module owns:

- All HTTP requests to Twitch APIs (Helix + OAuth) via Saloon
- OAuth token exchange and user profile retrieval
- App access token management (client credentials, cached)
- EventSub webhook reception and raw payload storage
- EventSub subscription lifecycle (create, list, delete)
- ETL processing of Twitch events into domain entities (future)

### This module does NOT own:

- User/tenant identity management (belongs to `identity`)
- Gamification or XP from Twitch events (belongs to `character` / `ranking`)
- Discord notifications about Twitch events (belongs to `bot-discord`)

## Dependencies

- **Identity** — OAuth user resolution (`OAuthClientContract`, `ExternalIdentity`)
- **Saloon** — HTTP transport layer (`saloon/saloon ^4.0`)
- **No dependency on** Moderation, Bot Discord, or Integration Discord
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('twitch_event_logs', function (Blueprint $table): void {
$table->id();
$table->string('event_type')->index();
$table->string('broadcaster_user_id')->nullable()->index();
$table->string('user_id')->nullable();
$table->string('twitch_message_id')->nullable()->unique();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
$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.

$table->jsonb('payload');
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('twitch_event_logs');
}
};
14 changes: 14 additions & 0 deletions app-modules/integration-twitch/routes/twitch-webhook-routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

use He4rt\IntegrationTwitch\Http\Controllers\TwitchWebhookController;
use He4rt\IntegrationTwitch\Http\Middleware\VerifyTwitchSignature;
use Illuminate\Support\Facades\Route;

Route::prefix('api/webhooks/twitch')
->middleware(VerifyTwitchSignature::class)
->group(function (): void {
Route::post('/eventsub', TwitchWebhookController::class)
->name('twitch.eventsub.webhook');
});
27 changes: 0 additions & 27 deletions app-modules/integration-twitch/src/Client/TwitchBaseClient.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationTwitch\Console;

use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager;
use He4rt\Identity\ExternalIdentity\Enums\CredentialsType;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use He4rt\Identity\ExternalIdentity\Enums\IdentityType;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\IntegrationTwitch\Transport\Requests\Users\GetUsers;
use He4rt\IntegrationTwitch\Transport\TwitchHelixConnector;
use Illuminate\Console\Command;

final class LinkTwitchChannelCommand extends Command
{
protected $signature = 'twitch:link-channel {login : Twitch channel login name} {--tenant= : Tenant slug or ID}';

protected $description = 'Link a Twitch channel to a tenant via ExternalIdentity';

public function handle(TwitchHelixConnector $helix): int
{
$login = $this->argument('login');
$tenantOption = $this->option('tenant');

$response = $helix->send(new GetUsers(login: $login));
$users = $response->json('data', []);

if (blank($users)) {
$this->error(sprintf("Twitch user '%s' not found.", $login));

return self::FAILURE;
}

$twitchUser = $users[0];
$broadcasterId = $twitchUser['id'];
$displayName = $twitchUser['display_name'];

$query = Tenant::query()->where('slug', $tenantOption);

if (is_numeric($tenantOption)) {
$query->orWhere('id', $tenantOption);
}

$tenant = $query->first();

if (!$tenant) {
$this->error(sprintf("Tenant '%s' not found.", $tenantOption));

return self::FAILURE;
}

$existing = $tenant->providers()
->where('provider', IdentityProvider::Twitch)
->where('external_account_id', $broadcasterId)
->first();

if ($existing) {
$this->warn(sprintf("Channel '%s' (ID: %s) is already linked to tenant '%s'.", $login, $broadcasterId, $tenant->name));

return self::SUCCESS;
}

$tenant->providers()->create([
'tenant_id' => $tenant->getKey(),
'type' => IdentityType::External,
'provider' => IdentityProvider::Twitch,
'credentials_type' => CredentialsType::OAuth2,
'credentials' => ClientAccessManager::make(),
'external_account_id' => $broadcasterId,
'connected_at' => now(),
'metadata' => [
'login' => $login,
'display_name' => $displayName,
],
]);

$this->info(sprintf("Linked Twitch channel '%s' (ID: %s) to tenant '%s'.", $displayName, $broadcasterId, $tenant->name));

return self::SUCCESS;
}
}
Loading