feat(notifications): add Console and Webhook provider adapters

Console adapter persists alerts directly to the project database. Webhook
adapter dispatches signed JSON payloads (HMAC-SHA256) to subscriber URLs.
This commit is contained in:
Jake Barnby
2026-05-01 12:07:38 +12:00
parent 34935b8eed
commit 99641f24a5
4 changed files with 349 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Appwrite\Messaging\Adapter;
use Appwrite\Messaging\Messages\Console as ConsoleMessage;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Messaging\Adapter;
use Utopia\Messaging\Message;
use Utopia\Messaging\Response;
class Console extends Adapter
{
protected const NAME = 'Console';
protected const TYPE = 'console';
protected const MESSAGE_TYPE = ConsoleMessage::class;
public function __construct(protected Database $database)
{
}
public function getName(): string
{
return static::NAME;
}
public function getType(): string
{
return static::TYPE;
}
public function getMessageType(): string
{
return static::MESSAGE_TYPE;
}
public function getMaxMessagesPerRequest(): int
{
return 1000;
}
public function send(Message $message): array
{
if (!$message instanceof ConsoleMessage) {
throw new \Exception('Invalid message type.');
}
return $this->process($message);
}
protected function process(ConsoleMessage $message): array
{
$response = new Response($this->getType());
$delivered = 0;
foreach ($message->getRecipients() as $recipient) {
$userId = $recipient['userId'] ?? '';
$teamId = $recipient['teamId'] ?? '';
$key = $userId !== '' ? $userId : $teamId;
try {
$document = new Document([
'$id' => $message->getMessageId() ?? ID::unique(),
'$permissions' => $this->buildPermissions($userId, $teamId),
'messageId' => $message->getMessageId(),
'type' => $message->getType(),
'channel' => self::TYPE,
'userId' => $userId !== '' ? $userId : null,
'teamId' => $teamId !== '' ? $teamId : null,
'projectId' => $message->getProjectId(),
'title' => $message->getTitle(),
'body' => $message->getBody(),
]);
$this->database->createDocument('alerts', $document);
$delivered++;
$response->addResult($key);
} catch (\Throwable $error) {
$response->addResult($key, $error->getMessage());
}
}
$response->setDeliveredTo($delivered);
return $response->toArray();
}
/**
* @return array<string>
*/
private function buildPermissions(string $userId, string $teamId): array
{
$permissions = [];
if ($userId !== '') {
$permissions[] = Permission::read(Role::user($userId));
$permissions[] = Permission::update(Role::user($userId));
$permissions[] = Permission::delete(Role::user($userId));
}
if ($teamId !== '') {
$permissions[] = Permission::read(Role::team($teamId));
$permissions[] = Permission::update(Role::team($teamId, 'owner'));
$permissions[] = Permission::delete(Role::team($teamId, 'owner'));
}
return $permissions;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Appwrite\Messaging\Adapter;
use Appwrite\Messaging\Messages\Webhook as WebhookMessage;
use Utopia\Messaging\Adapter;
use Utopia\Messaging\Message;
use Utopia\Messaging\Response;
class Webhook extends Adapter
{
protected const NAME = 'Webhook';
protected const TYPE = 'webhook';
protected const MESSAGE_TYPE = WebhookMessage::class;
protected const SIGNATURE_HEADER = 'X-Appwrite-Webhook-Signature';
protected const TIMESTAMP_HEADER = 'X-Appwrite-Webhook-Timestamp';
public function getName(): string
{
return static::NAME;
}
public function getType(): string
{
return static::TYPE;
}
public function getMessageType(): string
{
return static::MESSAGE_TYPE;
}
public function getMaxMessagesPerRequest(): int
{
return 100;
}
public function send(Message $message): array
{
if (!$message instanceof WebhookMessage) {
throw new \Exception('Invalid message type.');
}
return $this->process($message);
}
protected function process(WebhookMessage $message): array
{
$response = new Response($this->getType());
$body = \json_encode($message->getPayload(), JSON_THROW_ON_ERROR);
$timestamp = (string) \time();
$headers = [
'Content-Type: application/json',
self::TIMESTAMP_HEADER . ': ' . $timestamp,
];
$secret = $message->getSigningSecret();
if ($secret !== null && $secret !== '') {
$signature = \hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$headers[] = self::SIGNATURE_HEADER . ': sha256=' . $signature;
}
foreach ($message->getHeaders() as $name => $value) {
$headers[] = $name . ': ' . $value;
}
$delivered = 0;
foreach ($message->getUrls() as $url) {
$result = $this->dispatch('POST', $url, $headers, $body, $message->getTimeout());
if ($result['statusCode'] >= 200 && $result['statusCode'] < 300 && empty($result['error'])) {
$delivered++;
$response->addResult($url);
} else {
$response->addResult($url, $result['error'] ?: ('HTTP ' . $result['statusCode']));
}
}
$response->setDeliveredTo($delivered);
return $response->toArray();
}
/**
* @param array<int, string> $headers
* @return array{statusCode: int, response: string|null, error: string|null}
*/
protected function dispatch(string $method, string $url, array $headers, string $body, int $timeout): array
{
$handle = \curl_init();
\curl_setopt_array($handle, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_URL => $url,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => \min(10, $timeout),
CURLOPT_USERAGENT => 'Appwrite Webhook',
]);
$output = \curl_exec($handle);
$statusCode = (int) \curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = \curl_error($handle);
\curl_close($handle);
return [
'statusCode' => $statusCode,
'response' => \is_string($output) ? $output : null,
'error' => $error !== '' ? $error : null,
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace Appwrite\Messaging\Messages;
use Utopia\Messaging\Message;
class Console implements Message
{
/**
* @param array<int, array{userId?: string, teamId?: string}> $recipients
*/
public function __construct(
protected array $recipients,
protected string $title,
protected string $body,
protected string $type = 'info',
protected ?string $messageId = null,
protected ?string $projectId = null,
) {
}
/**
* @return array<int, array{userId?: string, teamId?: string}>
*/
public function getRecipients(): array
{
return $this->recipients;
}
/**
* @return array<int, array{userId?: string, teamId?: string}>
*/
public function getTo(): array
{
return $this->recipients;
}
public function getTitle(): string
{
return $this->title;
}
public function getBody(): string
{
return $this->body;
}
public function getType(): string
{
return $this->type;
}
public function getMessageId(): ?string
{
return $this->messageId;
}
public function getProjectId(): ?string
{
return $this->projectId;
}
}
@@ -0,0 +1,66 @@
<?php
namespace Appwrite\Messaging\Messages;
use Utopia\Messaging\Message;
class Webhook implements Message
{
/**
* @param array<int, string> $urls
* @param array<string, mixed> $payload
* @param array<string, string> $headers
*/
public function __construct(
protected array $urls,
protected array $payload,
protected ?string $signingSecret = null,
protected array $headers = [],
protected int $timeout = 30,
) {
}
/**
* @return array<int, string>
*/
public function getUrls(): array
{
return $this->urls;
}
/**
* Alias used by the base adapter to bound max messages per request.
*
* @return array<int, string>
*/
public function getTo(): array
{
return $this->urls;
}
/**
* @return array<string, mixed>
*/
public function getPayload(): array
{
return $this->payload;
}
public function getSigningSecret(): ?string
{
return $this->signingSecret;
}
/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return $this->headers;
}
public function getTimeout(): int
{
return $this->timeout;
}
}