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 <noreply@anthropic.com>
This commit is contained in:
parent
65b31b90a0
commit
79d206009c
29 changed files with 4046 additions and 0 deletions
17
composer.json
Normal file
17
composer.json
Normal file
|
|
@ -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
|
||||
}
|
||||
147
src/Bluesky/Auth.php
Normal file
147
src/Bluesky/Auth.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Bluesky;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Bluesky AT Protocol authentication.
|
||||
*
|
||||
* Uses app passwords (recommended) or OAuth 2.0.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://bsky.social/xrpc';
|
||||
|
||||
private string $identifier;
|
||||
|
||||
private string $appPassword;
|
||||
|
||||
private ?string $pdsUrl = null;
|
||||
|
||||
public function __construct(string $identifier = '', string $appPassword = '')
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
src/Bluesky/Delete.php
Normal file
86
src/Bluesky/Delete.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Bluesky;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Bluesky post deletion.
|
||||
*
|
||||
* Deletes app.bsky.feed.post records from the AT Protocol.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://bsky.social/xrpc';
|
||||
|
||||
private ?string $pdsUrl = null;
|
||||
|
||||
private ?string $did = null;
|
||||
|
||||
/**
|
||||
* Set custom PDS URL.
|
||||
*/
|
||||
public function withPds(string $pdsUrl): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
src/Bluesky/Media.php
Normal file
80
src/Bluesky/Media.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Bluesky;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Bluesky media upload.
|
||||
*
|
||||
* Uploads blobs to the AT Protocol for embedding in posts.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://bsky.social/xrpc';
|
||||
|
||||
private const MAX_SIZE = 1_000_000; // 1MB limit
|
||||
|
||||
private ?string $pdsUrl = null;
|
||||
|
||||
/**
|
||||
* Set custom PDS URL.
|
||||
*/
|
||||
public function withPds(string $pdsUrl): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
235
src/Bluesky/Post.php
Normal file
235
src/Bluesky/Post.php
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Bluesky;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Bluesky post publishing.
|
||||
*
|
||||
* Creates app.bsky.feed.post records in the AT Protocol.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://bsky.social/xrpc';
|
||||
|
||||
private ?string $pdsUrl = null;
|
||||
|
||||
private ?string $did = null;
|
||||
|
||||
/**
|
||||
* Set custom PDS URL.
|
||||
*/
|
||||
public function withPds(string $pdsUrl): self
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
189
src/Bluesky/Read.php
Normal file
189
src/Bluesky/Read.php
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Bluesky;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Bluesky profile and post reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://bsky.social/xrpc';
|
||||
|
||||
private ?string $pdsUrl = null;
|
||||
|
||||
/**
|
||||
* Set custom PDS URL.
|
||||
*/
|
||||
public function withPds(string $pdsUrl): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
src/Farcaster/Auth.php
Normal file
136
src/Farcaster/Auth.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Farcaster;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Farcaster authentication.
|
||||
*
|
||||
* Uses Signer keys tied to custody addresses.
|
||||
* For simplicity, we use Neynar API for managed signers.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const NEYNAR_API = 'https://api.neynar.com/v2/farcaster';
|
||||
|
||||
private string $apiKey;
|
||||
|
||||
private ?string $clientId = null;
|
||||
|
||||
public function __construct(string $apiKey = '', ?string $clientId = null)
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
65
src/Farcaster/Delete.php
Normal file
65
src/Farcaster/Delete.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Farcaster;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Farcaster cast deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const NEYNAR_API = 'https://api.neynar.com/v2/farcaster';
|
||||
|
||||
private string $apiKey = '';
|
||||
|
||||
private ?string $signerUuid = null;
|
||||
|
||||
/**
|
||||
* Set the Neynar API key.
|
||||
*/
|
||||
public function withApiKey(string $apiKey): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
138
src/Farcaster/Post.php
Normal file
138
src/Farcaster/Post.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Farcaster;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Farcaster cast publishing.
|
||||
*
|
||||
* Uses Neynar API for managed signers.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const NEYNAR_API = 'https://api.neynar.com/v2/farcaster';
|
||||
|
||||
private string $apiKey = '';
|
||||
|
||||
private ?string $signerUuid = null;
|
||||
|
||||
/**
|
||||
* Set the Neynar API key.
|
||||
*/
|
||||
public function withApiKey(string $apiKey): self
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
205
src/Farcaster/Read.php
Normal file
205
src/Farcaster/Read.php
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Farcaster;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Farcaster profile and cast reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const NEYNAR_API = 'https://api.neynar.com/v2/farcaster';
|
||||
|
||||
private string $apiKey = '';
|
||||
|
||||
private ?int $fid = null;
|
||||
|
||||
/**
|
||||
* Set the Neynar API key.
|
||||
*/
|
||||
public function withApiKey(string $apiKey): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
144
src/Lemmy/Auth.php
Normal file
144
src/Lemmy/Auth.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Lemmy authentication.
|
||||
*
|
||||
* Uses username/password for JWT token.
|
||||
* Federated - requires instance URL.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
public function __construct(string $instanceUrl = '')
|
||||
{
|
||||
$this->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'] ?? []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
src/Lemmy/Comment.php
Normal file
139
src/Lemmy/Comment.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Commentable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Lemmy comment publishing.
|
||||
*/
|
||||
class Comment implements Commentable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL.
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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] : [];
|
||||
}
|
||||
}
|
||||
192
src/Lemmy/Communities.php
Normal file
192
src/Lemmy/Communities.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Lemmy communities listing.
|
||||
*/
|
||||
class Communities implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL.
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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] : [];
|
||||
}
|
||||
}
|
||||
75
src/Lemmy/Delete.php
Normal file
75
src/Lemmy/Delete.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Lemmy post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL.
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
src/Lemmy/Post.php
Normal file
111
src/Lemmy/Post.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Lemmy post publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL.
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
214
src/Lemmy/Read.php
Normal file
214
src/Lemmy/Read.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Lemmy;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Lemmy post and profile reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL.
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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] : [];
|
||||
}
|
||||
}
|
||||
149
src/Mastodon/Auth.php
Normal file
149
src/Mastodon/Auth.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Mastodon;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Mastodon OAuth 2.0 authentication.
|
||||
*
|
||||
* Federated - requires instance URL.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl;
|
||||
|
||||
private string $clientId;
|
||||
|
||||
private string $clientSecret;
|
||||
|
||||
private string $redirectUri;
|
||||
|
||||
public function __construct(
|
||||
string $instanceUrl = '',
|
||||
string $clientId = '',
|
||||
string $clientSecret = '',
|
||||
string $redirectUri = ''
|
||||
) {
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
src/Mastodon/Delete.php
Normal file
49
src/Mastodon/Delete.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Mastodon;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Mastodon status deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL (required).
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
src/Mastodon/Media.php
Normal file
123
src/Mastodon/Media.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Mastodon;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Mastodon media upload.
|
||||
*
|
||||
* Supports images, video, and audio.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL (required).
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
126
src/Mastodon/Post.php
Normal file
126
src/Mastodon/Post.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Mastodon;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Mastodon status publishing.
|
||||
*
|
||||
* Creates toots/statuses on the fediverse.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL (required).
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
224
src/Mastodon/Read.php
Normal file
224
src/Mastodon/Read.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Mastodon;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Mastodon account and status reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private string $instanceUrl = '';
|
||||
|
||||
/**
|
||||
* Set the instance URL (required).
|
||||
*/
|
||||
public function forInstance(string $instanceUrl): self
|
||||
{
|
||||
$this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
193
src/Nostr/Auth.php
Normal file
193
src/Nostr/Auth.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Nostr;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Nostr authentication via cryptographic keys.
|
||||
*
|
||||
* No OAuth - uses NIP-01 secp256k1 key pairs.
|
||||
* Private keys should be stored securely (e.g., encrypted).
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
|
||||
private ?string $privateKey = null;
|
||||
|
||||
private ?string $publicKey = null;
|
||||
|
||||
public function __construct(?string $privateKey = null)
|
||||
{
|
||||
if ($privateKey) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
111
src/Nostr/Delete.php
Normal file
111
src/Nostr/Delete.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Nostr;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Nostr event deletion (soft delete via NIP-09).
|
||||
*
|
||||
* Creates kind 5 deletion events. Note: relays may
|
||||
* not honour deletion requests.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private ?string $privateKey = null;
|
||||
|
||||
private ?string $publicKey = null;
|
||||
|
||||
private array $relays = [];
|
||||
|
||||
/**
|
||||
* Set the private key for signing.
|
||||
*/
|
||||
public function withPrivateKey(string $privateKey): self
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
220
src/Nostr/Post.php
Normal file
220
src/Nostr/Post.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Nostr;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Nostr event publishing.
|
||||
*
|
||||
* Creates kind 1 (text note) events and publishes to relays.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private ?string $privateKey = null;
|
||||
|
||||
private ?string $publicKey = null;
|
||||
|
||||
private array $relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.snort.social',
|
||||
];
|
||||
|
||||
/**
|
||||
* Set the private key for signing.
|
||||
*/
|
||||
public function withPrivateKey(string $privateKey): self
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
199
src/Nostr/Read.php
Normal file
199
src/Nostr/Read.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Nostr;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Nostr profile and event reading.
|
||||
*
|
||||
* Queries relays via HTTP API (nostr.band, etc.).
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const NOSTR_BAND_API = 'https://api.nostr.band/v0';
|
||||
|
||||
private ?string $publicKey = null;
|
||||
|
||||
/**
|
||||
* Set the public key for user operations.
|
||||
*/
|
||||
public function withPublicKey(string $publicKey): self
|
||||
{
|
||||
$this->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] ?? [],
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
150
src/Threads/Auth.php
Normal file
150
src/Threads/Auth.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Threads;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Threads OAuth 2.0 authentication.
|
||||
*
|
||||
* Uses Instagram/Meta OAuth flow.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const AUTH_URL = 'https://threads.net/oauth/authorize';
|
||||
|
||||
private const TOKEN_URL = 'https://graph.threads.net/oauth/access_token';
|
||||
|
||||
private const API_URL = 'https://graph.threads.net/v1.0';
|
||||
|
||||
private string $clientId;
|
||||
|
||||
private string $clientSecret;
|
||||
|
||||
private string $redirectUri;
|
||||
|
||||
public function __construct(
|
||||
string $clientId = '',
|
||||
string $clientSecret = '',
|
||||
string $redirectUri = ''
|
||||
) {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
35
src/Threads/Delete.php
Normal file
35
src/Threads/Delete.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Threads;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Threads post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.threads.net/v1.0';
|
||||
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$response = $this->http()
|
||||
->delete(self::API_URL."/{$id}", [
|
||||
'access_token' => $this->accessToken(),
|
||||
]);
|
||||
|
||||
return $this->fromHttp($response, fn () => [
|
||||
'deleted' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
159
src/Threads/Post.php
Normal file
159
src/Threads/Post.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Threads;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Threads post publishing.
|
||||
*
|
||||
* Two-step process: create container, then publish.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.threads.net/v1.0';
|
||||
|
||||
private ?string $userId = null;
|
||||
|
||||
/**
|
||||
* Set the user ID for posting (required).
|
||||
*/
|
||||
public function forUser(string $userId): self
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
135
src/Threads/Read.php
Normal file
135
src/Threads/Read.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Web3\Threads;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Threads profile and post reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.threads.net/v1.0';
|
||||
|
||||
/**
|
||||
* Get a specific thread by ID.
|
||||
*/
|
||||
public function get(string $id): Response
|
||||
{
|
||||
$response = $this->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'] ?? []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue