diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b682ad2 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-chat", + "description": "Chat platform integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Chat\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Discord/Auth.php b/src/Discord/Auth.php new file mode 100644 index 0000000..f2a4da7 --- /dev/null +++ b/src/Discord/Auth.php @@ -0,0 +1,106 @@ +webhookUrl = $webhookUrl; + + return $this; + } + + /** + * Discord uses webhook URLs, not OAuth. + */ + public function getAuthUrl(): string + { + return 'https://discord.com/developers/applications'; + } + + /** + * Validate webhook URL and return credentials. + * + * @param array $params ['webhook_url' => string, 'channel_name' => string] + */ + public function requestAccessToken(array $params): array + { + $webhookUrl = $params['webhook_url'] ?? $this->webhookUrl; + $channelName = $params['channel_name'] ?? 'Discord Channel'; + + if (! $webhookUrl) { + return ['error' => 'Webhook URL is required']; + } + + // Validate webhook URL format + if (! str_starts_with($webhookUrl, 'https://discord.com/api/webhooks/')) { + return ['error' => 'Invalid Discord webhook URL']; + } + + // Verify webhook by fetching its info + $response = $this->http()->get($webhookUrl); + + if (! $response->successful()) { + return ['error' => 'Invalid or expired webhook URL']; + } + + $data = $response->json(); + + return [ + 'webhook_url' => $webhookUrl, + 'webhook_id' => $data['id'] ?? null, + 'channel_id' => $data['channel_id'] ?? null, + 'guild_id' => $data['guild_id'] ?? null, + 'channel_name' => $data['name'] ?? $channelName, + 'account_id' => $data['id'] ?? md5($webhookUrl), + ]; + } + + public function getAccount(): Response + { + if (! $this->webhookUrl) { + return $this->error('Webhook URL is required'); + } + + $response = $this->http()->get($this->webhookUrl); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'name' => $data['name'], + 'channel_id' => $data['channel_id'], + 'guild_id' => $data['guild_id'] ?? null, + 'avatar' => $data['avatar'] ? "https://cdn.discordapp.com/avatars/{$data['id']}/{$data['avatar']}.png" : null, + ]); + } +} diff --git a/src/Discord/Delete.php b/src/Discord/Delete.php new file mode 100644 index 0000000..d6630fe --- /dev/null +++ b/src/Discord/Delete.php @@ -0,0 +1,58 @@ +webhookUrl = $webhookUrl; + + return $this; + } + + /** + * Delete a message posted via this webhook. + * + * @param string $id Message ID + */ + public function delete(string $id): Response + { + if (! $this->webhookUrl) { + return $this->error('Webhook URL is required'); + } + + $response = $this->http()->delete("{$this->webhookUrl}/messages/{$id}"); + + // Discord returns 204 No Content on success + if ($response->status() === 204) { + return $this->ok([ + 'deleted' => true, + 'id' => $id, + ]); + } + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => false, + 'error' => $data['message'] ?? 'Failed to delete message', + ]); + } +} diff --git a/src/Discord/Post.php b/src/Discord/Post.php new file mode 100644 index 0000000..55a9506 --- /dev/null +++ b/src/Discord/Post.php @@ -0,0 +1,123 @@ +webhookUrl = $webhookUrl; + + return $this; + } + + /** + * Post a message to Discord. + * + * @param string $text Message content + * @param Collection $media Images to embed + * @param array $params username, avatar_url, tts, embed options + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->webhookUrl) { + return $this->error('Webhook URL is required'); + } + + $payload = []; + + if ($text) { + $payload['content'] = $text; + } + + // Handle embeds for media + if ($media->isNotEmpty()) { + $embeds = []; + + foreach ($media as $item) { + $imageUrl = $item['url'] ?? $item['path'] ?? null; + if ($imageUrl) { + $embed = [ + 'image' => ['url' => $imageUrl], + ]; + + // Add title/description if provided + if (isset($item['title'])) { + $embed['title'] = $item['title']; + } + if (isset($item['description'])) { + $embed['description'] = $item['description']; + } + + $embeds[] = $embed; + } + } + + if (! empty($embeds)) { + $payload['embeds'] = $embeds; + } + } + + // Custom embed from params + if (isset($params['embed'])) { + $payload['embeds'] = array_merge($payload['embeds'] ?? [], [$params['embed']]); + } + + // Optional customisation + if (isset($params['username'])) { + $payload['username'] = $params['username']; + } + + if (isset($params['avatar_url'])) { + $payload['avatar_url'] = $params['avatar_url']; + } + + if (isset($params['tts'])) { + $payload['tts'] = (bool) $params['tts']; + } + + // Use ?wait=true to get message ID in response + $response = $this->http()->post($this->webhookUrl.'?wait=true', $payload); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'channel_id' => $data['channel_id'], + 'timestamp' => $data['timestamp'] ?? null, + ]); + } + + /** + * Discord message URL. + */ + public static function externalPostUrl(string $guildId, string $channelId, string $messageId): string + { + return "https://discord.com/channels/{$guildId}/{$channelId}/{$messageId}"; + } + + /** + * Discord server URL. + */ + public static function externalAccountUrl(string $guildId): string + { + return "https://discord.com/channels/{$guildId}"; + } +} diff --git a/src/Slack/Auth.php b/src/Slack/Auth.php new file mode 100644 index 0000000..a7e27ba --- /dev/null +++ b/src/Slack/Auth.php @@ -0,0 +1,92 @@ +webhookUrl = $webhookUrl; + + return $this; + } + + /** + * Slack uses webhook URLs, not OAuth. + */ + public function getAuthUrl(): string + { + return 'https://api.slack.com/apps'; + } + + /** + * Validate webhook URL and return credentials. + * + * @param array $params ['webhook_url' => string, 'channel_name' => string] + */ + public function requestAccessToken(array $params): array + { + $webhookUrl = $params['webhook_url'] ?? $this->webhookUrl; + $channelName = $params['channel_name'] ?? 'Slack Channel'; + + if (! $webhookUrl) { + return ['error' => 'Webhook URL is required']; + } + + // Validate webhook URL format + if (! str_starts_with($webhookUrl, 'https://hooks.slack.com/')) { + return ['error' => 'Invalid Slack webhook URL']; + } + + // Test the webhook with a simple request + $response = $this->http()->post($webhookUrl, [ + 'text' => '', // Empty test - Slack will accept but not post + ]); + + // Slack returns 'invalid_payload' for empty text, which confirms webhook is valid + if (! $response->successful() && $response->body() !== 'invalid_payload') { + return ['error' => 'Invalid or expired webhook URL']; + } + + return [ + 'webhook_url' => $webhookUrl, + 'channel_name' => $channelName, + 'account_id' => md5($webhookUrl), + ]; + } + + public function getAccount(): Response + { + return $this->error('Use webhook credentials directly'); + } +} diff --git a/src/Slack/Post.php b/src/Slack/Post.php new file mode 100644 index 0000000..8ed32e2 --- /dev/null +++ b/src/Slack/Post.php @@ -0,0 +1,120 @@ +webhookUrl = $webhookUrl; + + return $this; + } + + /** + * Post a message to Slack. + * + * @param string $text Message text (supports mrkdwn) + * @param Collection $media Images to include + * @param array $params username, icon_emoji, icon_url + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->webhookUrl) { + return $this->error('Webhook URL is required'); + } + + $blocks = []; + + // Add text as section block + if ($text) { + $blocks[] = [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => $text, + ], + ]; + } + + // Add media as image blocks + foreach ($media as $item) { + $imageUrl = $item['url'] ?? $item['path'] ?? null; + if ($imageUrl) { + $blocks[] = [ + 'type' => 'image', + 'image_url' => $imageUrl, + 'alt_text' => $item['alt_text'] ?? $item['name'] ?? 'Image', + ]; + } + } + + $payload = []; + + if (! empty($blocks)) { + $payload['blocks'] = $blocks; + } else { + $payload['text'] = $text ?: 'Message from Host UK'; + } + + // Optional customisation + if (isset($params['username'])) { + $payload['username'] = $params['username']; + } + + if (isset($params['icon_emoji'])) { + $payload['icon_emoji'] = $params['icon_emoji']; + } + + if (isset($params['icon_url'])) { + $payload['icon_url'] = $params['icon_url']; + } + + $response = $this->http()->post($this->webhookUrl, $payload); + + // Slack webhooks return 'ok' as plain text on success + if ($response->successful() && $response->body() === 'ok') { + return $this->ok([ + 'id' => uniqid('slack_'), + 'success' => true, + ]); + } + + return $this->error($response->body() ?: 'Failed to post message'); + } + + /** + * Slack doesn't provide post URLs for webhook messages. + */ + public static function externalPostUrl(string $workspace, string $channel): string + { + return "https://{$workspace}.slack.com/archives/{$channel}"; + } + + /** + * Slack workspace URL. + */ + public static function externalAccountUrl(string $workspace): string + { + return "https://{$workspace}.slack.com"; + } +} diff --git a/src/Telegram/Auth.php b/src/Telegram/Auth.php new file mode 100644 index 0000000..b967730 --- /dev/null +++ b/src/Telegram/Auth.php @@ -0,0 +1,122 @@ +botToken = $botToken; + + return $this; + } + + /** + * BotFather link for creating bots. + */ + public function getAuthUrl(): string + { + return 'https://t.me/BotFather'; + } + + /** + * Validate bot token and return credentials. + * + * @param array $params ['bot_token' => string, 'chat_id' => string] + */ + public function requestAccessToken(array $params): array + { + $botToken = $params['bot_token'] ?? $this->botToken; + $chatId = $params['chat_id'] ?? ''; + + if (! $botToken) { + return ['error' => 'Bot token is required']; + } + + // Verify the bot token + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getMe"); + + if (! $response->successful()) { + return ['error' => 'Invalid bot token']; + } + + $data = $response->json(); + + if (! ($data['ok'] ?? false)) { + return ['error' => $data['description'] ?? 'Invalid bot token']; + } + + $bot = $data['result']; + + return [ + 'access_token' => $botToken, + 'chat_id' => $chatId, + 'bot_id' => (string) $bot['id'], + 'bot_username' => $bot['username'], + 'bot_name' => $bot['first_name'], + 'account_id' => (string) $bot['id'], + ]; + } + + public function getAccount(): Response + { + if (! $this->botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$this->botToken}/getMe"); + + return $this->fromHttp($response, function ($data) { + $bot = $data['result'] ?? $data; + + return [ + 'id' => (string) $bot['id'], + 'name' => $bot['first_name'], + 'username' => $bot['username'], + 'can_join_groups' => $bot['can_join_groups'] ?? false, + 'can_read_messages' => $bot['can_read_all_group_messages'] ?? false, + 'supports_inline' => $bot['supports_inline_queries'] ?? false, + ]; + }); + } + + /** + * Bot profile URL. + */ + public static function externalAccountUrl(string $username): string + { + return "https://t.me/{$username}"; + } +} diff --git a/src/Telegram/Chats.php b/src/Telegram/Chats.php new file mode 100644 index 0000000..49bbc1e --- /dev/null +++ b/src/Telegram/Chats.php @@ -0,0 +1,113 @@ +accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + // Get updates to find chats the bot is in + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getUpdates", [ + 'allowed_updates' => ['message', 'channel_post', 'my_chat_member'], + 'limit' => 100, + ]); + + return $this->fromHttp($response, function ($data) { + $chats = []; + $seenIds = []; + + foreach ($data['result'] ?? [] as $update) { + // Try multiple sources for chat info + $chat = $update['message']['chat'] + ?? $update['channel_post']['chat'] + ?? $update['my_chat_member']['chat'] + ?? null; + + if ($chat && ! in_array($chat['id'], $seenIds)) { + $seenIds[] = $chat['id']; + $chats[] = [ + 'id' => (string) $chat['id'], + 'name' => $chat['title'] ?? $chat['first_name'] ?? $chat['username'] ?? 'Unknown', + 'type' => $chat['type'] ?? 'private', + 'username' => $chat['username'] ?? null, + ]; + } + } + + return ['chats' => $chats]; + }); + } + + /** + * Get chat member count. + */ + public function memberCount(string $chatId): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getChatMemberCount", [ + 'chat_id' => $chatId, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'count' => $data['result'] ?? 0, + ]); + } + + /** + * Get chat administrators. + */ + public function administrators(string $chatId): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getChatAdministrators", [ + 'chat_id' => $chatId, + ]); + + return $this->fromHttp($response, function ($data) { + return [ + 'administrators' => array_map(fn ($admin) => [ + 'id' => (string) $admin['user']['id'], + 'username' => $admin['user']['username'] ?? null, + 'name' => $admin['user']['first_name'] ?? '', + 'status' => $admin['status'], + 'is_anonymous' => $admin['is_anonymous'] ?? false, + ], $data['result'] ?? []), + ]; + }); + } +} diff --git a/src/Telegram/Delete.php b/src/Telegram/Delete.php new file mode 100644 index 0000000..4a714db --- /dev/null +++ b/src/Telegram/Delete.php @@ -0,0 +1,64 @@ +chatId = $chatId; + + return $this; + } + + /** + * Delete a message. + * + * @param string $id Message ID + */ + public function delete(string $id): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + if (! $this->chatId) { + return $this->error('Chat ID is required'); + } + + $response = $this->http()->post(self::API_URL."/bot{$botToken}/deleteMessage", [ + 'chat_id' => $this->chatId, + 'message_id' => $id, + ]); + + return $this->fromHttp($response, function ($data) use ($id) { + return [ + 'deleted' => $data['result'] ?? $data['ok'] ?? true, + 'id' => $id, + ]; + }); + } +} diff --git a/src/Telegram/Post.php b/src/Telegram/Post.php new file mode 100644 index 0000000..52b0a7c --- /dev/null +++ b/src/Telegram/Post.php @@ -0,0 +1,212 @@ +chatId = $chatId; + + return $this; + } + + /** + * Send a message to Telegram. + * + * @param string $text Message text + * @param Collection $media Media items to send + * @param array $params chat_id, parse_mode, disable_web_page_preview, disable_notification + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $chatId = $params['chat_id'] ?? $this->chatId; + if (! $chatId) { + return $this->error('Chat ID is required'); + } + + // Handle media + if ($media->isNotEmpty()) { + return $this->sendMedia($botToken, $chatId, $text, $media, $params); + } + + // Send text message + $messageData = [ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => $params['parse_mode'] ?? 'HTML', + ]; + + if (isset($params['disable_web_page_preview'])) { + $messageData['disable_web_page_preview'] = $params['disable_web_page_preview']; + } + + if (isset($params['disable_notification'])) { + $messageData['disable_notification'] = $params['disable_notification']; + } + + if (isset($params['reply_to_message_id'])) { + $messageData['reply_to_message_id'] = $params['reply_to_message_id']; + } + + $response = $this->http()->post(self::API_URL."/bot{$botToken}/sendMessage", $messageData); + + return $this->fromHttp($response, function ($data) { + $result = $data['result'] ?? $data; + + return [ + 'id' => (string) $result['message_id'], + 'chat_id' => (string) ($result['chat']['id'] ?? ''), + ]; + }); + } + + /** + * Send media (photo, video, or media group). + */ + private function sendMedia(string $botToken, string $chatId, string $text, Collection $media, array $params): Response + { + $mediaItems = []; + + foreach ($media as $index => $item) { + $type = $this->getMediaType($item); + $mediaItem = [ + 'type' => $type, + 'media' => $item['url'] ?? $item['path'] ?? '', + ]; + + // Only add caption to first item + if ($index === 0 && $text) { + $mediaItem['caption'] = $text; + $mediaItem['parse_mode'] = $params['parse_mode'] ?? 'HTML'; + } + + $mediaItems[] = $mediaItem; + } + + // Single media item - use specific method + if (count($mediaItems) === 1) { + return $this->sendSingleMedia($botToken, $chatId, $mediaItems[0], $params); + } + + // Multiple media items - use sendMediaGroup + $response = $this->http()->post(self::API_URL."/bot{$botToken}/sendMediaGroup", [ + 'chat_id' => $chatId, + 'media' => json_encode($mediaItems), + 'disable_notification' => $params['disable_notification'] ?? false, + ]); + + return $this->fromHttp($response, function ($data) { + $results = $data['result'] ?? []; + $first = $results[0] ?? $data; + + return [ + 'id' => (string) ($first['message_id'] ?? ''), + 'chat_id' => (string) ($first['chat']['id'] ?? ''), + 'message_count' => count($results), + ]; + }); + } + + /** + * Send single media item. + */ + private function sendSingleMedia(string $botToken, string $chatId, array $item, array $params): Response + { + $method = match ($item['type']) { + 'photo' => 'sendPhoto', + 'video' => 'sendVideo', + 'audio' => 'sendAudio', + 'document' => 'sendDocument', + 'animation' => 'sendAnimation', + default => 'sendPhoto', + }; + + $payload = [ + 'chat_id' => $chatId, + $item['type'] => $item['media'], + ]; + + if (isset($item['caption'])) { + $payload['caption'] = $item['caption']; + $payload['parse_mode'] = $item['parse_mode'] ?? 'HTML'; + } + + if (isset($params['disable_notification'])) { + $payload['disable_notification'] = $params['disable_notification']; + } + + $response = $this->http()->post(self::API_URL."/bot{$botToken}/{$method}", $payload); + + return $this->fromHttp($response, function ($data) { + $result = $data['result'] ?? $data; + + return [ + 'id' => (string) $result['message_id'], + 'chat_id' => (string) ($result['chat']['id'] ?? ''), + ]; + }); + } + + /** + * Determine media type from item. + */ + private function getMediaType(array $item): string + { + $mimeType = $item['mime_type'] ?? ''; + + if (str_starts_with($mimeType, 'image/gif')) { + return 'animation'; + } + + if (str_starts_with($mimeType, 'image/')) { + return 'photo'; + } + + if (str_starts_with($mimeType, 'video/')) { + return 'video'; + } + + if (str_starts_with($mimeType, 'audio/')) { + return 'audio'; + } + + return 'document'; + } + + /** + * Telegram message URL. + */ + public static function externalPostUrl(string $chatUsername, string $messageId): string + { + return "https://t.me/{$chatUsername}/{$messageId}"; + } +} diff --git a/src/Telegram/Read.php b/src/Telegram/Read.php new file mode 100644 index 0000000..1e1297d --- /dev/null +++ b/src/Telegram/Read.php @@ -0,0 +1,125 @@ +me(); + } + + /** + * Get current bot information. + */ + public function me(): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getMe"); + + return $this->fromHttp($response, function ($data) { + $bot = $data['result'] ?? $data; + + return [ + 'id' => (string) $bot['id'], + 'username' => $bot['username'], + 'name' => $bot['first_name'], + 'can_join_groups' => $bot['can_join_groups'] ?? false, + 'can_read_messages' => $bot['can_read_all_group_messages'] ?? false, + 'supports_inline' => $bot['supports_inline_queries'] ?? false, + ]; + }); + } + + /** + * Get chat information. + */ + public function chat(string $chatId): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getChat", [ + 'chat_id' => $chatId, + ]); + + return $this->fromHttp($response, function ($data) { + $chat = $data['result'] ?? $data; + + return [ + 'id' => (string) $chat['id'], + 'type' => $chat['type'], + 'title' => $chat['title'] ?? null, + 'username' => $chat['username'] ?? null, + 'first_name' => $chat['first_name'] ?? null, + 'description' => $chat['description'] ?? null, + 'photo' => $chat['photo']['big_file_id'] ?? null, + ]; + }); + } + + /** + * List recent chats (from updates). + */ + public function list(array $params = []): Response + { + $botToken = $this->accessToken(); + if (! $botToken) { + return $this->error('Bot token is required'); + } + + $response = $this->http()->get(self::API_URL."/bot{$botToken}/getUpdates", [ + 'allowed_updates' => ['message', 'channel_post'], + 'limit' => $params['limit'] ?? 100, + ]); + + return $this->fromHttp($response, function ($data) { + $chats = []; + $seenIds = []; + + foreach ($data['result'] ?? [] as $update) { + $chat = $update['message']['chat'] ?? $update['channel_post']['chat'] ?? null; + + if ($chat && ! in_array($chat['id'], $seenIds)) { + $seenIds[] = $chat['id']; + $chats[] = [ + 'id' => (string) $chat['id'], + 'name' => $chat['title'] ?? $chat['first_name'] ?? $chat['username'] ?? '', + 'type' => $chat['type'] ?? 'private', + 'username' => $chat['username'] ?? null, + ]; + } + } + + return ['chats' => $chats]; + }); + } +}