From 99641f24a52d0d4079e158e4ddf17e19e2689ad2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 1 May 2026 12:07:38 +1200 Subject: [PATCH] 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. --- src/Appwrite/Messaging/Adapter/Console.php | 108 +++++++++++++++++++ src/Appwrite/Messaging/Adapter/Webhook.php | 113 ++++++++++++++++++++ src/Appwrite/Messaging/Messages/Console.php | 62 +++++++++++ src/Appwrite/Messaging/Messages/Webhook.php | 66 ++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 src/Appwrite/Messaging/Adapter/Console.php create mode 100644 src/Appwrite/Messaging/Adapter/Webhook.php create mode 100644 src/Appwrite/Messaging/Messages/Console.php create mode 100644 src/Appwrite/Messaging/Messages/Webhook.php diff --git a/src/Appwrite/Messaging/Adapter/Console.php b/src/Appwrite/Messaging/Adapter/Console.php new file mode 100644 index 0000000000..aa88f59472 --- /dev/null +++ b/src/Appwrite/Messaging/Adapter/Console.php @@ -0,0 +1,108 @@ +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 + */ + 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; + } +} diff --git a/src/Appwrite/Messaging/Adapter/Webhook.php b/src/Appwrite/Messaging/Adapter/Webhook.php new file mode 100644 index 0000000000..e2cd3183d5 --- /dev/null +++ b/src/Appwrite/Messaging/Adapter/Webhook.php @@ -0,0 +1,113 @@ +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 $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, + ]; + } +} diff --git a/src/Appwrite/Messaging/Messages/Console.php b/src/Appwrite/Messaging/Messages/Console.php new file mode 100644 index 0000000000..6d08c0f0e8 --- /dev/null +++ b/src/Appwrite/Messaging/Messages/Console.php @@ -0,0 +1,62 @@ + $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 + */ + public function getRecipients(): array + { + return $this->recipients; + } + + /** + * @return array + */ + 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; + } +} diff --git a/src/Appwrite/Messaging/Messages/Webhook.php b/src/Appwrite/Messaging/Messages/Webhook.php new file mode 100644 index 0000000000..8572959b6c --- /dev/null +++ b/src/Appwrite/Messaging/Messages/Webhook.php @@ -0,0 +1,66 @@ + $urls + * @param array $payload + * @param array $headers + */ + public function __construct( + protected array $urls, + protected array $payload, + protected ?string $signingSecret = null, + protected array $headers = [], + protected int $timeout = 30, + ) { + } + + /** + * @return array + */ + public function getUrls(): array + { + return $this->urls; + } + + /** + * Alias used by the base adapter to bound max messages per request. + * + * @return array + */ + public function getTo(): array + { + return $this->urls; + } + + /** + * @return array + */ + public function getPayload(): array + { + return $this->payload; + } + + public function getSigningSecret(): ?string + { + return $this->signingSecret; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getTimeout(): int + { + return $this->timeout; + } +}