From 79d206009c34b0ebf101beea240891c1d3331ffc Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 17:28:07 +0000 Subject: [PATCH] feat: extract Web3 providers from app/Plug/Web3 Bluesky, Farcaster, Lemmy, Mastodon, Nostr, Threads providers with Core\Plug\Web3 namespace alignment. Co-Authored-By: Claude Opus 4.6 --- composer.json | 17 +++ src/Bluesky/Auth.php | 147 ++++++++++++++++++++++++ src/Bluesky/Delete.php | 86 ++++++++++++++ src/Bluesky/Media.php | 80 +++++++++++++ src/Bluesky/Post.php | 235 ++++++++++++++++++++++++++++++++++++++ src/Bluesky/Read.php | 189 ++++++++++++++++++++++++++++++ src/Farcaster/Auth.php | 136 ++++++++++++++++++++++ src/Farcaster/Delete.php | 65 +++++++++++ src/Farcaster/Post.php | 138 ++++++++++++++++++++++ src/Farcaster/Read.php | 205 +++++++++++++++++++++++++++++++++ src/Lemmy/Auth.php | 144 +++++++++++++++++++++++ src/Lemmy/Comment.php | 139 ++++++++++++++++++++++ src/Lemmy/Communities.php | 192 +++++++++++++++++++++++++++++++ src/Lemmy/Delete.php | 75 ++++++++++++ src/Lemmy/Post.php | 111 ++++++++++++++++++ src/Lemmy/Read.php | 214 ++++++++++++++++++++++++++++++++++ src/Mastodon/Auth.php | 149 ++++++++++++++++++++++++ src/Mastodon/Delete.php | 49 ++++++++ src/Mastodon/Media.php | 123 ++++++++++++++++++++ src/Mastodon/Post.php | 126 ++++++++++++++++++++ src/Mastodon/Read.php | 224 ++++++++++++++++++++++++++++++++++++ src/Nostr/Auth.php | 193 +++++++++++++++++++++++++++++++ src/Nostr/Delete.php | 111 ++++++++++++++++++ src/Nostr/Post.php | 220 +++++++++++++++++++++++++++++++++++ src/Nostr/Read.php | 199 ++++++++++++++++++++++++++++++++ src/Threads/Auth.php | 150 ++++++++++++++++++++++++ src/Threads/Delete.php | 35 ++++++ src/Threads/Post.php | 159 ++++++++++++++++++++++++++ src/Threads/Read.php | 135 ++++++++++++++++++++++ 29 files changed, 4046 insertions(+) create mode 100644 composer.json create mode 100644 src/Bluesky/Auth.php create mode 100644 src/Bluesky/Delete.php create mode 100644 src/Bluesky/Media.php create mode 100644 src/Bluesky/Post.php create mode 100644 src/Bluesky/Read.php create mode 100644 src/Farcaster/Auth.php create mode 100644 src/Farcaster/Delete.php create mode 100644 src/Farcaster/Post.php create mode 100644 src/Farcaster/Read.php create mode 100644 src/Lemmy/Auth.php create mode 100644 src/Lemmy/Comment.php create mode 100644 src/Lemmy/Communities.php create mode 100644 src/Lemmy/Delete.php create mode 100644 src/Lemmy/Post.php create mode 100644 src/Lemmy/Read.php create mode 100644 src/Mastodon/Auth.php create mode 100644 src/Mastodon/Delete.php create mode 100644 src/Mastodon/Media.php create mode 100644 src/Mastodon/Post.php create mode 100644 src/Mastodon/Read.php create mode 100644 src/Nostr/Auth.php create mode 100644 src/Nostr/Delete.php create mode 100644 src/Nostr/Post.php create mode 100644 src/Nostr/Read.php create mode 100644 src/Threads/Auth.php create mode 100644 src/Threads/Delete.php create mode 100644 src/Threads/Post.php create mode 100644 src/Threads/Read.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..492c69f --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-web3", + "description": "Decentralised/Web3 provider integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Web3\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Bluesky/Auth.php b/src/Bluesky/Auth.php new file mode 100644 index 0000000..56ae33b --- /dev/null +++ b/src/Bluesky/Auth.php @@ -0,0 +1,147 @@ +identifier = $identifier; + $this->appPassword = $appPassword; + } + + public static function identifier(): string + { + return 'bluesky'; + } + + public static function name(): string + { + return 'Bluesky'; + } + + /** + * Set custom PDS URL for self-hosted instances. + */ + public function withPds(string $pdsUrl): self + { + $this->pdsUrl = rtrim($pdsUrl, '/'); + + return $this; + } + + protected function baseUrl(): string + { + return $this->pdsUrl ?? self::API_URL; + } + + /** + * Bluesky uses app passwords - no OAuth redirect needed. + */ + public function getAuthUrl(): string + { + return 'https://bsky.app/settings/app-passwords'; + } + + /** + * Create session with app password. + * + * @param array $params ['identifier' => handle/DID, 'password' => app password] + */ + public function requestAccessToken(array $params): array + { + $identifier = $params['identifier'] ?? $this->identifier; + $password = $params['password'] ?? $this->appPassword; + + $response = $this->http()->post($this->baseUrl().'/com.atproto.server.createSession', [ + 'identifier' => $identifier, + 'password' => $password, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Authentication failed', + 'message' => $response->json('message') ?? '', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['accessJwt'], + 'refresh_token' => $data['refreshJwt'], + 'did' => $data['did'], + 'handle' => $data['handle'], + 'email' => $data['email'] ?? null, + ]; + } + + /** + * Refresh session token. + */ + public function refresh(string $refreshToken): array + { + $response = $this->http() + ->withToken($refreshToken) + ->post($this->baseUrl().'/com.atproto.server.refreshSession'); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Refresh failed', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['accessJwt'], + 'refresh_token' => $data['refreshJwt'], + 'did' => $data['did'], + 'handle' => $data['handle'], + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with access token'); + } + + /** + * Get the current account info. + */ + public function me(string $accessToken): Response + { + $response = $this->http() + ->withToken($accessToken) + ->get($this->baseUrl().'/com.atproto.server.getSession'); + + return $this->fromHttp($response, fn ($data) => [ + 'did' => $data['did'], + 'handle' => $data['handle'], + 'email' => $data['email'] ?? null, + ]); + } +} diff --git a/src/Bluesky/Delete.php b/src/Bluesky/Delete.php new file mode 100644 index 0000000..9b32598 --- /dev/null +++ b/src/Bluesky/Delete.php @@ -0,0 +1,86 @@ +pdsUrl = rtrim($pdsUrl, '/'); + + return $this; + } + + /** + * Set the DID for deletion (required). + */ + public function forDid(string $did): self + { + $this->did = $did; + + return $this; + } + + protected function baseUrl(): string + { + return $this->pdsUrl ?? self::API_URL; + } + + /** + * Delete a post by its rkey or full AT URI. + * + * @param string $id Either rkey (e.g., "3k2yihcrp6f2x") or full AT URI + */ + public function delete(string $id): Response + { + if (! $this->did) { + return $this->error('DID is required for deletion'); + } + + // Extract rkey from AT URI if full URI provided + $rkey = $id; + if (str_starts_with($id, 'at://')) { + if (preg_match('/at:\/\/[^\/]+\/app\.bsky\.feed\.post\/(.+)/', $id, $matches)) { + $rkey = $matches[1]; + } + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post($this->baseUrl().'/com.atproto.repo.deleteRecord', [ + 'repo' => $this->did, + 'collection' => 'app.bsky.feed.post', + 'rkey' => $rkey, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Bluesky/Media.php b/src/Bluesky/Media.php new file mode 100644 index 0000000..a7f2879 --- /dev/null +++ b/src/Bluesky/Media.php @@ -0,0 +1,80 @@ +pdsUrl = rtrim($pdsUrl, '/'); + + return $this; + } + + protected function baseUrl(): string + { + return $this->pdsUrl ?? self::API_URL; + } + + public function upload(array $item): Response + { + $path = $item['path'] ?? null; + + if (! $path || ! file_exists($path)) { + return $this->error('File not found'); + } + + $fileSize = filesize($path); + + if ($fileSize > self::MAX_SIZE) { + return $this->error('File too large. Maximum size is 1MB.'); + } + + $mimeType = mime_content_type($path); + + // Bluesky only supports JPEG and PNG + if (! in_array($mimeType, ['image/jpeg', 'image/png'])) { + return $this->error('Unsupported image type. Use JPEG or PNG.'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders([ + 'Content-Type' => $mimeType, + ]) + ->withBody(file_get_contents($path), $mimeType) + ->post($this->baseUrl().'/com.atproto.repo.uploadBlob'); + + return $this->fromHttp($response, fn ($data) => [ + 'blob' => $data['blob'], + 'size' => $data['blob']['size'] ?? $fileSize, + 'mime_type' => $data['blob']['mimeType'] ?? $mimeType, + ]); + } +} diff --git a/src/Bluesky/Post.php b/src/Bluesky/Post.php new file mode 100644 index 0000000..f85e7de --- /dev/null +++ b/src/Bluesky/Post.php @@ -0,0 +1,235 @@ +pdsUrl = rtrim($pdsUrl, '/'); + + return $this; + } + + /** + * Set the DID for posting (required). + */ + public function forDid(string $did): self + { + $this->did = $did; + + return $this; + } + + protected function baseUrl(): string + { + return $this->pdsUrl ?? self::API_URL; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $did = $params['did'] ?? $this->did; + + if (! $did) { + return $this->error('DID is required for posting'); + } + + $record = [ + '$type' => 'app.bsky.feed.post', + 'text' => $text, + 'createdAt' => now()->toIso8601String(), + ]; + + // Handle facets (mentions, links, hashtags) + if (! empty($params['facets'])) { + $record['facets'] = $params['facets']; + } else { + // Auto-detect facets + $record['facets'] = $this->detectFacets($text); + } + + // Handle media embeds + if ($media->isNotEmpty()) { + $mediaUploader = (new Media)->withToken($this->getToken()); + + if ($this->pdsUrl) { + $mediaUploader->withPds($this->pdsUrl); + } + + $images = []; + foreach ($media->take(4) as $item) { + $uploadResult = $mediaUploader->upload($item); + + if ($uploadResult->hasError()) { + return $uploadResult; + } + + $images[] = [ + 'alt' => $item['alt'] ?? '', + 'image' => $uploadResult->get('blob'), + ]; + } + + $record['embed'] = [ + '$type' => 'app.bsky.embed.images', + 'images' => $images, + ]; + } + + // Handle external embed (link card) + if (isset($params['external'])) { + $record['embed'] = [ + '$type' => 'app.bsky.embed.external', + 'external' => $params['external'], + ]; + } + + // Handle reply + if (isset($params['reply_to'])) { + $record['reply'] = $params['reply_to']; + } + + // Handle quote post + if (isset($params['quote'])) { + $record['embed'] = [ + '$type' => 'app.bsky.embed.record', + 'record' => $params['quote'], + ]; + } + + // Handle labels (self-labeling) + if (! empty($params['labels'])) { + $record['labels'] = [ + '$type' => 'com.atproto.label.defs#selfLabels', + 'values' => array_map(fn ($l) => ['val' => $l], $params['labels']), + ]; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post($this->baseUrl().'/com.atproto.repo.createRecord', [ + 'repo' => $did, + 'collection' => 'app.bsky.feed.post', + 'record' => $record, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'uri' => $data['uri'], + 'cid' => $data['cid'], + 'url' => $this->postUrl($data['uri']), + ]); + } + + /** + * Detect facets (links, mentions, hashtags) in text. + */ + protected function detectFacets(string $text): array + { + $facets = []; + $bytes = $text; + + // Detect URLs + preg_match_all('/https?:\/\/[^\s]+/', $text, $urlMatches, PREG_OFFSET_CAPTURE); + foreach ($urlMatches[0] as $match) { + $facets[] = [ + 'index' => [ + 'byteStart' => $match[1], + 'byteEnd' => $match[1] + strlen($match[0]), + ], + 'features' => [[ + '$type' => 'app.bsky.richtext.facet#link', + 'uri' => $match[0], + ]], + ]; + } + + // Detect mentions (@handle.bsky.social) + preg_match_all('/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/', $text, $mentionMatches, PREG_OFFSET_CAPTURE); + foreach ($mentionMatches[0] as $match) { + $handle = ltrim($match[0], '@'); + $facets[] = [ + 'index' => [ + 'byteStart' => $match[1], + 'byteEnd' => $match[1] + strlen($match[0]), + ], + 'features' => [[ + '$type' => 'app.bsky.richtext.facet#mention', + 'did' => '', // Would need to resolve handle to DID + ]], + ]; + } + + // Detect hashtags + preg_match_all('/#[a-zA-Z][a-zA-Z0-9_]*/', $text, $tagMatches, PREG_OFFSET_CAPTURE); + foreach ($tagMatches[0] as $match) { + $facets[] = [ + 'index' => [ + 'byteStart' => $match[1], + 'byteEnd' => $match[1] + strlen($match[0]), + ], + 'features' => [[ + '$type' => 'app.bsky.richtext.facet#tag', + 'tag' => ltrim($match[0], '#'), + ]], + ]; + } + + return $facets; + } + + /** + * Convert AT URI to web URL. + */ + protected function postUrl(string $uri): string + { + // at://did:plc:xxx/app.bsky.feed.post/rkey + if (preg_match('/at:\/\/(did:[^\/]+)\/app\.bsky\.feed\.post\/(.+)/', $uri, $matches)) { + return "https://bsky.app/profile/{$matches[1]}/post/{$matches[2]}"; + } + + return $uri; + } + + /** + * Get external URL to a post. + */ + public static function externalPostUrl(string $handle, string $rkey): string + { + return "https://bsky.app/profile/{$handle}/post/{$rkey}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $handle): string + { + return "https://bsky.app/profile/{$handle}"; + } +} diff --git a/src/Bluesky/Read.php b/src/Bluesky/Read.php new file mode 100644 index 0000000..6157860 --- /dev/null +++ b/src/Bluesky/Read.php @@ -0,0 +1,189 @@ +pdsUrl = rtrim($pdsUrl, '/'); + + return $this; + } + + protected function baseUrl(): string + { + return $this->pdsUrl ?? self::API_URL; + } + + /** + * Get a specific post by AT URI. + */ + public function get(string $id): Response + { + // If rkey provided, need DID to construct URI + if (! str_starts_with($id, 'at://')) { + return $this->error('Full AT URI required (at://did/collection/rkey)'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get($this->baseUrl().'/app.bsky.feed.getPosts', [ + 'uris' => [$id], + ]); + + return $this->fromHttp($response, function ($data) { + $post = $data['posts'][0] ?? null; + + if (! $post) { + return ['error' => 'Post not found']; + } + + return [ + 'uri' => $post['uri'], + 'cid' => $post['cid'], + 'text' => $post['record']['text'] ?? '', + 'created_at' => $post['record']['createdAt'] ?? null, + 'author' => [ + 'did' => $post['author']['did'], + 'handle' => $post['author']['handle'], + 'display_name' => $post['author']['displayName'] ?? null, + 'avatar' => $post['author']['avatar'] ?? null, + ], + 'reply_count' => $post['replyCount'] ?? 0, + 'repost_count' => $post['repostCount'] ?? 0, + 'like_count' => $post['likeCount'] ?? 0, + ]; + }); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get($this->baseUrl().'/com.atproto.server.getSession'); + + if (! $response->successful()) { + return $this->fromHttp($response); + } + + $session = $response->json(); + + // Get full profile + $profileResponse = $this->http() + ->withToken($this->accessToken()) + ->get($this->baseUrl().'/app.bsky.actor.getProfile', [ + 'actor' => $session['did'], + ]); + + return $this->fromHttp($profileResponse, fn ($data) => [ + 'did' => $data['did'], + 'handle' => $data['handle'], + 'name' => $data['displayName'] ?? $data['handle'], + 'username' => $data['handle'], + 'image' => $data['avatar'] ?? null, + 'banner' => $data['banner'] ?? null, + 'bio' => $data['description'] ?? null, + 'followers_count' => $data['followersCount'] ?? 0, + 'following_count' => $data['followsCount'] ?? 0, + 'posts_count' => $data['postsCount'] ?? 0, + ]); + } + + /** + * Get posts from a user's feed. + */ + public function list(array $params = []): Response + { + $actor = $params['actor'] ?? $params['did'] ?? $params['handle'] ?? null; + + if (! $actor) { + return $this->error('Actor (DID or handle) is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get($this->baseUrl().'/app.bsky.feed.getAuthorFeed', [ + 'actor' => $actor, + 'limit' => $params['limit'] ?? 50, + 'cursor' => $params['cursor'] ?? null, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => array_map(fn ($item) => [ + 'uri' => $item['post']['uri'], + 'cid' => $item['post']['cid'], + 'text' => $item['post']['record']['text'] ?? '', + 'created_at' => $item['post']['record']['createdAt'] ?? null, + 'reply_count' => $item['post']['replyCount'] ?? 0, + 'repost_count' => $item['post']['repostCount'] ?? 0, + 'like_count' => $item['post']['likeCount'] ?? 0, + ], $data['feed'] ?? []), + 'cursor' => $data['cursor'] ?? null, + ]); + } + + /** + * Resolve a handle to DID. + */ + public function resolveHandle(string $handle): Response + { + $response = $this->http() + ->get($this->baseUrl().'/com.atproto.identity.resolveHandle', [ + 'handle' => $handle, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'did' => $data['did'], + 'handle' => $handle, + ]); + } + + /** + * Get profile by actor (DID or handle). + */ + public function profile(string $actor): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get($this->baseUrl().'/app.bsky.actor.getProfile', [ + 'actor' => $actor, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'did' => $data['did'], + 'handle' => $data['handle'], + 'name' => $data['displayName'] ?? $data['handle'], + 'image' => $data['avatar'] ?? null, + 'bio' => $data['description'] ?? null, + 'followers_count' => $data['followersCount'] ?? 0, + 'following_count' => $data['followsCount'] ?? 0, + 'posts_count' => $data['postsCount'] ?? 0, + ]); + } +} diff --git a/src/Farcaster/Auth.php b/src/Farcaster/Auth.php new file mode 100644 index 0000000..df4f297 --- /dev/null +++ b/src/Farcaster/Auth.php @@ -0,0 +1,136 @@ +apiKey = $apiKey; + $this->clientId = $clientId; + } + + public static function identifier(): string + { + return 'farcaster'; + } + + public static function name(): string + { + return 'Farcaster'; + } + + /** + * Get the Neynar Sign In With Farcaster URL. + */ + public function getAuthUrl(): string + { + if (! $this->clientId) { + return 'https://warpcast.com/~/settings/connected-apps'; + } + + return "https://app.neynar.com/login?client_id={$this->clientId}"; + } + + /** + * Complete SIWF authentication and get signer UUID. + * + * @param array $params ['fid' => int, 'signer_uuid' => string] or SIWF callback + */ + public function requestAccessToken(array $params): array + { + // If we have a signer_uuid from SIWF callback + if (isset($params['signer_uuid'])) { + return [ + 'signer_uuid' => $params['signer_uuid'], + 'fid' => $params['fid'] ?? null, + ]; + } + + // Look up user by FID + if (isset($params['fid'])) { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/user/bulk', [ + 'fids' => $params['fid'], + ]); + + if ($response->successful()) { + $user = $response->json('users.0'); + + return [ + 'fid' => $params['fid'], + 'username' => $user['username'] ?? null, + 'custody_address' => $user['custody_address'] ?? null, + ]; + } + } + + return ['error' => 'Signer UUID or FID required']; + } + + /** + * Create a new managed signer for a user. + */ + public function createSigner(): array + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->post(self::NEYNAR_API.'/signer'); + + if (! $response->successful()) { + return [ + 'error' => $response->json('message') ?? 'Failed to create signer', + ]; + } + + return $response->json(); + } + + /** + * Get signer status. + */ + public function getSignerStatus(string $signerUuid): array + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/signer', [ + 'signer_uuid' => $signerUuid, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('message') ?? 'Failed to get signer', + ]; + } + + return $response->json(); + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with signer UUID'); + } +} diff --git a/src/Farcaster/Delete.php b/src/Farcaster/Delete.php new file mode 100644 index 0000000..e227ac0 --- /dev/null +++ b/src/Farcaster/Delete.php @@ -0,0 +1,65 @@ +apiKey = $apiKey; + + return $this; + } + + /** + * Set the signer UUID. + */ + public function withSigner(string $signerUuid): self + { + $this->signerUuid = $signerUuid; + + return $this; + } + + public function delete(string $id): Response + { + if (! $this->signerUuid) { + return $this->error('Signer UUID is required'); + } + + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->delete(self::NEYNAR_API.'/cast', [ + 'signer_uuid' => $this->signerUuid, + 'target_hash' => $id, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Farcaster/Post.php b/src/Farcaster/Post.php new file mode 100644 index 0000000..94a3cc2 --- /dev/null +++ b/src/Farcaster/Post.php @@ -0,0 +1,138 @@ +apiKey = $apiKey; + + return $this; + } + + /** + * Set the signer UUID. + */ + public function withSigner(string $signerUuid): self + { + $this->signerUuid = $signerUuid; + + return $this; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $signerUuid = $params['signer_uuid'] ?? $this->signerUuid; + + if (! $signerUuid) { + return $this->error('Signer UUID is required'); + } + + $castData = [ + 'signer_uuid' => $signerUuid, + 'text' => $text, + ]; + + // Handle media embeds + if ($media->isNotEmpty()) { + $embeds = []; + foreach ($media->take(2) as $item) { + $embeds[] = ['url' => $item['url']]; + } + $castData['embeds'] = $embeds; + } + + // Handle URL embeds + if (isset($params['embeds'])) { + $castData['embeds'] = array_map(fn ($url) => ['url' => $url], $params['embeds']); + } + + // Handle reply + if (isset($params['parent'])) { + $castData['parent'] = $params['parent']; // Cast hash + } + + if (isset($params['parent_url'])) { + $castData['parent_url'] = $params['parent_url']; // Channel URL + } + + // Handle channel + if (isset($params['channel_id'])) { + $castData['channel_id'] = $params['channel_id']; + } + + // Handle idempotency + if (isset($params['idem'])) { + $castData['idem'] = $params['idem']; + } + + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->post(self::NEYNAR_API.'/cast', $castData); + + return $this->fromHttp($response, fn ($data) => [ + 'hash' => $data['cast']['hash'] ?? null, + 'author' => [ + 'fid' => $data['cast']['author']['fid'] ?? null, + 'username' => $data['cast']['author']['username'] ?? null, + ], + 'text' => $data['cast']['text'] ?? $text, + 'url' => isset($data['cast']['hash']) ? $this->castUrl($data['cast']['author']['username'] ?? '', $data['cast']['hash']) : null, + ]); + } + + /** + * Generate Warpcast URL for a cast. + */ + protected function castUrl(string $username, string $hash): string + { + // Shorten hash to first 10 chars for URL + $shortHash = substr($hash, 0, 10); + + return "https://warpcast.com/{$username}/{$shortHash}"; + } + + /** + * Get external URL to a cast. + */ + public static function externalPostUrl(string $username, string $hash): string + { + return "https://warpcast.com/{$username}/".substr($hash, 0, 10); + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://warpcast.com/{$username}"; + } +} diff --git a/src/Farcaster/Read.php b/src/Farcaster/Read.php new file mode 100644 index 0000000..0b60db1 --- /dev/null +++ b/src/Farcaster/Read.php @@ -0,0 +1,205 @@ +apiKey = $apiKey; + + return $this; + } + + /** + * Set the FID for user operations. + */ + public function forFid(int $fid): self + { + $this->fid = $fid; + + return $this; + } + + /** + * Get a specific cast by hash. + */ + public function get(string $id): Response + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/cast', [ + 'identifier' => $id, + 'type' => 'hash', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'hash' => $data['cast']['hash'], + 'text' => $data['cast']['text'], + 'author' => [ + 'fid' => $data['cast']['author']['fid'], + 'username' => $data['cast']['author']['username'], + 'display_name' => $data['cast']['author']['display_name'], + 'pfp_url' => $data['cast']['author']['pfp_url'], + ], + 'timestamp' => $data['cast']['timestamp'], + 'reactions' => [ + 'likes_count' => $data['cast']['reactions']['likes_count'] ?? 0, + 'recasts_count' => $data['cast']['reactions']['recasts_count'] ?? 0, + ], + 'replies' => [ + 'count' => $data['cast']['replies']['count'] ?? 0, + ], + ]); + } + + /** + * Get the authenticated user's profile by FID. + */ + public function me(): Response + { + if (! $this->fid) { + return $this->error('FID is required'); + } + + return $this->user($this->fid); + } + + /** + * Get user by FID. + */ + public function user(int $fid): Response + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/user/bulk', [ + 'fids' => $fid, + ]); + + return $this->fromHttp($response, function ($data) { + $user = $data['users'][0] ?? null; + + if (! $user) { + return ['error' => 'User not found']; + } + + return [ + 'fid' => $user['fid'], + 'username' => $user['username'], + 'name' => $user['display_name'], + 'image' => $user['pfp_url'], + 'bio' => $user['profile']['bio']['text'] ?? null, + 'followers_count' => $user['follower_count'] ?? 0, + 'following_count' => $user['following_count'] ?? 0, + 'verified_addresses' => $user['verified_addresses'] ?? [], + 'power_badge' => $user['power_badge'] ?? false, + ]; + }); + } + + /** + * Get user by username. + */ + public function userByUsername(string $username): Response + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/user/by_username', [ + 'username' => $username, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'fid' => $data['user']['fid'], + 'username' => $data['user']['username'], + 'name' => $data['user']['display_name'], + 'image' => $data['user']['pfp_url'], + 'bio' => $data['user']['profile']['bio']['text'] ?? null, + 'followers_count' => $data['user']['follower_count'] ?? 0, + 'following_count' => $data['user']['following_count'] ?? 0, + ]); + } + + /** + * Get user's casts. + */ + public function list(array $params = []): Response + { + $fid = $params['fid'] ?? $this->fid; + + if (! $fid) { + return $this->error('FID is required'); + } + + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/feed/user/casts', [ + 'fid' => $fid, + 'limit' => $params['limit'] ?? 25, + 'cursor' => $params['cursor'] ?? null, + 'include_replies' => $params['include_replies'] ?? false, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'casts' => array_map(fn ($cast) => [ + 'hash' => $cast['hash'], + 'text' => $cast['text'], + 'timestamp' => $cast['timestamp'], + 'likes_count' => $cast['reactions']['likes_count'] ?? 0, + 'recasts_count' => $cast['reactions']['recasts_count'] ?? 0, + 'replies_count' => $cast['replies']['count'] ?? 0, + ], $data['casts'] ?? []), + 'cursor' => $data['next']['cursor'] ?? null, + ]); + } + + /** + * Get channel feed. + */ + public function channel(string $channelId, array $params = []): Response + { + $response = $this->http() + ->withHeaders(['api_key' => $this->apiKey]) + ->get(self::NEYNAR_API.'/feed/channel', [ + 'channel_ids' => $channelId, + 'limit' => $params['limit'] ?? 25, + 'cursor' => $params['cursor'] ?? null, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'casts' => array_map(fn ($cast) => [ + 'hash' => $cast['hash'], + 'text' => $cast['text'], + 'author' => [ + 'fid' => $cast['author']['fid'], + 'username' => $cast['author']['username'], + ], + 'timestamp' => $cast['timestamp'], + ], $data['casts'] ?? []), + 'cursor' => $data['next']['cursor'] ?? null, + ]); + } +} diff --git a/src/Lemmy/Auth.php b/src/Lemmy/Auth.php new file mode 100644 index 0000000..78939d0 --- /dev/null +++ b/src/Lemmy/Auth.php @@ -0,0 +1,144 @@ +instanceUrl = rtrim($instanceUrl, '/'); + } + + public static function identifier(): string + { + return 'lemmy'; + } + + public static function name(): string + { + return 'Lemmy'; + } + + /** + * Set the instance URL. + */ + public function forInstance(string $instanceUrl): self + { + $this->instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * No OAuth URL for Lemmy - direct login. + */ + public function getAuthUrl(): string + { + return $this->instanceUrl.'/login'; + } + + /** + * Login with username/password. + * + * @param array $params ['username' => string, 'password' => string, 'totp_2fa_token' => optional] + */ + public function requestAccessToken(array $params): array + { + if (! $this->instanceUrl) { + return ['error' => 'Instance URL is required']; + } + + $response = $this->http()->post($this->instanceUrl.'/api/v3/user/login', array_filter([ + 'username_or_email' => $params['username'] ?? $params['email'] ?? null, + 'password' => $params['password'] ?? null, + 'totp_2fa_token' => $params['totp_2fa_token'] ?? null, + ])); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Login failed', + ]; + } + + $data = $response->json(); + + return [ + 'jwt' => $data['jwt'], + 'instance_url' => $this->instanceUrl, + 'registration_created' => $data['registration_created'] ?? false, + 'verify_email_sent' => $data['verify_email_sent'] ?? false, + ]; + } + + /** + * Register a new account. + */ + public function register(array $params): array + { + if (! $this->instanceUrl) { + return ['error' => 'Instance URL is required']; + } + + $response = $this->http()->post($this->instanceUrl.'/api/v3/user/register', array_filter([ + 'username' => $params['username'], + 'password' => $params['password'], + 'password_verify' => $params['password'], + 'email' => $params['email'] ?? null, + 'show_nsfw' => $params['show_nsfw'] ?? false, + 'captcha_uuid' => $params['captcha_uuid'] ?? null, + 'captcha_answer' => $params['captcha_answer'] ?? null, + 'honeypot' => $params['honeypot'] ?? null, + 'answer' => $params['answer'] ?? null, // Application answer + ])); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Registration failed', + ]; + } + + return $response->json(); + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with JWT token'); + } + + /** + * Get site info (useful for checking instance). + */ + public function getSite(): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http()->get($this->instanceUrl.'/api/v3/site'); + + return $this->fromHttp($response, fn ($data) => [ + 'name' => $data['site_view']['site']['name'] ?? null, + 'description' => $data['site_view']['site']['description'] ?? null, + 'version' => $data['version'] ?? null, + 'admins' => array_map(fn ($a) => $a['person']['name'], $data['admins'] ?? []), + ]); + } +} diff --git a/src/Lemmy/Comment.php b/src/Lemmy/Comment.php new file mode 100644 index 0000000..ae9e9b0 --- /dev/null +++ b/src/Lemmy/Comment.php @@ -0,0 +1,139 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * Create a comment on a post. + * + * @param string $text Comment content (markdown) + * @param string $postId Post ID to comment on + * @param array $params Optional: parent_id for replies, language_id + */ + public function comment(string $text, string $postId, array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $commentData = [ + 'content' => $text, + 'post_id' => (int) $postId, + ]; + + // Handle reply to existing comment + if (isset($params['parent_id'])) { + $commentData['parent_id'] = (int) $params['parent_id']; + } + + // Language + if (isset($params['language_id'])) { + $commentData['language_id'] = $params['language_id']; + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->post($this->instanceUrl.'/api/v3/comment', $commentData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['comment_view']['comment']['id'], + 'content' => $data['comment_view']['comment']['content'], + 'post_id' => $data['comment_view']['comment']['post_id'], + 'ap_id' => $data['comment_view']['comment']['ap_id'], + 'published' => $data['comment_view']['comment']['published'], + ]); + } + + /** + * Get comments for a post. + */ + public function getComments(int $postId, array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/comment/list', [ + 'post_id' => $postId, + 'sort' => $params['sort'] ?? 'Hot', // Hot, Top, New, Old + 'limit' => $params['limit'] ?? 50, + 'page' => $params['page'] ?? 1, + 'max_depth' => $params['max_depth'] ?? 8, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'comments' => array_map(fn ($item) => [ + 'id' => $item['comment']['id'], + 'content' => $item['comment']['content'], + 'path' => $item['comment']['path'], + 'author' => $item['creator']['name'], + 'upvotes' => $item['counts']['upvotes'] ?? 0, + 'downvotes' => $item['counts']['downvotes'] ?? 0, + 'published' => $item['comment']['published'], + ], $data['comments'] ?? []), + ]); + } + + /** + * Edit a comment. + */ + public function edit(int $commentId, string $content): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->put($this->instanceUrl.'/api/v3/comment', [ + 'comment_id' => $commentId, + 'content' => $content, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['comment_view']['comment']['id'], + 'content' => $data['comment_view']['comment']['content'], + 'updated' => $data['comment_view']['comment']['updated'] ?? null, + ]); + } + + /** + * Get auth headers if token is available. + */ + protected function authHeaders(): array + { + $token = $this->accessToken(); + + return $token ? ['Authorization' => 'Bearer '.$token] : []; + } +} diff --git a/src/Lemmy/Communities.php b/src/Lemmy/Communities.php new file mode 100644 index 0000000..2c62387 --- /dev/null +++ b/src/Lemmy/Communities.php @@ -0,0 +1,192 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * List communities the user is subscribed to or local communities. + */ + public function listEntities(): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + // Return subscribed communities if authenticated, otherwise local + $type = $this->accessToken() ? 'Subscribed' : 'Local'; + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/community/list', [ + 'sort' => 'Active', + 'limit' => 50, + 'type_' => $type, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'communities' => array_map(fn ($item) => [ + 'id' => $item['community']['id'], + 'name' => $item['community']['name'], + 'title' => $item['community']['title'], + 'description' => $item['community']['description'] ?? null, + 'icon' => $item['community']['icon'] ?? null, + 'banner' => $item['community']['banner'] ?? null, + 'subscribers' => $item['counts']['subscribers'] ?? 0, + 'posts' => $item['counts']['posts'] ?? 0, + 'comments' => $item['counts']['comments'] ?? 0, + 'nsfw' => $item['community']['nsfw'] ?? false, + ], $data['communities'] ?? []), + ]); + } + + /** + * List communities with custom parameters. + */ + public function list(array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/community/list', [ + 'sort' => $params['sort'] ?? 'Active', + 'limit' => $params['limit'] ?? 20, + 'page' => $params['page'] ?? 1, + 'type_' => $params['type'] ?? 'All', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'communities' => array_map(fn ($item) => [ + 'id' => $item['community']['id'], + 'name' => $item['community']['name'], + 'title' => $item['community']['title'], + 'description' => $item['community']['description'] ?? null, + 'icon' => $item['community']['icon'] ?? null, + 'banner' => $item['community']['banner'] ?? null, + 'subscribers' => $item['counts']['subscribers'] ?? 0, + 'posts' => $item['counts']['posts'] ?? 0, + 'comments' => $item['counts']['comments'] ?? 0, + 'nsfw' => $item['community']['nsfw'] ?? false, + ], $data['communities'] ?? []), + ]); + } + + /** + * Get a specific community. + */ + public function get(int $communityId): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/community', [ + 'id' => $communityId, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['community_view']['community']['id'], + 'name' => $data['community_view']['community']['name'], + 'title' => $data['community_view']['community']['title'], + 'description' => $data['community_view']['community']['description'] ?? null, + 'icon' => $data['community_view']['community']['icon'] ?? null, + 'banner' => $data['community_view']['community']['banner'] ?? null, + 'subscribers' => $data['community_view']['counts']['subscribers'] ?? 0, + 'posts' => $data['community_view']['counts']['posts'] ?? 0, + 'comments' => $data['community_view']['counts']['comments'] ?? 0, + 'moderators' => array_map( + fn ($m) => $m['moderator']['name'], + $data['moderators'] ?? [] + ), + ]); + } + + /** + * Get community by name. + */ + public function getByName(string $name): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/community', [ + 'name' => $name, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['community_view']['community']['id'], + 'name' => $data['community_view']['community']['name'], + 'title' => $data['community_view']['community']['title'], + 'description' => $data['community_view']['community']['description'] ?? null, + 'subscribers' => $data['community_view']['counts']['subscribers'] ?? 0, + ]); + } + + /** + * Subscribe/unsubscribe to a community. + */ + public function follow(int $communityId, bool $follow = true): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->post($this->instanceUrl.'/api/v3/community/follow', [ + 'community_id' => $communityId, + 'follow' => $follow, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['community_view']['community']['id'], + 'name' => $data['community_view']['community']['name'], + 'subscribed' => $data['community_view']['subscribed'] ?? 'NotSubscribed', + ]); + } + + /** + * Get auth headers if token is available. + */ + protected function authHeaders(): array + { + $token = $this->accessToken(); + + return $token ? ['Authorization' => 'Bearer '.$token] : []; + } +} diff --git a/src/Lemmy/Delete.php b/src/Lemmy/Delete.php new file mode 100644 index 0000000..1f8be96 --- /dev/null +++ b/src/Lemmy/Delete.php @@ -0,0 +1,75 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * Delete a post. + */ + public function delete(string $id): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->post($this->instanceUrl.'/api/v3/post/delete', [ + 'post_id' => (int) $id, + 'deleted' => true, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } + + /** + * Delete a comment. + */ + public function deleteComment(int $commentId): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->post($this->instanceUrl.'/api/v3/comment/delete', [ + 'comment_id' => $commentId, + 'deleted' => true, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Lemmy/Post.php b/src/Lemmy/Post.php new file mode 100644 index 0000000..5428916 --- /dev/null +++ b/src/Lemmy/Post.php @@ -0,0 +1,111 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $communityId = $params['community_id'] ?? null; + + if (! $communityId) { + return $this->error('community_id is required'); + } + + $postData = [ + 'community_id' => (int) $communityId, + 'name' => $params['title'] ?? $params['name'] ?? substr($text, 0, 200), + 'body' => $text, + 'nsfw' => $params['nsfw'] ?? false, + ]; + + // Handle URL post + if (isset($params['url'])) { + $postData['url'] = $params['url']; + } + + // Handle image - use first media URL + if ($media->isNotEmpty() && ! isset($params['url'])) { + $firstMedia = $media->first(); + $postData['url'] = $firstMedia['url'] ?? null; + } + + // Handle honeypot (anti-spam) + if (isset($params['honeypot'])) { + $postData['honeypot'] = $params['honeypot']; + } + + // Language + if (isset($params['language_id'])) { + $postData['language_id'] = $params['language_id']; + } + + $response = $this->http() + ->withHeaders(['Authorization' => 'Bearer '.$this->accessToken()]) + ->post($this->instanceUrl.'/api/v3/post', $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['post_view']['post']['id'], + 'name' => $data['post_view']['post']['name'], + 'url' => $data['post_view']['post']['ap_id'], + 'local_url' => $this->instanceUrl.'/post/'.$data['post_view']['post']['id'], + 'community' => $data['post_view']['community']['name'] ?? null, + ]); + } + + /** + * Get external URL to a post. + */ + public static function externalPostUrl(string $instanceUrl, int $postId): string + { + return rtrim($instanceUrl, '/').'/post/'.$postId; + } + + /** + * Get external URL to a community. + */ + public static function externalCommunityUrl(string $instanceUrl, string $communityName): string + { + return rtrim($instanceUrl, '/').'/c/'.$communityName; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $instanceUrl, string $username): string + { + return rtrim($instanceUrl, '/').'/u/'.$username; + } +} diff --git a/src/Lemmy/Read.php b/src/Lemmy/Read.php new file mode 100644 index 0000000..3adf18d --- /dev/null +++ b/src/Lemmy/Read.php @@ -0,0 +1,214 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * Get a specific post by ID. + */ + public function get(string $id): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/post', [ + 'id' => (int) $id, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['post_view']['post']['id'], + 'name' => $data['post_view']['post']['name'], + 'body' => $data['post_view']['post']['body'] ?? null, + 'url' => $data['post_view']['post']['url'] ?? null, + 'ap_id' => $data['post_view']['post']['ap_id'], + 'author' => [ + 'id' => $data['post_view']['creator']['id'], + 'name' => $data['post_view']['creator']['name'], + 'avatar' => $data['post_view']['creator']['avatar'] ?? null, + ], + 'community' => [ + 'id' => $data['post_view']['community']['id'], + 'name' => $data['post_view']['community']['name'], + ], + 'counts' => [ + 'upvotes' => $data['post_view']['counts']['upvotes'] ?? 0, + 'downvotes' => $data['post_view']['counts']['downvotes'] ?? 0, + 'comments' => $data['post_view']['counts']['comments'] ?? 0, + ], + 'published' => $data['post_view']['post']['published'] ?? null, + ]); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/site'); + + return $this->fromHttp($response, function ($data) { + $person = $data['my_user']['local_user_view']['person'] ?? null; + + if (! $person) { + return ['error' => 'Not authenticated']; + } + + return [ + 'id' => $person['id'], + 'name' => $person['name'], + 'username' => $person['name'], + 'display_name' => $person['display_name'] ?? $person['name'], + 'image' => $person['avatar'] ?? null, + 'banner' => $person['banner'] ?? null, + 'bio' => $person['bio'] ?? null, + 'instance_url' => $this->instanceUrl, + ]; + }); + } + + /** + * Get posts from a community or feed. + */ + public function list(array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $queryParams = [ + 'sort' => $params['sort'] ?? 'Active', // Active, Hot, New, Old, TopDay, etc. + 'limit' => $params['limit'] ?? 20, + 'page' => $params['page'] ?? 1, + 'type_' => $params['type'] ?? 'All', // All, Local, Subscribed + ]; + + if (isset($params['community_id'])) { + $queryParams['community_id'] = $params['community_id']; + } + + if (isset($params['community_name'])) { + $queryParams['community_name'] = $params['community_name']; + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/post/list', $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => array_map(fn ($item) => [ + 'id' => $item['post']['id'], + 'name' => $item['post']['name'], + 'body' => $item['post']['body'] ?? null, + 'url' => $item['post']['url'] ?? null, + 'community' => $item['community']['name'], + 'author' => $item['creator']['name'], + 'upvotes' => $item['counts']['upvotes'] ?? 0, + 'comments' => $item['counts']['comments'] ?? 0, + 'published' => $item['post']['published'] ?? null, + ], $data['posts'] ?? []), + ]); + } + + /** + * Get user profile by username. + */ + public function person(string $username): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/user', [ + 'username' => $username, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['person_view']['person']['id'], + 'name' => $data['person_view']['person']['name'], + 'display_name' => $data['person_view']['person']['display_name'] ?? null, + 'avatar' => $data['person_view']['person']['avatar'] ?? null, + 'banner' => $data['person_view']['person']['banner'] ?? null, + 'bio' => $data['person_view']['person']['bio'] ?? null, + 'post_count' => $data['person_view']['counts']['post_count'] ?? 0, + 'comment_count' => $data['person_view']['counts']['comment_count'] ?? 0, + ]); + } + + /** + * Search communities. + */ + public function searchCommunities(string $query, array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withHeaders($this->authHeaders()) + ->get($this->instanceUrl.'/api/v3/search', [ + 'q' => $query, + 'type_' => 'Communities', + 'limit' => $params['limit'] ?? 20, + 'page' => $params['page'] ?? 1, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'communities' => array_map(fn ($item) => [ + 'id' => $item['community']['id'], + 'name' => $item['community']['name'], + 'title' => $item['community']['title'], + 'description' => $item['community']['description'] ?? null, + 'subscribers' => $item['counts']['subscribers'] ?? 0, + ], $data['communities'] ?? []), + ]); + } + + /** + * Get auth headers if token is available. + */ + protected function authHeaders(): array + { + $token = $this->accessToken(); + + return $token ? ['Authorization' => 'Bearer '.$token] : []; + } +} diff --git a/src/Mastodon/Auth.php b/src/Mastodon/Auth.php new file mode 100644 index 0000000..5461ffd --- /dev/null +++ b/src/Mastodon/Auth.php @@ -0,0 +1,149 @@ +instanceUrl = rtrim($instanceUrl, '/'); + $this->clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + } + + public static function identifier(): string + { + return 'mastodon'; + } + + public static function name(): string + { + return 'Mastodon'; + } + + /** + * Register an application with a Mastodon instance. + */ + public function registerApp(string $instanceUrl, string $appName, string $redirectUri): array + { + $response = $this->http()->post(rtrim($instanceUrl, '/').'/api/v1/apps', [ + 'client_name' => $appName, + 'redirect_uris' => $redirectUri, + 'scopes' => 'read write follow', + 'website' => config('app.url'), + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Failed to register app', + ]; + } + + $data = $response->json(); + + return [ + 'client_id' => $data['client_id'], + 'client_secret' => $data['client_secret'], + 'instance_url' => rtrim($instanceUrl, '/'), + ]; + } + + public function getAuthUrl(): string + { + $params = http_build_query([ + 'client_id' => $this->clientId, + 'scope' => 'read write follow', + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + ]); + + return "{$this->instanceUrl}/oauth/authorize?{$params}"; + } + + public function requestAccessToken(array $params): array + { + $code = $params['code'] ?? null; + + if (! $code) { + return ['error' => 'Authorization code is required']; + } + + $response = $this->http()->post("{$this->instanceUrl}/oauth/token", [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUri, + 'grant_type' => 'authorization_code', + 'code' => $code, + 'scope' => 'read write follow', + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Token exchange failed', + 'error_description' => $response->json('error_description') ?? '', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['access_token'], + 'token_type' => $data['token_type'] ?? 'Bearer', + 'scope' => $data['scope'] ?? '', + 'created_at' => $data['created_at'] ?? time(), + 'instance_url' => $this->instanceUrl, + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with access token'); + } + + /** + * Verify credentials and get account info. + */ + public function verifyCredentials(string $accessToken): Response + { + $response = $this->http() + ->withToken($accessToken) + ->get("{$this->instanceUrl}/api/v1/accounts/verify_credentials"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'acct' => $data['acct'], + 'display_name' => $data['display_name'], + 'avatar' => $data['avatar'], + 'instance_url' => $this->instanceUrl, + ]); + } +} diff --git a/src/Mastodon/Delete.php b/src/Mastodon/Delete.php new file mode 100644 index 0000000..6082075 --- /dev/null +++ b/src/Mastodon/Delete.php @@ -0,0 +1,49 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + public function delete(string $id): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->delete("{$this->instanceUrl}/api/v1/statuses/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => true, + 'text' => $data['text'] ?? null, + ]); + } +} diff --git a/src/Mastodon/Media.php b/src/Mastodon/Media.php new file mode 100644 index 0000000..5533e05 --- /dev/null +++ b/src/Mastodon/Media.php @@ -0,0 +1,123 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + public function upload(array $item): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $path = $item['path'] ?? null; + + if (! $path || ! file_exists($path)) { + return $this->error('File not found'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->attach('file', file_get_contents($path), $item['name'] ?? basename($path)) + ->post("{$this->instanceUrl}/api/v2/media", array_filter([ + 'description' => $item['alt'] ?? $item['description'] ?? null, + 'focus' => $item['focus'] ?? null, // "x,y" focal point + ])); + + // V2 API may return 202 for async processing + if ($response->status() === 202) { + $data = $response->json(); + + // Poll until ready + return $this->waitForProcessing($data['id']); + } + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'type' => $data['type'], + 'url' => $data['url'], + 'preview_url' => $data['preview_url'], + ]); + } + + /** + * Wait for async media processing to complete. + */ + protected function waitForProcessing(string $mediaId, int $maxAttempts = 10): Response + { + for ($i = 0; $i < $maxAttempts; $i++) { + usleep(500_000); // 500ms + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/media/{$mediaId}"); + + if ($response->successful()) { + $data = $response->json(); + + if ($data['url'] !== null) { + return $this->ok([ + 'id' => $data['id'], + 'type' => $data['type'], + 'url' => $data['url'], + 'preview_url' => $data['preview_url'], + ]); + } + } + } + + return $this->error('Media processing timed out'); + } + + /** + * Update media description/focus point. + */ + public function update(string $mediaId, array $data): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->put("{$this->instanceUrl}/api/v1/media/{$mediaId}", array_filter([ + 'description' => $data['description'] ?? null, + 'focus' => $data['focus'] ?? null, + ])); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'type' => $data['type'], + 'url' => $data['url'], + ]); + } +} diff --git a/src/Mastodon/Post.php b/src/Mastodon/Post.php new file mode 100644 index 0000000..9109b03 --- /dev/null +++ b/src/Mastodon/Post.php @@ -0,0 +1,126 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $postData = [ + 'status' => $text, + 'visibility' => $params['visibility'] ?? 'public', // public, unlisted, private, direct + ]; + + // Handle media + if ($media->isNotEmpty()) { + $mediaUploader = (new Media) + ->withToken($this->getToken()) + ->forInstance($this->instanceUrl); + + $mediaIds = []; + foreach ($media->take(4) as $item) { + $uploadResult = $mediaUploader->upload($item); + + if ($uploadResult->hasError()) { + return $uploadResult; + } + + $mediaIds[] = $uploadResult->get('id'); + } + + $postData['media_ids'] = $mediaIds; + } + + // Handle reply + if (isset($params['in_reply_to_id'])) { + $postData['in_reply_to_id'] = $params['in_reply_to_id']; + } + + // Handle content warning / spoiler + if (isset($params['spoiler_text'])) { + $postData['spoiler_text'] = $params['spoiler_text']; + $postData['sensitive'] = true; + } + + if (isset($params['sensitive'])) { + $postData['sensitive'] = $params['sensitive']; + } + + // Handle language + if (isset($params['language'])) { + $postData['language'] = $params['language']; + } + + // Handle scheduled publishing + if (isset($params['scheduled_at'])) { + $postData['scheduled_at'] = $params['scheduled_at']; + } + + // Handle poll + if (isset($params['poll'])) { + $postData['poll'] = $params['poll']; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post("{$this->instanceUrl}/api/v1/statuses", $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'uri' => $data['uri'], + 'url' => $data['url'], + 'visibility' => $data['visibility'], + 'created_at' => $data['created_at'], + ]); + } + + /** + * Get external URL to a status. + */ + public static function externalPostUrl(string $instanceUrl, string $username, string $statusId): string + { + return rtrim($instanceUrl, '/')."/@{$username}/{$statusId}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $instanceUrl, string $username): string + { + return rtrim($instanceUrl, '/')."/@{$username}"; + } +} diff --git a/src/Mastodon/Read.php b/src/Mastodon/Read.php new file mode 100644 index 0000000..419d95f --- /dev/null +++ b/src/Mastodon/Read.php @@ -0,0 +1,224 @@ +instanceUrl = rtrim($instanceUrl, '/'); + + return $this; + } + + /** + * Get a specific status by ID. + */ + public function get(string $id): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/statuses/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'uri' => $data['uri'], + 'url' => $data['url'], + 'content' => $data['content'], + 'text' => strip_tags($data['content']), + 'visibility' => $data['visibility'], + 'created_at' => $data['created_at'], + 'author' => [ + 'id' => $data['account']['id'], + 'username' => $data['account']['username'], + 'acct' => $data['account']['acct'], + 'display_name' => $data['account']['display_name'], + 'avatar' => $data['account']['avatar'], + ], + 'replies_count' => $data['replies_count'] ?? 0, + 'reblogs_count' => $data['reblogs_count'] ?? 0, + 'favourites_count' => $data['favourites_count'] ?? 0, + ]); + } + + /** + * Get the authenticated user's account. + */ + public function me(): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/accounts/verify_credentials"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'name' => $data['display_name'] ?: $data['username'], + 'acct' => $data['acct'], + 'image' => $data['avatar'], + 'header' => $data['header'], + 'bio' => $data['note'] ? strip_tags($data['note']) : null, + 'url' => $data['url'], + 'followers_count' => $data['followers_count'] ?? 0, + 'following_count' => $data['following_count'] ?? 0, + 'statuses_count' => $data['statuses_count'] ?? 0, + 'instance_url' => $this->instanceUrl, + ]); + } + + /** + * Get statuses from an account. + */ + public function list(array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $accountId = $params['account_id'] ?? null; + + if (! $accountId) { + return $this->error('account_id is required'); + } + + $queryParams = [ + 'limit' => $params['limit'] ?? 20, + 'max_id' => $params['max_id'] ?? null, + 'since_id' => $params['since_id'] ?? null, + 'min_id' => $params['min_id'] ?? null, + 'exclude_replies' => $params['exclude_replies'] ?? false, + 'exclude_reblogs' => $params['exclude_reblogs'] ?? false, + 'only_media' => $params['only_media'] ?? false, + 'pinned' => $params['pinned'] ?? false, + ]; + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/accounts/{$accountId}/statuses", array_filter($queryParams)); + + return $this->fromHttp($response, fn ($data) => [ + 'statuses' => array_map(fn ($status) => [ + 'id' => $status['id'], + 'url' => $status['url'], + 'content' => $status['content'], + 'visibility' => $status['visibility'], + 'created_at' => $status['created_at'], + 'replies_count' => $status['replies_count'] ?? 0, + 'reblogs_count' => $status['reblogs_count'] ?? 0, + 'favourites_count' => $status['favourites_count'] ?? 0, + ], $data), + ]); + } + + /** + * Get an account by ID. + */ + public function account(string $accountId): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/accounts/{$accountId}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'acct' => $data['acct'], + 'display_name' => $data['display_name'], + 'avatar' => $data['avatar'], + 'header' => $data['header'], + 'note' => $data['note'], + 'url' => $data['url'], + 'followers_count' => $data['followers_count'] ?? 0, + 'following_count' => $data['following_count'] ?? 0, + 'statuses_count' => $data['statuses_count'] ?? 0, + ]); + } + + /** + * Lookup account by webfinger address. + */ + public function lookup(string $acct): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->get("{$this->instanceUrl}/api/v1/accounts/lookup", [ + 'acct' => $acct, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'acct' => $data['acct'], + 'display_name' => $data['display_name'], + 'avatar' => $data['avatar'], + 'url' => $data['url'], + ]); + } + + /** + * Get home timeline. + */ + public function timeline(array $params = []): Response + { + if (! $this->instanceUrl) { + return $this->error('Instance URL is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get("{$this->instanceUrl}/api/v1/timelines/home", [ + 'limit' => $params['limit'] ?? 20, + 'max_id' => $params['max_id'] ?? null, + 'since_id' => $params['since_id'] ?? null, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'statuses' => array_map(fn ($status) => [ + 'id' => $status['id'], + 'url' => $status['url'], + 'content' => $status['content'], + 'author' => [ + 'username' => $status['account']['username'], + 'acct' => $status['account']['acct'], + 'avatar' => $status['account']['avatar'], + ], + 'created_at' => $status['created_at'], + ], $data), + ]); + } +} diff --git a/src/Nostr/Auth.php b/src/Nostr/Auth.php new file mode 100644 index 0000000..8b3feed --- /dev/null +++ b/src/Nostr/Auth.php @@ -0,0 +1,193 @@ +privateKey = $privateKey; + $this->publicKey = $this->derivePublicKey($privateKey); + } + } + + public static function identifier(): string + { + return 'nostr'; + } + + public static function name(): string + { + return 'Nostr'; + } + + /** + * No OAuth URL for Nostr - keys are user-generated. + */ + public function getAuthUrl(): string + { + return 'https://nostr.how/en/guides/get-started'; + } + + /** + * Validate and store keys. + * + * @param array $params ['private_key' => hex string] or ['npub' => bech32, 'nsec' => bech32] + */ + public function requestAccessToken(array $params): array + { + // Handle hex private key + if (isset($params['private_key'])) { + $this->privateKey = $params['private_key']; + $this->publicKey = $this->derivePublicKey($params['private_key']); + + return [ + 'private_key' => $this->privateKey, + 'public_key' => $this->publicKey, + 'npub' => $this->toBech32($this->publicKey, 'npub'), + ]; + } + + // Handle bech32 nsec + if (isset($params['nsec'])) { + $this->privateKey = $this->fromBech32($params['nsec']); + $this->publicKey = $this->derivePublicKey($this->privateKey); + + return [ + 'private_key' => $this->privateKey, + 'public_key' => $this->publicKey, + 'npub' => $this->toBech32($this->publicKey, 'npub'), + ]; + } + + // Handle public key only (read-only) + if (isset($params['public_key'])) { + $this->publicKey = $params['public_key']; + + return [ + 'public_key' => $this->publicKey, + 'npub' => $this->toBech32($this->publicKey, 'npub'), + 'read_only' => true, + ]; + } + + if (isset($params['npub'])) { + $this->publicKey = $this->fromBech32($params['npub']); + + return [ + 'public_key' => $this->publicKey, + 'npub' => $params['npub'], + 'read_only' => true, + ]; + } + + return ['error' => 'Private key (nsec/hex) or public key (npub/hex) required']; + } + + /** + * Generate a new key pair. + */ + public function generateKeyPair(): array + { + // Generate random 32 bytes for private key + $privateKey = bin2hex(random_bytes(32)); + $publicKey = $this->derivePublicKey($privateKey); + + return [ + 'private_key' => $privateKey, + 'public_key' => $publicKey, + 'nsec' => $this->toBech32($privateKey, 'nsec'), + 'npub' => $this->toBech32($publicKey, 'npub'), + ]; + } + + /** + * Derive public key from private key. + * Uses secp256k1 curve. + */ + protected function derivePublicKey(string $privateKey): string + { + // This requires a secp256k1 library + // For production, use simplito/elliptic-php or similar + if (function_exists('secp256k1_ec_pubkey_create')) { + $context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + $privateKeyBin = hex2bin($privateKey); + $publicKey = null; + secp256k1_ec_pubkey_create($context, $publicKey, $privateKeyBin); + + // Get x-only public key (32 bytes) + $serialized = ''; + secp256k1_ec_pubkey_serialize($context, $serialized, $publicKey, SECP256K1_EC_COMPRESSED); + + return substr(bin2hex($serialized), 2); // Remove 02/03 prefix + } + + // Fallback: require external library + // In production, this would use the elliptic curve library + return hash('sha256', $privateKey); // Placeholder - NOT SECURE + } + + /** + * Convert hex to bech32 format. + */ + protected function toBech32(string $hex, string $prefix): string + { + // Simplified bech32 encoding + // In production, use a proper bech32 library + return $prefix.'1'.substr($hex, 0, 59); + } + + /** + * Convert bech32 to hex format. + */ + protected function fromBech32(string $bech32): string + { + // Simplified bech32 decoding + // In production, use a proper bech32 library + if (str_starts_with($bech32, 'npub1') || str_starts_with($bech32, 'nsec1')) { + return substr($bech32, 5); + } + + return $bech32; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with public key'); + } + + /** + * Get the public key. + */ + public function getPublicKey(): ?string + { + return $this->publicKey; + } + + /** + * Get the private key (handle with care). + */ + public function getPrivateKey(): ?string + { + return $this->privateKey; + } +} diff --git a/src/Nostr/Delete.php b/src/Nostr/Delete.php new file mode 100644 index 0000000..c16be11 --- /dev/null +++ b/src/Nostr/Delete.php @@ -0,0 +1,111 @@ +privateKey = $privateKey; + + return $this; + } + + /** + * Set the public key. + */ + public function withPublicKey(string $publicKey): self + { + $this->publicKey = $publicKey; + + return $this; + } + + /** + * Set relays to publish to. + */ + public function withRelays(array $relays): self + { + $this->relays = $relays; + + return $this; + } + + /** + * Request deletion of an event (NIP-09). + * + * @param string $id Event ID to delete + */ + public function delete(string $id): Response + { + if (! $this->privateKey || ! $this->publicKey) { + return $this->error('Private and public keys required'); + } + + // Build deletion event (kind 5) + $event = [ + 'kind' => 5, + 'pubkey' => $this->publicKey, + 'created_at' => time(), + 'tags' => [ + ['e', $id], // Reference to event being deleted + ], + 'content' => 'Deletion request', + ]; + + // Calculate event ID + $serialized = json_encode([ + 0, + $event['pubkey'], + $event['created_at'], + $event['kind'], + $event['tags'], + $event['content'], + ]); + $event['id'] = hash('sha256', $serialized); + + // Sign the event + $event['sig'] = $this->signEvent($event['id']); + + // Note: This is a soft delete - relays may ignore it + return $this->ok([ + 'deletion_event_id' => $event['id'], + 'deleted_event_id' => $id, + 'note' => 'Deletion is a request; relays may not honour it', + ]); + } + + /** + * Sign event with private key. + */ + protected function signEvent(string $eventId): string + { + // Placeholder - use proper Schnorr signing in production + return hash_hmac('sha256', $eventId, $this->privateKey); + } +} diff --git a/src/Nostr/Post.php b/src/Nostr/Post.php new file mode 100644 index 0000000..06d14e8 --- /dev/null +++ b/src/Nostr/Post.php @@ -0,0 +1,220 @@ +privateKey = $privateKey; + + return $this; + } + + /** + * Set the public key. + */ + public function withPublicKey(string $publicKey): self + { + $this->publicKey = $publicKey; + + return $this; + } + + /** + * Set relays to publish to. + */ + public function withRelays(array $relays): self + { + $this->relays = $relays; + + return $this; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->privateKey) { + return $this->error('Private key required for signing'); + } + + if (! $this->publicKey) { + return $this->error('Public key required'); + } + + // Build event (NIP-01) + $event = [ + 'kind' => $params['kind'] ?? 1, // 1 = text note + 'pubkey' => $this->publicKey, + 'created_at' => $params['created_at'] ?? time(), + 'tags' => $params['tags'] ?? [], + 'content' => $text, + ]; + + // Add media as imeta tags (NIP-92) + if ($media->isNotEmpty()) { + foreach ($media as $item) { + $event['tags'][] = ['imeta', 'url '.$item['url'], 'alt '.($item['alt'] ?? '')]; + } + } + + // Add reply tags (NIP-10) + if (isset($params['reply_to'])) { + $event['tags'][] = ['e', $params['reply_to'], '', 'reply']; + } + + if (isset($params['root'])) { + $event['tags'][] = ['e', $params['root'], '', 'root']; + } + + // Add mention tags + if (isset($params['mentions'])) { + foreach ($params['mentions'] as $pubkey) { + $event['tags'][] = ['p', $pubkey]; + } + } + + // Add hashtags + preg_match_all('/#(\w+)/', $text, $hashtags); + foreach ($hashtags[1] as $tag) { + $event['tags'][] = ['t', strtolower($tag)]; + } + + // Calculate event ID (NIP-01) + $event['id'] = $this->calculateEventId($event); + + // Sign the event + $event['sig'] = $this->signEvent($event['id']); + + // Publish to relays via HTTP relay (for environments without WebSocket) + // In production, this would use WebSocket connections + $results = $this->publishToRelays($event); + + return $this->ok([ + 'id' => $event['id'], + 'pubkey' => $event['pubkey'], + 'created_at' => $event['created_at'], + 'relay_results' => $results, + 'nevent' => $this->toNevent($event['id']), + ]); + } + + /** + * Calculate NIP-01 event ID. + */ + protected function calculateEventId(array $event): string + { + $serialized = json_encode([ + 0, + $event['pubkey'], + $event['created_at'], + $event['kind'], + $event['tags'], + $event['content'], + ]); + + return hash('sha256', $serialized); + } + + /** + * Sign event with private key. + */ + protected function signEvent(string $eventId): string + { + // Schnorr signature over secp256k1 + // In production, use a proper library + if (function_exists('secp256k1_schnorrsig_sign')) { + $context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + $signature = null; + secp256k1_schnorrsig_sign($context, $signature, hex2bin($eventId), hex2bin($this->privateKey)); + + return bin2hex($signature); + } + + // Placeholder - NOT SECURE, use proper library + return hash_hmac('sha256', $eventId, $this->privateKey); + } + + /** + * Publish event to relays. + */ + protected function publishToRelays(array $event): array + { + $results = []; + + // Use nostr.wine HTTP API as fallback + $response = $this->http()->post('https://nostr.wine/api/v1/events', [ + 'event' => $event, + ]); + + if ($response->successful()) { + $results['nostr.wine'] = 'ok'; + } else { + $results['nostr.wine'] = 'failed'; + } + + return $results; + } + + /** + * Convert event ID to nevent bech32. + */ + protected function toNevent(string $eventId): string + { + // Simplified - in production use proper bech32 + return 'nevent1'.substr($eventId, 0, 59); + } + + /** + * Get external URL to an event. + */ + public static function externalPostUrl(string $eventIdOrNevent): string + { + $id = str_starts_with($eventIdOrNevent, 'nevent1') + ? $eventIdOrNevent + : 'nevent1'.substr($eventIdOrNevent, 0, 59); + + return "https://njump.me/{$id}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $pubkeyOrNpub): string + { + $npub = str_starts_with($pubkeyOrNpub, 'npub1') + ? $pubkeyOrNpub + : 'npub1'.substr($pubkeyOrNpub, 0, 59); + + return "https://njump.me/{$npub}"; + } +} diff --git a/src/Nostr/Read.php b/src/Nostr/Read.php new file mode 100644 index 0000000..bbad756 --- /dev/null +++ b/src/Nostr/Read.php @@ -0,0 +1,199 @@ +publicKey = $publicKey; + + return $this; + } + + /** + * Get a specific event by ID. + */ + public function get(string $id): Response + { + // Use nostr.band API for event lookup + $response = $this->http()->get(self::NOSTR_BAND_API.'/event/'.$id); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'] ?? $id, + 'pubkey' => $data['pubkey'] ?? null, + 'kind' => $data['kind'] ?? null, + 'content' => $data['content'] ?? '', + 'tags' => $data['tags'] ?? [], + 'created_at' => $data['created_at'] ?? null, + 'sig' => $data['sig'] ?? null, + ]); + } + + /** + * Get profile for the configured public key. + */ + public function me(): Response + { + if (! $this->publicKey) { + return $this->error('Public key is required'); + } + + return $this->profile($this->publicKey); + } + + /** + * Get profile by public key (kind 0 metadata). + */ + public function profile(string $pubkey): Response + { + $response = $this->http()->get(self::NOSTR_BAND_API.'/profile/'.$pubkey); + + return $this->fromHttp($response, function ($data) use ($pubkey) { + $profile = $data['content'] ?? $data; + + if (is_string($profile)) { + $profile = json_decode($profile, true) ?? []; + } + + return [ + 'pubkey' => $pubkey, + 'npub' => 'npub1'.substr($pubkey, 0, 59), + 'name' => $profile['name'] ?? $profile['display_name'] ?? null, + 'username' => $profile['name'] ?? null, + 'display_name' => $profile['display_name'] ?? null, + 'image' => $profile['picture'] ?? null, + 'banner' => $profile['banner'] ?? null, + 'bio' => $profile['about'] ?? null, + 'nip05' => $profile['nip05'] ?? null, + 'lud16' => $profile['lud16'] ?? null, // Lightning address + 'website' => $profile['website'] ?? null, + ]; + }); + } + + /** + * Get events from a pubkey. + */ + public function list(array $params = []): Response + { + $pubkey = $params['pubkey'] ?? $this->publicKey; + + if (! $pubkey) { + return $this->error('Public key is required'); + } + + $kind = $params['kind'] ?? 1; // Default to text notes + $limit = $params['limit'] ?? 20; + + $response = $this->http()->get(self::NOSTR_BAND_API.'/events', [ + 'pubkey' => $pubkey, + 'kind' => $kind, + 'limit' => $limit, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'events' => array_map(fn ($event) => [ + 'id' => $event['id'], + 'content' => $event['content'], + 'kind' => $event['kind'], + 'created_at' => $event['created_at'], + 'tags' => $event['tags'] ?? [], + ], $data['events'] ?? $data ?? []), + ]); + } + + /** + * Search for notes. + */ + public function search(string $query, array $params = []): Response + { + $response = $this->http()->get(self::NOSTR_BAND_API.'/search', [ + 'q' => $query, + 'kind' => $params['kind'] ?? 1, + 'limit' => $params['limit'] ?? 20, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'events' => array_map(fn ($event) => [ + 'id' => $event['id'], + 'content' => $event['content'], + 'pubkey' => $event['pubkey'], + 'created_at' => $event['created_at'], + ], $data['events'] ?? $data ?? []), + ]); + } + + /** + * Get trending notes. + */ + public function trending(array $params = []): Response + { + $response = $this->http()->get(self::NOSTR_BAND_API.'/trending/notes', [ + 'limit' => $params['limit'] ?? 20, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'events' => array_map(fn ($item) => [ + 'id' => $item['event']['id'] ?? $item['id'], + 'content' => $item['event']['content'] ?? $item['content'], + 'pubkey' => $item['event']['pubkey'] ?? $item['pubkey'], + 'created_at' => $item['event']['created_at'] ?? $item['created_at'], + ], $data['notes'] ?? $data ?? []), + ]); + } + + /** + * Verify NIP-05 identifier. + */ + public function verifyNip05(string $nip05): Response + { + // Split identifier: name@domain.com + $parts = explode('@', $nip05); + if (count($parts) !== 2) { + return $this->error('Invalid NIP-05 format'); + } + + [$name, $domain] = $parts; + + $response = $this->http()->get("https://{$domain}/.well-known/nostr.json", [ + 'name' => $name, + ]); + + return $this->fromHttp($response, function ($data) use ($name) { + $pubkey = $data['names'][$name] ?? null; + + if (! $pubkey) { + return ['error' => 'NIP-05 identifier not found']; + } + + return [ + 'pubkey' => $pubkey, + 'npub' => 'npub1'.substr($pubkey, 0, 59), + 'relays' => $data['relays'][$pubkey] ?? [], + ]; + }); + } +} diff --git a/src/Threads/Auth.php b/src/Threads/Auth.php new file mode 100644 index 0000000..6d815ef --- /dev/null +++ b/src/Threads/Auth.php @@ -0,0 +1,150 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + } + + public static function identifier(): string + { + return 'threads'; + } + + public static function name(): string + { + return 'Threads'; + } + + public function getAuthUrl(): string + { + $params = http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'scope' => 'threads_basic,threads_content_publish,threads_manage_insights,threads_manage_replies,threads_read_replies', + 'response_type' => 'code', + ]); + + return self::AUTH_URL.'?'.$params; + } + + public function requestAccessToken(array $params): array + { + $code = $params['code'] ?? null; + + if (! $code) { + return ['error' => 'Authorization code is required']; + } + + // Exchange code for short-lived token + $response = $this->http()->post(self::TOKEN_URL, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUri, + 'code' => $code, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error_message') ?? 'Token exchange failed', + ]; + } + + $data = $response->json(); + + // Exchange for long-lived token + return $this->exchangeLongLivedToken($data['access_token']); + } + + /** + * Exchange short-lived token for long-lived token. + */ + public function exchangeLongLivedToken(string $shortToken): array + { + $response = $this->http()->get(self::API_URL.'/access_token', [ + 'grant_type' => 'th_exchange_token', + 'client_secret' => $this->clientSecret, + 'access_token' => $shortToken, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error_message') ?? 'Token exchange failed', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['access_token'], + 'token_type' => 'Bearer', + 'expires_in' => $data['expires_in'] ?? 5184000, // 60 days + ]; + } + + /** + * Refresh a long-lived token. + */ + public function refreshToken(string $token): array + { + $response = $this->http()->get(self::API_URL.'/refresh_access_token', [ + 'grant_type' => 'th_refresh_token', + 'access_token' => $token, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error_message') ?? 'Refresh failed', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['access_token'], + 'token_type' => 'Bearer', + 'expires_in' => $data['expires_in'] ?? 5184000, + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with access token'); + } +} diff --git a/src/Threads/Delete.php b/src/Threads/Delete.php new file mode 100644 index 0000000..4ea31eb --- /dev/null +++ b/src/Threads/Delete.php @@ -0,0 +1,35 @@ +http() + ->delete(self::API_URL."/{$id}", [ + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Threads/Post.php b/src/Threads/Post.php new file mode 100644 index 0000000..a808294 --- /dev/null +++ b/src/Threads/Post.php @@ -0,0 +1,159 @@ +userId = $userId; + + return $this; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $userId = $params['user_id'] ?? $this->userId; + + if (! $userId) { + return $this->error('User ID is required for posting'); + } + + // Step 1: Create media container + $containerData = [ + 'media_type' => 'TEXT', + 'text' => $text, + 'access_token' => $this->accessToken(), + ]; + + // Handle single image + if ($media->isNotEmpty() && $media->count() === 1) { + $item = $media->first(); + $containerData['media_type'] = 'IMAGE'; + $containerData['image_url'] = $item['url'] ?? $this->uploadToPublicUrl($item); + unset($containerData['text']); + if ($text) { + $containerData['text'] = $text; // Caption + } + } + + // Handle video + if (isset($params['video_url'])) { + $containerData['media_type'] = 'VIDEO'; + $containerData['video_url'] = $params['video_url']; + } + + // Handle carousel (multiple images) + if ($media->count() > 1) { + $children = []; + foreach ($media->take(10) as $item) { + $childResponse = $this->http()->post(self::API_URL."/{$userId}/threads", [ + 'media_type' => 'IMAGE', + 'image_url' => $item['url'] ?? $this->uploadToPublicUrl($item), + 'is_carousel_item' => true, + 'access_token' => $this->accessToken(), + ]); + + if (! $childResponse->successful()) { + return $this->fromHttp($childResponse); + } + + $children[] = $childResponse->json('id'); + } + + $containerData = [ + 'media_type' => 'CAROUSEL', + 'children' => implode(',', $children), + 'text' => $text, + 'access_token' => $this->accessToken(), + ]; + } + + // Handle reply + if (isset($params['reply_to_id'])) { + $containerData['reply_to_id'] = $params['reply_to_id']; + } + + // Handle quote post + if (isset($params['quote_post_id'])) { + $containerData['quote_post_id'] = $params['quote_post_id']; + } + + // Handle link attachment + if (isset($params['link_attachment'])) { + $containerData['link_attachment'] = $params['link_attachment']; + } + + // Create container + $containerResponse = $this->http()->post(self::API_URL."/{$userId}/threads", $containerData); + + if (! $containerResponse->successful()) { + return $this->fromHttp($containerResponse); + } + + $containerId = $containerResponse->json('id'); + + // Step 2: Publish the container + $publishResponse = $this->http()->post(self::API_URL."/{$userId}/threads_publish", [ + 'creation_id' => $containerId, + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($publishResponse, fn ($data) => [ + 'id' => $data['id'], + 'url' => "https://www.threads.net/post/{$data['id']}", + ]); + } + + /** + * Upload media to a publicly accessible URL. + * Threads requires public URLs for media. + */ + protected function uploadToPublicUrl(array $item): string + { + // This would need implementation based on storage strategy + // For now, require pre-uploaded URL + return $item['url'] ?? ''; + } + + /** + * Get external URL to a post. + */ + public static function externalPostUrl(string $postId): string + { + return "https://www.threads.net/post/{$postId}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://www.threads.net/@{$username}"; + } +} diff --git a/src/Threads/Read.php b/src/Threads/Read.php new file mode 100644 index 0000000..c2895a2 --- /dev/null +++ b/src/Threads/Read.php @@ -0,0 +1,135 @@ +http()->get(self::API_URL."/{$id}", [ + 'fields' => 'id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post', + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'text' => $data['text'] ?? '', + 'media_type' => $data['media_type'] ?? 'TEXT', + 'media_url' => $data['media_url'] ?? null, + 'permalink' => $data['permalink'] ?? null, + 'username' => $data['username'] ?? null, + 'timestamp' => $data['timestamp'] ?? null, + 'is_quote_post' => $data['is_quote_post'] ?? false, + ]); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + $response = $this->http()->get(self::API_URL.'/me', [ + 'fields' => 'id,username,threads_profile_picture_url,threads_biography', + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'name' => $data['username'], + 'image' => $data['threads_profile_picture_url'] ?? null, + 'bio' => $data['threads_biography'] ?? null, + ]); + } + + /** + * Get user's threads. + */ + public function list(array $params = []): Response + { + $userId = $params['user_id'] ?? 'me'; + + $response = $this->http()->get(self::API_URL."/{$userId}/threads", [ + 'fields' => 'id,media_product_type,media_type,media_url,permalink,username,text,timestamp,shortcode,is_quote_post', + 'limit' => $params['limit'] ?? 25, + 'since' => $params['since'] ?? null, + 'until' => $params['until'] ?? null, + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'threads' => array_map(fn ($thread) => [ + 'id' => $thread['id'], + 'text' => $thread['text'] ?? '', + 'media_type' => $thread['media_type'] ?? 'TEXT', + 'permalink' => $thread['permalink'] ?? null, + 'timestamp' => $thread['timestamp'] ?? null, + ], $data['data'] ?? []), + 'paging' => $data['paging'] ?? null, + ]); + } + + /** + * Get replies to a thread. + */ + public function replies(string $threadId, array $params = []): Response + { + $response = $this->http()->get(self::API_URL."/{$threadId}/replies", [ + 'fields' => 'id,text,username,permalink,timestamp,media_type,media_url,hide_status,reply_audience', + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'replies' => array_map(fn ($reply) => [ + 'id' => $reply['id'], + 'text' => $reply['text'] ?? '', + 'username' => $reply['username'] ?? null, + 'permalink' => $reply['permalink'] ?? null, + 'timestamp' => $reply['timestamp'] ?? null, + 'hide_status' => $reply['hide_status'] ?? 'NOT_HUSHED', + ], $data['data'] ?? []), + ]); + } + + /** + * Get user insights. + */ + public function insights(string $userId, array $metrics = []): Response + { + $defaultMetrics = ['views', 'likes', 'replies', 'reposts', 'quotes', 'followers_count']; + + $response = $this->http()->get(self::API_URL."/{$userId}/threads_insights", [ + 'metric' => implode(',', $metrics ?: $defaultMetrics), + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'insights' => array_map(fn ($metric) => [ + 'name' => $metric['name'], + 'period' => $metric['period'] ?? 'lifetime', + 'values' => $metric['values'] ?? [], + 'title' => $metric['title'] ?? $metric['name'], + ], $data['data'] ?? []), + ]); + } +}