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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,5 @@ BACKUP_VPS_PASSWORD=
BACKUP_VPS_KEY_PATH=
BACKUP_VPS_PORT=22
BACKUP_VPS_ROOT=/backups

WHATSAPP_WEBHOOK_SECRET=
69 changes: 69 additions & 0 deletions app-modules/integration-whatsapp/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Integration WhatsApp Context

Ingest and integration layer for the WhatsApp platform. Receives normalized events from an
external collector service (`wpp-tui`, a separate Node/Baileys repo) via authenticated webhook,
and persists them as a raw event store (data lake) for later analysis.

> **Status:** Core ingest implemented (migrations, models, HMAC webhook, raw event store + Job).
> The Collector sends every event and Laravel stores everything raw — there is **no collection policy /
> filtering** in this phase (the per-group `collection_policy` idea was dropped; revisit if/when a
> hardening phase needs it). Design in `docs/spec.md`; data-modeling decision in
> `docs/adr/0001-data-lake-approach.md`; implementation plan in `docs/plans/0001-ingest-implementation.md`.
> Also deferred: Filament admin, identity-link flow, `wpp-tui` webhook wiring, deploy/retention/onboarding.

## Glossary

| Term | Definition | Not to be confused with |
| ---------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Collector** | The external Node/Baileys service (`wpp-tui` repo) that holds the WhatsApp WebSocket session and emits events. | This module (which is the Laravel-side ingest, not the WhatsApp runtime). |
| **Webhook ingest** | The HTTP endpoint that receives events from the Collector, validates HMAC, and enqueues a Job. | The Collector's own optional `WEBHOOK_URL` proxy (raw forwarding). |
| **Event store** | The `whatsapp_events` table holding raw Baileys payloads (jsonb), one row per event. | Aggregated metrics (which do not exist yet — deferred until data team). |
| **Identity link** | The future flow that associates a `whatsapp_participants` row with an `identity.users` record. | OAuth (Discord uses OAuth; WhatsApp uses a DM verification code flow). |

## Architecture

```
WhatsApp servers
↓ WebSocket (Baileys)
[ wpp-tui — external Node repo ] ← runtime, holds session, sends every event (no filtering)
↓ POST /api/integrations/whatsapp/events (HMAC + X-Event-Id)
[ integration-whatsapp — THIS module ]
├─ Ingest/Http/Middleware/VerifyWhatsAppSignature ← validate HMAC + X-Event-Id
├─ Ingest/Http/Controllers/WhatsAppWebhookController ← idempotency check, dispatch Job → 202
├─ Ingest/Jobs/ProcessWhatsAppEvent ← upsert group/participant, insert raw event
└─ Models/{WhatsAppGroup, WhatsAppParticipant, WhatsAppEvent}
↓ (future)
[ identity ] ← resolve participant → user (DM verification flow)
[ activity ] ← (future) aggregate engagement once metrics are defined
```

## Module Boundaries

### This module owns:

- The webhook ingest endpoint and its HMAC/idempotency validation
- The raw event store (`whatsapp_groups`, `whatsapp_participants`, `whatsapp_events`)
- Upsert logic for groups and participants from incoming events
- (Future) the identity-link verification endpoint

### This module does NOT own:

- The WhatsApp WebSocket connection / Baileys runtime (lives in the `wpp-tui` repo)
- Metric aggregation / dashboards (deferred — will likely live in `activity` + `panel-admin`)
- Identity/user records (belongs to `identity`)

## Dependencies

- **Identity** — participant ↔ user linking (future)
- **No dependency on** `bot-discord`, `moderation`, `community`, etc.

## Key decisions (see docs/adr/0001)

- **Data lake first**: store raw Baileys payloads in `whatsapp_events.payload` (jsonb). Decide what to
measure later, once the data team has material to work with.
- **No phone hashing / no encryption (for now)**: `participants.external_jid` stores the real WhatsApp
JID (real phone number). Conscious decision for the exploration phase.
- **No TTL (for now)**: events are retained indefinitely until the exploration phase ends and a
retention policy is defined.
- **Collect all event types**: including `presence.update`. Volume is accepted as the cost of mapping.
- **Only `type` is materialized top-level** on `whatsapp_events`; everything else lives in `payload`.
28 changes: 28 additions & 0 deletions app-modules/integration-whatsapp/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "he4rt/integration-whatsapp",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {},
"autoload": {
"psr-4": {
"He4rt\\IntegrationWhatsapp\\": "src/",
"He4rt\\IntegrationWhatsapp\\Database\\Factories\\": "database/factories/",
"He4rt\\IntegrationWhatsapp\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"He4rt\\IntegrationWhatsapp\\Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"He4rt\\IntegrationWhatsapp\\IntegrationWhatsappServiceProvider"
]
}
}
}
7 changes: 7 additions & 0 deletions app-modules/integration-whatsapp/config/whatsapp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

return [
'webhook_secret' => env('WHATSAPP_WEBHOOK_SECRET', ''),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationWhatsapp\Database\Factories;

use He4rt\IntegrationWhatsapp\Models\WhatsAppEvent;
use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup;
use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<WhatsAppEvent>
*/
final class WhatsAppEventFactory extends Factory
{
protected $model = WhatsAppEvent::class;

public function definition(): array
{
return [
'event_id' => fake()->uuid(),
'type' => 'messages.upsert',
'group_id' => WhatsAppGroup::factory(),
'participant_id' => WhatsAppParticipant::factory(),
'participant_alt' => fake()->numerify('################').'@lid',
'occurred_at' => now(),
'occurred_at_source' => 'whatsapp',
'received_at' => now(),
'payload' => [
'key' => ['id' => fake()->uuid()],
'message' => ['conversation' => fake()->sentence()],
],
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationWhatsapp\Database\Factories;

use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<WhatsAppGroup>
*/
final class WhatsAppGroupFactory extends Factory
{
protected $model = WhatsAppGroup::class;

public function definition(): array
{
return [
'external_jid' => fake()->unique()->numerify('1203#########').'@g.us',
'display_name' => fake()->words(2, true),
'internal_name' => fake()->randomElement(['geral', 'delas', 'vagas']),
'payload' => [],
'first_seen_at' => now(),
'last_seen_at' => now(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationWhatsapp\Database\Factories;

use He4rt\IntegrationWhatsapp\Models\WhatsAppGroup;
use He4rt\IntegrationWhatsapp\Models\WhatsAppGroupParticipant;
use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<WhatsAppGroupParticipant>
*/
final class WhatsAppGroupParticipantFactory extends Factory
{
protected $model = WhatsAppGroupParticipant::class;

public function definition(): array
{
return [
'group_id' => WhatsAppGroup::factory(),
'participant_id' => WhatsAppParticipant::factory(),
'admin_role' => null,
'joined_at' => now(),
'left_at' => null,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationWhatsapp\Database\Factories;

use He4rt\IntegrationWhatsapp\Models\WhatsAppParticipant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<WhatsAppParticipant>
*/
final class WhatsAppParticipantFactory extends Factory
{
protected $model = WhatsAppParticipant::class;

public function definition(): array
{
return [
'external_jid' => fake()->unique()->numerify('5511#########').'@s.whatsapp.net',
'push_name' => fake()->name(),
'payload' => [],
'identity_id' => null,
'first_seen_at' => now(),
'last_seen_at' => now(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?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('whatsapp_groups', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->string('external_jid')->unique();
$table->string('display_name')->nullable();
$table->string('internal_name')->nullable();
$table->jsonb('payload')->nullable();
$table->timestamp('first_seen_at')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('whatsapp_groups');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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('whatsapp_participants', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('external_jid')->unique();
$table->string('push_name')->nullable();
$table->jsonb('payload')->nullable();
$table->foreignUuid('identity_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('first_seen_at')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('whatsapp_participants');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?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('whatsapp_events', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->uuid('event_id')->unique();
$table->string('type')->index();
$table->foreignUuid('group_id')->nullable()->constrained('whatsapp_groups')->nullOnDelete();
$table->foreignUuid('participant_id')->nullable()->constrained('whatsapp_participants')->nullOnDelete();
$table->string('participant_alt')->nullable();
$table->timestamp('occurred_at')->index();
$table->string('occurred_at_source')->nullable();
$table->timestamp('received_at')->nullable();
$table->jsonb('payload');
$table->timestamps();

$table->index(['type', 'occurred_at']);
$table->index(['group_id', 'occurred_at']);
$table->index(['participant_id', 'occurred_at']);
});
}

public function down(): void
{
Schema::dropIfExists('whatsapp_events');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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('whatsapp_group_participants', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('group_id')->constrained('whatsapp_groups')->cascadeOnDelete();
$table->foreignUuid('participant_id')->constrained('whatsapp_participants')->cascadeOnDelete();
$table->string('admin_role')->nullable();
$table->timestamp('joined_at')->nullable();
$table->timestamp('left_at')->nullable();
$table->timestamps();

$table->unique(['group_id', 'participant_id']);
$table->index(['group_id', 'left_at']);
});
}

public function down(): void
{
Schema::dropIfExists('whatsapp_group_participants');
}
};
Loading