return false;
}
- if (in_array($protocol, array_merge(self::NATIVE_SUPPORT, [self::ZOT, self::PHANTOM]))) {
+ if (in_array($protocol, array_merge(self::NATIVE_SUPPORT, [self::ZOT, self::BLUESKY, self::PHANTOM]))) {
return true;
}
}
/**
- * @return AtProtocol\Arguments
+ * @return ATProtocol\Actor
*/
public static function atpActor()
{
use DOMDocument;
use DomXPath;
use Exception;
+use Friendica\Content\Text\HTML;
use Friendica\Core\Hook;
use Friendica\Core\Logger;
use Friendica\Core\Protocol;
use Friendica\Network\HTTPClient\Client\HttpClientRequest;
use Friendica\Protocol\ActivityNamespace;
use Friendica\Protocol\ActivityPub;
+use Friendica\Protocol\ATProtocol;
use Friendica\Protocol\Diaspora;
use Friendica\Protocol\Email;
use Friendica\Protocol\Feed;
$parts = parse_url($uri);
if (empty($parts['scheme']) && empty($parts['host']) && (empty($parts['path']) || strpos($parts['path'], '@') === false)) {
- Logger::info('URI was not detectable', ['uri' => $uri]);
- return [];
+ Logger::info('URI was not detectable, probe for AT Protocol now', ['uri' => $uri]);
+ return self::atProtocol($uri);
}
// If the URI starts with "mailto:" then jump directly to the mail detection
}
if (empty($data)) {
+ $data = self::atProtocol($uri);
+ if (!empty($data)) {
+ return $data;
+ }
if (!empty($parts['scheme'])) {
return self::feed($uri);
} elseif (!empty($uid)) {
return (string)Uri::fromParts((array)(array)$baseParts);
}
+ /**
+ * Check for AT Protocol (Bluesky)
+ *
+ * @param string $uri Profile link
+ * @return array Profile data or empty array
+ */
+ private static function atProtocol(string $uri): array
+ {
+ if (parse_url($uri, PHP_URL_SCHEME) == 'did') {
+ $did = $uri;
+ } elseif (parse_url($uri, PHP_URL_PATH) == $uri && strpos($uri, '@') === false) {
+ $did = DI::atProtocol()->getDid($uri);
+ if (empty($did)) {
+ return [];
+ }
+ } elseif (Network::isValidHttpUrl($uri)) {
+ $did = DI::atProtocol()->getDidByProfile($uri);
+ if (empty($did)) {
+ return [];
+ }
+ } else {
+ return [];
+ }
+
+ $profile = DI::atProtocol()->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did]);
+ if (empty($profile) || empty($profile->did)) {
+ return [];
+ }
+
+ $nick = $profile->handle ?? $profile->did;
+ $name = $profile->displayName ?? $nick;
+
+ $data = [
+ 'network' => Protocol::BLUESKY,
+ 'url' => $profile->did,
+ 'alias' => ATProtocol::WEB . '/profile/' . $nick,
+ 'name' => $name ?: $nick,
+ 'nick' => $nick,
+ 'addr' => $nick,
+ 'poll' => ATProtocol::WEB . '/profile/' . $profile->did . '/rss',
+ 'photo' => $profile->avatar ?? '',
+ ];
+
+ if (!empty($profile->description)) {
+ $data['about'] = HTML::toBBCode($profile->description);
+ }
+
+ if (!empty($profile->banner)) {
+ $data['header'] = $profile->banner;
+ }
+
+ $directory = DI::atProtocol()->get(ATProtocol::DIRECTORY . '/' . $profile->did);
+ if (!empty($directory)) {
+ foreach ($directory->service as $service) {
+ if (($service->id == '#atproto_pds') && ($service->type == 'AtprotoPersonalDataServer') && !empty($service->serviceEndpoint)) {
+ $data['baseurl'] = $service->serviceEndpoint;
+ }
+ }
+
+ foreach ($directory->verificationMethod as $method) {
+ if (!empty($method->publicKeyMultibase)) {
+ $data['pubkey'] = $method->publicKeyMultibase;
+ }
+ }
+ }
+
+ return $data;
+ }
+
/**
* Check for feed contact
*
$this->httpClient = $httpClient;
}
+ /**
+ * Returns an array of user ids who want to import the Bluesky timeline
+ *
+ * @return array user ids
+ */
public function getUids(): array
{
$uids = [];
return $uids;
}
+ /**
+ * Fetches XRPC data
+ * @see https://atproto.com/specs/xrpc#lexicon-http-endpoints
+ *
+ * @param string $url for example "app.bsky.feed.getTimeline"
+ * @param array $parameters Array with parameters
+ * @param integer $uid User ID
+ * @return stdClass|null Fetched data
+ */
public function XRPCGet(string $url, array $parameters = [], int $uid = 0): ?stdClass
{
if (!empty($parameters)) {
return $data;
}
+ /**
+ * Fetch data from the given URL via GET and return it as a JSON class
+ *
+ * @param string $url HTTP URL
+ * @param array $opts HTTP options
+ * @return stdClass|null Fetched data
+ */
public function get(string $url, array $opts = []): ?stdClass
{
try {
return $data;
}
+ /**
+ * Perform an XRPC post for a given user
+ * @see https://atproto.com/specs/xrpc#lexicon-http-endpoints
+ *
+ * @param integer $uid User ID
+ * @param string $url Endpoints like "com.atproto.repo.createRecord"
+ * @param [type] $parameters array or StdClass with parameters
+ * @return stdClass|null
+ */
public function XRPCPost(int $uid, string $url, $parameters): ?stdClass
{
$data = $this->post($uid, '/xrpc/' . $url, json_encode($parameters), ['Content-type' => 'application/json', 'Authorization' => ['Bearer ' . $this->getUserToken($uid)]]);
return $data;
}
+ /**
+ * Post data to the user PDS
+ *
+ * @param integer $uid User ID
+ * @param string $url HTTP URL without the hostname
+ * @param string $params Parameter string
+ * @param array $headers HTTP header information
+ * @return stdClass|null
+ */
public function post(int $uid, string $url, string $params, array $headers): ?stdClass
{
$pds = $this->getUserPds($uid);
return $data;
}
+ /**
+ * Fetches the PDS for a given user
+ * @see https://atproto.com/guides/glossary#pds-personal-data-server
+ *
+ * @param integer $uid User ID or 0
+ * @return string|null PDS or null if the user has got no PDS assigned. If UID set to 0, the public api URL is used
+ */
private function getUserPds(int $uid): ?string
{
if ($uid == 0) {
return $pds;
}
+ /**
+ * Fetch the DID for a given user
+ * @see https://atproto.com/guides/glossary#did-decentralized-id
+ *
+ * @param integer $uid User ID
+ * @param boolean $refresh Default "false". If set to true, the DID is detected from the handle again.
+ * @return string|null DID or null if no DID has been found.
+ */
public function getUserDid(int $uid, bool $refresh = false): ?string
{
if (!$this->pConfig->get($uid, 'bluesky', 'post')) {
return $did;
}
+ /**
+ * Fetches the DID for a given handle
+ *
+ * @param string $handle The user handle
+ * @return string DID (did:plc:...)
+ */
public function getDid(string $handle): string
{
if ($handle == '') {
return '';
}
+ /**
+ * Fetches a DID for a given profile URL
+ *
+ * @param string $url HTTP path to the profile in the format https://bsky.app/profile/username
+ * @return string DID (did:plc:...)
+ */
public function getDidByProfile(string $url): string
{
if (preg_match('#^' . self::WEB . '/profile/(.+)#', $url, $matches)) {
return $ids['bsky_did'];
}
+ /**
+ * Fetches the DID of a given handle via a HTTP request to the .well-known URL.
+ * This is one of the ways, custom handles can be authorized.
+ *
+ * @param string $handle The user handle
+ * @return string DID (did:plc:...)
+ */
private function getDidByWellknown(string $handle): string
{
$curlResult = $this->httpClient->get('http://' . $handle . '/.well-known/atproto-did');
return '';
}
+ /**
+ * Fetches the DID of a given handle via a DND request.
+ * This is one of the ways, custom handles can be authorized.
+ *
+ * @param string $handle The user handle
+ * @return string DID (did:plc:...)
+ */
private function getDidByDns(string $handle): string
{
$records = @dns_get_record('_atproto.' . $handle . '.', DNS_TXT);
return '';
}
- private function getPdsOfDid(string $did): ?string
+ /**
+ * Fetch the PDS of a given DID
+ *
+ * @param string $did DID (did:plc:...)
+ * @return string|null URL of the PDS, e.g. https://enoki.us-east.host.bsky.network
+ */
+ public function getPdsOfDid(string $did): ?string
{
$data = $this->get(self::DIRECTORY . '/' . $did);
if (empty($data) || empty($data->service)) {
return null;
}
+ /**
+ * Checks if the provided DID matches the handle
+ *
+ * @param string $did DID (did:plc:...)
+ * @param string $handle The user handle
+ * @return boolean
+ */
private function isValidDid(string $did, string $handle): bool
{
$data = $this->get(self::DIRECTORY . '/' . $did);
return in_array('at://' . $handle, $data->alsoKnownAs);
}
+ /**
+ * Fetches the user token for a given user
+ *
+ * @param integer $uid User ID
+ * @return string user token
+ */
public function getUserToken(int $uid): string
{
$token = $this->pConfig->get($uid, 'bluesky', 'access_token');
return $token;
}
+ /**
+ * Refresh and returns the user token for a given user.
+ *
+ * @param integer $uid User ID
+ * @return string user token
+ */
private function refreshUserToken(int $uid): string
{
$token = $this->pConfig->get($uid, 'bluesky', 'refresh_token');
return $data->accessJwt;
}
+ /**
+ * Create a user token for the given user
+ *
+ * @param integer $uid User ID
+ * @param string $password Application password
+ * @return string user token
+ */
public function createUserToken(int $uid, string $password): string
{
$did = $this->getUserDid($uid);
$this->atprotocol = $atprotocol;
}
- public function syncContacts(int $uid)
+ /**
+ * Syncronize the contacts (followers, sharers) for the given user
+ *
+ * @param integer $uid User ID
+ * @return void
+ */
+ public function syncContacts(int $uid): void
{
$this->logger->info('Sync contacts for user - start', ['uid' => $uid]);
$contacts = Contact::selectToArray(['id', 'url', 'rel'], ['uid' => $uid, 'network' => Protocol::BLUESKY, 'rel' => [Contact::FRIEND, Contact::SHARING, Contact::FOLLOWER]]);
$this->logger->info('Sync contacts for user - done', ['uid' => $uid]);
}
- public function updateContactByDID(string $did)
+ /**
+ * Update a contact for a given DID and user id
+ *
+ * @param string $did DID (did:plc:...)
+ * @param integer $contact_uid User id of the contact to be updated
+ * @return void
+ */
+ public function updateContactByDID(string $did, int $contact_uid): void
{
- $profile = $this->atprotocol->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did]);
+ $profile = $this->atprotocol->XRPCGet('app.bsky.actor.getProfile', ['actor' => $did], $contact_uid);
if (empty($profile) || empty($profile->did)) {
return;
}
}
}
- /*
- @todo Add this part when the function will be callable with a uid
- if (!empty($profile->viewer)) {
- if (!empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
- $fields['rel'] = Contact::FRIEND;
- } elseif (!empty($profile->viewer->following) && empty($profile->viewer->followedBy)) {
- $fields['rel'] = Contact::SHARING;
- } elseif (empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
- $fields['rel'] = Contact::FOLLOWER;
- } else {
- $fields['rel'] = Contact::NOTHING;
- }
- }
- */
+ Contact::update($fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY]);
if (!empty($profile->avatar)) {
$contact = Contact::selectFirst(['id', 'avatar'], ['network' => Protocol::BLUESKY, 'nurl' => $did, 'uid' => 0]);
}
}
- $this->logger->notice('Update profile', ['did' => $profile->did, 'fields' => $fields]);
+ $this->logger->notice('Update global profile', ['did' => $profile->did, 'fields' => $fields]);
- Contact::update($fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY]);
+ if (!empty($profile->viewer) && ($contact_uid != 0)) {
+ if (!empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
+ $user_fields = ['rel' => Contact::FRIEND];
+ } elseif (!empty($profile->viewer->following) && empty($profile->viewer->followedBy)) {
+ $user_fields = ['rel' => Contact::SHARING];
+ } elseif (empty($profile->viewer->following) && !empty($profile->viewer->followedBy)) {
+ $user_fields = ['rel' => Contact::FOLLOWER];
+ } else {
+ $user_fields = ['rel' => Contact::NOTHING];
+ }
+ Contact::update($user_fields, ['nurl' => $profile->did, 'network' => Protocol::BLUESKY, 'uid' => $contact_uid]);
+ $this->logger->notice('Update user profile', ['uid' => $contact_uid, 'did' => $profile->did, 'fields' => $user_fields]);
+ }
}
- public function getContactByDID(string $did, int $uid, int $contact_uid): array
+ /**
+ * Fetch and possibly create a contact array for a given DID
+ *
+ * @param string $did The contact DID
+ * @param integer $uid "0" when either the public contact or the user contact is desired
+ * @param integer $contact_uid If not found, the contact will be created for this user id
+ * @param boolean $auto_update Default "false". If activated, the contact will be updated every 24 hours
+ * @return array Contact array
+ */
+ public function getContactByDID(string $did, int $uid, int $contact_uid, bool $auto_update = false): array
{
$contact = Contact::selectFirst([], ['network' => Protocol::BLUESKY, 'nurl' => $did, 'uid' => [$contact_uid, $uid]], ['order' => ['uid' => true]]);
- if (!empty($contact)) {
+ if (!empty($contact) && (!$auto_update || ($contact['updated'] > DateTimeFormat::utc('now -24 hours')))) {
return $contact;
}
$cid = $contact['id'];
}
- $this->updateContactByDID($did);
+ $this->updateContactByDID($did, $contact_uid);
return Contact::getById($cid);
}
private $uids = [];
private $self = [];
private $capped = false;
- private $next_stat = 0;
/** @var LoggerInterface */
private $logger;
$this->processor = $processor;
}
- // *****************************************
- // * Listener
- // *****************************************
- public function listen()
+ /**
+ * Listen to incoming webstream messages from Jetstream
+ *
+ * @return void
+ */
+ public function listen(): void
{
$timeout = 300;
$timeout_limit = 10;
}
}
- private function incrementMessages()
+ /**
+ * Increment the message counter for the statistics page
+ *
+ * @return void
+ */
+ private function incrementMessages(): void
{
$packets = (int)($this->keyValue->get('jetstream_messages') ?? 0);
if ($packets >= PHP_INT_MAX) {
$this->keyValue->set('jetstream_messages', $packets + 1);
}
+ /**
+ * Synchronize contacts for all active users
+ *
+ * @return void
+ */
private function syncContacts()
{
$active_uids = $this->atprotocol->getUids();
}
}
+ /**
+ * Set options like the followed DIDs
+ *
+ * @return void
+ */
private function setOptions()
{
$active_uids = $this->atprotocol->getUids();
}
}
+ /**
+ * Returns an array of DIDs provided by an array of contacts
+ *
+ * @param array $contacts Array of contact records
+ * @param array $uids Array with the user ids with enabled bluesky timeline import
+ * @param integer $did_limit Maximum limit of entries
+ * @param array $dids Array of DIDs that are added to the output list
+ * @return array DIDs
+ */
private function addDids(array $contacts, array $uids, int $did_limit, array $dids): array
{
foreach ($contacts as $contact) {
return $dids;
}
- private function route(stdClass $data)
+ /**
+ * Route incoming messages
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function route(stdClass $data): void
{
Item::incrementInbound(Protocol::BLUESKY);
}
}
- private function routeCommits(stdClass $data)
+ /**
+ * Route incoming commit messages
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function routeCommits(stdClass $data): void
{
$drift = $this->getDrift($data);
$this->logger->notice('Received commit', ['time' => date(DateTimeFormat::ATOM, $data->time_us / 1000000), 'drift' => $drift, 'capped' => $this->capped, 'did' => $data->did, 'operation' => $data->commit->operation, 'collection' => $data->commit->collection, 'timestamp' => $data->time_us]);
}
}
+ /**
+ * Calculate the drift between the server timestamp and the current time.
+ *
+ * @param stdClass $data message object
+ * @return integer The calculated drift
+ */
private function getDrift(stdClass $data): int
{
$drift = max(0, round(time() - $data->time_us / 1000000));
return $drift;
}
- private function routePost(stdClass $data, int $drift)
+ /**
+ * Route app.bsky.feed.post commits
+ *
+ * @param stdClass $data message object
+ * @param integer $drift
+ * @return void
+ */
+ private function routePost(stdClass $data, int $drift): void
{
switch ($data->commit->operation) {
case 'delete':
}
}
- private function routeRepost(stdClass $data, int $drift)
+ /**
+ * Route app.bsky.feed.repost commits
+ *
+ * @param stdClass $data message object
+ * @param integer $drift
+ * @return void
+ */
+ private function routeRepost(stdClass $data, int $drift): void
{
switch ($data->commit->operation) {
case 'delete':
}
}
- private function routeLike(stdClass $data)
+ /**
+ * Route app.bsky.feed.like commits
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function routeLike(stdClass $data): void
{
switch ($data->commit->operation) {
case 'delete':
}
}
- private function routeProfile(stdClass $data)
+ /**
+ * Route app.bsky.actor.profile commits
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function routeProfile(stdClass $data): void
{
switch ($data->commit->operation) {
case 'delete':
break;
case 'create':
- $this->actor->updateContactByDID($data->did);
+ $this->actor->updateContactByDID($data->did, 0);
break;
case 'update':
- $this->actor->updateContactByDID($data->did);
+ $this->actor->updateContactByDID($data->did, 0);
break;
default:
}
}
- private function routeFollow(stdClass $data)
+ /**
+ * Route app.bsky.graph.follow commits
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function routeFollow(stdClass $data): void
{
switch ($data->commit->operation) {
case 'delete':
}
}
- private function storeCommitMessage(stdClass $data)
+ /**
+ * Store commit messages for debugging purposes
+ *
+ * @param stdClass $data message object
+ * @return void
+ */
+ private function storeCommitMessage(stdClass $data): void
{
if ($this->config->get('debug', 'jetstream_log')) {
$tempfile = tempnam(System::getTempPath(), 'at-proto.commit.' . $data->commit->collection . '.' . $data->commit->operation . '-');