3. Installation

Implement InstallationLifecycle for Agora — validate the key with /me, register the webhook, keep the forum row, verify, disconnect, and build the links a member opens.

The creator has pasted a forum address and a key. InstallationLifecycle turns that into a connected installation, and later tells the core whether it still works.

The port

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Acme\Connectors\Agora\Agora;
use Acme\Connectors\Agora\Models\Forum;
use Subscriby\Connector\Contracts\Ports\InstallationLifecycle;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\InstallationDraft;
use Subscriby\Connector\Data\InstallationHealth;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\InstallationRequest;
use Subscriby\Connector\Data\InstallationSummary;
use Subscriby\Connector\Enums\InstallationState;
use Subscriby\Connector\Exceptions\InstallationRefused;

final class AgoraInstallationLifecycle implements InstallationLifecycle
{
    public function __construct(
        private readonly Agora $agora,
        private readonly AgoraFailureClassifier $failures,
    ) {}

    public function begin(InstallationRequest $request): InstallationDraft
    {
        $forumUrl = (string) $request->fields['forum_url'];
        $apiKey = (string) $request->fields['api_key'];

        $me = $this->agora->client($forumUrl, $apiKey)->get('/me');

        if ($this->agora->refused($me)) {
            throw InstallationRefused::byPlatform('agora', $this->failures->classify($me));
        }

        $existing = Forum::query()->where('forum_url', $forumUrl)->whereNotNull('installation_id')->first();

        if ($existing !== null && $existing->installation_id !== $request->existing?->id) {
            throw InstallationRefused::credentialsHeld('agora');
        }

        return new InstallationDraft(state: ['bot_user_id' => (string) $me->json('data.id'), 'bot_name' => (string) $me->json('data.name')]);
    }

    public function complete(InstallationRequest $request, InstallationDraft $draft, CredentialBag $credentials): InstallationSummary
    {
        $forumUrl = (string) $credentials->get('forum_url');
        $secret = bin2hex(random_bytes(24));

        $webhook = $this->agora->client($forumUrl, (string) $credentials->get('api_key'))->post('/webhooks', [
            'url' => url(config('connector-agora.webhook_path').'/'.$draft->state['bot_user_id']),
            'secret' => $secret,
            'events' => ['message.created', 'board.member_left', 'board.picked'],
        ]);

        if ($this->agora->refused($webhook)) {
            throw InstallationRefused::byPlatform('agora', $this->failures->classify($webhook));
        }

        $forum = Forum::query()->updateOrCreate(
            ['forum_url' => $forumUrl],
            ['bot_user_id' => $draft->state['bot_user_id'], 'webhook_id' => (string) $webhook->json('data.id')],
        );

        return new InstallationSummary(
            externalId: $draft->state['bot_user_id'],
            displayName: $draft->state['bot_name'],
            handle: parse_url($forumUrl, PHP_URL_HOST) ?: null,
            meta: ['webhook_secret' => $secret],
            storageRef: (string) $forum->id,
        );
    }

    public function verify(InstallationRef $installation, CredentialBag $credentials): InstallationHealth
    {
        $me = $this->agora->for($installation, $credentials)->get('/me');

        if ($me->status() === 401) {
            return new InstallationHealth(InstallationState::Revoked, 'key_revoked', 'The API key was revoked on the forum.', creatorActionable: true);
        }

        if ($this->agora->refused($me)) {
            $failure = $this->failures->classify($me);

            return new InstallationHealth(InstallationState::Degraded, 'forum_unreachable', $failure->detail, creatorActionable: false, failureKind: $failure->kind);
        }

        return InstallationHealth::healthy();
    }

    public function disconnect(InstallationRef $installation, CredentialBag $credentials): void
    {
        $forum = Forum::query()->find($installation->storageRef);

        if ($forum?->webhook_id !== null) {
            $this->agora->for($installation, $credentials)->delete('/webhooks/'.$forum->webhook_id);
            $forum->forceFill(['webhook_id' => null])->save();
        }
    }

    public function describe(InstallationRef $installation, CredentialBag $credentials): InstallationSummary
    {
        $me = $this->agora->for($installation, $credentials)->get('/me');

        return new InstallationSummary(
            externalId: (string) $me->json('data.id', $installation->externalId),
            displayName: (string) $me->json('data.name', 'Agora bot'),
            handle: $installation->handle,
            storageRef: $installation->storageRef,
        );
    }

    public function startLink(InstallationRef $installation, ?string $payload = null): ?string
    {
        $forum = Forum::query()->find($installation->storageRef);

        if ($forum === null) {
            return null;
        }

        return rtrim($forum->forum_url, '/').'/messages/new?to='.$installation->externalId.($payload === null ? '' : '&draft='.rawurlencode($payload));
    }

    public function publicUrl(InstallationRef $installation): ?string
    {
        return Forum::query()->find($installation->storageRef)?->forum_url;
    }

    public function platformInstallation(): ?InstallationRef
    {
        return null;
    }
}

Reading it

begin() validates before anything is stored. /me proves the key and tells us the bot user's id and name; a refusal becomes InstallationRefused::byPlatform() with the classified failure, and the core shows its reason on the form. A forum already connected to another installation is credentialsHeld(), unless the creator is reconnecting the same installation ($request->existing). What we learnt is parked in the draft's state for complete(); nothing goes into our table yet, because the core has not decided to store anything.

complete() registers the webhook with a secret we mint, keeps our forum row, and answers the summary. Two things matter here:

  • externalId is the bot user's id, Agora's stable identity for the key; never the key, never the address.
  • The webhook secret goes into the summary's meta. The core merges meta into the bag it stores (the forum address, the API key and now the secret), encrypted, and hands the merged bag to every port; chapter 4's authenticate() reads it back through Core\Installations::credentials(). The connector keeps no secret of its own, which is exactly what review reads for.

verify() distinguishes a revoked key (Revoked, creator-actionable: the creator has to make a new one) from a forum that did not answer (Degraded, not the creator's fault). Chapter 7's recovery probe reuses this call so the two verdicts never disagree.

disconnect() is best effort: the key may already be dead, so a refusal is not thrown. The core wipes the credentials and marks the installation Disconnected regardless; our row stays so a reconnect names the same forum.

startLink() is what a forum has instead of a deep link: a "new message to the bot" page with the payload as the draft. The portal button, plan links and handshake links all go through it. publicUrl() is the forum itself. platformInstallation() is null: no platform scope.

Test it

it('connects a forum from a valid key and registers the webhook', function (): void {
    Http::fake([
        'forum.example.com/api/me' => Http::response(['ok' => true, 'data' => ['id' => 'u_bot', 'name' => 'Subscriby Bot']]),
        'forum.example.com/api/webhooks' => Http::response(['ok' => true, 'data' => ['id' => 'wh_1']]),
    ]);

    $request = new InstallationRequest(InstallationScope::Project, new CreatorRef('c1'), new ProjectRef('p1'), [
        'forum_url' => 'https://forum.example.com',
        'api_key' => 'agk_'.str_repeat('a', 32),
    ]);

    $draft = $lifecycle->begin($request);
    $summary = $lifecycle->complete($request, $draft, new CredentialBag(['forum_url' => 'https://forum.example.com', 'api_key' => 'agk_'.str_repeat('a', 32)]));

    expect($summary->externalId)->toBe('u_bot')
        ->and($summary->meta['webhook_secret'])->toHaveLength(48)
        ->and(Forum::query()->where('forum_url', 'https://forum.example.com')->value('webhook_id'))->toBe('wh_1');

    Http::assertSent(fn (Request $sent): bool => str_ends_with($sent->url(), '/api/webhooks') && $sent['events'] === ['message.created', 'board.member_left', 'board.picked']);
});

it('refuses a key the forum rejects', function (): void {
    Http::fake(['forum.example.com/api/me' => Http::response(['ok' => false, 'error' => 'invalid_key'], 401)]);

    expect(fn () => $lifecycle->begin($request))->toThrow(InstallationRefused::class);
});

Fake each call the port makes, one answer at a time; an unexpected call should fail the test.

What the core does with the summary

The core records the installation row from the summary (Core\Installations::record() under the hood), stores the CredentialBag merged with the summary's meta encrypted on it, and shows the creator "Connected to Subscriby Bot at forum.example.com". Your port never writes the core's row.

How is this guide?

On this page

Subscriby is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request