Compare commits

..

1 commit

Author SHA1 Message Date
153b6b7775 pnut: Import o timeline 2026-03-15 23:12:31 +00:00
11 changed files with 826 additions and 402 deletions

View file

@ -1,7 +1,7 @@
<?php <?php
/** /**
* Name: AT Protocol Connector (Bluesky, Eurosky, Blacksky, ...) * Name: Bluesky Connector
* Description: Post via AT Protocol, import timelines and feeds * Description: Post to Bluesky, import timelines and feeds
* Version: 1.1 * Version: 1.1
* Author: Michael Vogel <https://pirati.ca/profile/heluecht> * Author: Michael Vogel <https://pirati.ca/profile/heluecht>
* *
@ -46,7 +46,6 @@ use Friendica\Protocol\Activity;
use Friendica\Protocol\ATProtocol; use Friendica\Protocol\ATProtocol;
use Friendica\Protocol\Relay; use Friendica\Protocol\Relay;
use Friendica\Util\DateTimeFormat; use Friendica\Util\DateTimeFormat;
use Friendica\Util\ParseUrl;
use Friendica\Util\Strings; use Friendica\Util\Strings;
const BLUESKY_DEFAULT_POLL_INTERVAL = 10; // given in minutes const BLUESKY_DEFAULT_POLL_INTERVAL = 10; // given in minutes
@ -82,8 +81,6 @@ function bluesky_check_item_notification(array &$notification_data)
return; return;
} }
DI::atProtocol()->setApiForUser($notification_data['uid']);
$did = DI::atProtocol()->getUserDid($notification_data['uid']); $did = DI::atProtocol()->getUserDid($notification_data['uid']);
if (empty($did)) { if (empty($did)) {
return; return;
@ -99,16 +96,24 @@ function bluesky_item_by_link(array &$hookData)
return; return;
} }
DI::atProtocol()->setApiForUser($hookData['uid']); if (substr($hookData['uri'], 0, 5) != 'at://') {
if (!preg_match('#^' . ATProtocol::WEB . '/profile/(.+)/post/(.+)#', $hookData['uri'], $matches)) {
return;
}
if (!str_starts_with($hookData['uri'], 'at://')) { $did = DI::atProtocol()->getDid($matches[1]);
$data = ParseUrl::getSiteinfoCached($hookData['uri']); if (empty($did)) {
$uri = $data['atprotocol']['uri'] ?? ''; return;
}
DI::logger()->debug('Found bluesky post', ['uri' => $hookData['uri'], 'did' => $did, 'cid' => $matches[2]]);
$uri = 'at://' . $did . '/app.bsky.feed.post/' . $matches[2];
} else { } else {
$uri = $hookData['uri']; $uri = $hookData['uri'];
} }
$uri = DI::atpProcessor()->fetchMissingPost($uri, $hookData['uid'], Item::PR_FETCHED, 0, 0, '', false, Conversation::PARCEL_CONNECTOR); $uri = DI::atpProcessor()->fetchMissingPost($uri, $hookData['uid'], Item::PR_FETCHED, 0, 0);
DI::logger()->debug('Got post', ['uri' => $uri]); DI::logger()->debug('Got post', ['uri' => $uri]);
if (!empty($uri)) { if (!empty($uri)) {
$item = Post::selectFirst(['id'], ['uri' => $uri, 'uid' => $hookData['uid']]); $item = Post::selectFirst(['id'], ['uri' => $uri, 'uid' => $hookData['uid']]);
@ -120,22 +125,20 @@ function bluesky_item_by_link(array &$hookData)
function bluesky_support_follow(array &$data) function bluesky_support_follow(array &$data)
{ {
if ($data['protocol'] == Protocol::ATPROTO) { if ($data['protocol'] == Protocol::BLUESKY) {
$data['result'] = true; $data['result'] = true;
} }
} }
function bluesky_follow(array &$hook_data) function bluesky_follow(array &$hook_data)
{ {
DI::atProtocol()->setApiForUser($hook_data['uid']);
$token = DI::atProtocol()->getUserToken($hook_data['uid']); $token = DI::atProtocol()->getUserToken($hook_data['uid']);
if (empty($token)) { if (empty($token)) {
return; return;
} }
DI::logger()->debug('Check if contact is AT Protocol', ['data' => $hook_data]); DI::logger()->debug('Check if contact is bluesky', ['data' => $hook_data]);
$contact = DBA::selectFirst('contact', [], ['network' => Protocol::ATPROTO, 'nurl' => Strings::normaliseLink($hook_data['url']), 'uid' => [0, $hook_data['uid']]]); $contact = DBA::selectFirst('contact', [], ['network' => Protocol::BLUESKY, 'nurl' => Strings::normaliseLink($hook_data['url']), 'uid' => [0, $hook_data['uid']]]);
if (empty($contact)) { if (empty($contact)) {
return; return;
} }
@ -161,14 +164,12 @@ function bluesky_follow(array &$hook_data)
function bluesky_unfollow(array &$hook_data) function bluesky_unfollow(array &$hook_data)
{ {
DI::atProtocol()->setApiForUser($hook_data['uid']);
$token = DI::atProtocol()->getUserToken($hook_data['uid']); $token = DI::atProtocol()->getUserToken($hook_data['uid']);
if (empty($token)) { if (empty($token)) {
return; return;
} }
if ($hook_data['contact']['network'] != Protocol::ATPROTO) { if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
return; return;
} }
@ -184,14 +185,12 @@ function bluesky_unfollow(array &$hook_data)
function bluesky_block(array &$hook_data) function bluesky_block(array &$hook_data)
{ {
DI::atProtocol()->setApiForUser($hook_data['uid']);
$token = DI::atProtocol()->getUserToken($hook_data['uid']); $token = DI::atProtocol()->getUserToken($hook_data['uid']);
if (empty($token)) { if (empty($token)) {
return; return;
} }
if ($hook_data['contact']['network'] != Protocol::ATPROTO) { if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
return; return;
} }
@ -219,14 +218,12 @@ function bluesky_block(array &$hook_data)
function bluesky_unblock(array &$hook_data) function bluesky_unblock(array &$hook_data)
{ {
DI::atProtocol()->setApiForUser($hook_data['uid']);
$token = DI::atProtocol()->getUserToken($hook_data['uid']); $token = DI::atProtocol()->getUserToken($hook_data['uid']);
if (empty($token)) { if (empty($token)) {
return; return;
} }
if ($hook_data['contact']['network'] != Protocol::ATPROTO) { if ($hook_data['contact']['network'] != Protocol::BLUESKY) {
return; return;
} }
@ -246,7 +243,7 @@ function bluesky_addon_admin(string &$o)
$o = Renderer::replaceMacros($t, [ $o = Renderer::replaceMacros($t, [
'$submit' => DI::l10n()->t('Save Settings'), '$submit' => DI::l10n()->t('Save Settings'),
'$friendica_handles' => ['friendica_handles', DI::l10n()->t('Allow your users to use your hostname for their AT Protocol handles'), DI::config()->get('bluesky', 'friendica_handles'), DI::l10n()->t('Before enabling this option, you have to setup a wildcard domain configuration and you have to enable wildcard requests in your webserver configuration. On Apache this is done by adding "ServerAlias *.%s" to your HTTP configuration. You don\'t need to change the HTTPS configuration.', DI::baseUrl()->getHost())], '$friendica_handles' => ['friendica_handles', DI::l10n()->t('Allow your users to use your hostname for their Bluesky handles'), DI::config()->get('bluesky', 'friendica_handles'), DI::l10n()->t('Before enabling this option, you have to setup a wildcard domain configuration and you have to enable wildcard requests in your webserver configuration. On Apache this is done by adding "ServerAlias *.%s" to your HTTP configuration. You don\'t need to change the HTTPS configuration.', DI::baseUrl()->getHost())],
]); ]);
} }
@ -261,13 +258,10 @@ function bluesky_settings(array &$data)
return; return;
} }
DI::atProtocol()->setApiForUser(DI::userSession()->getLocalUserId());
$enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false; $enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post') ?? false;
$def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false; $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') ?? false;
$pds = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'pds'); $pds = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'pds');
$handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle'); $handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
$web = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'web');
$did = DI::atProtocol()->getUserDid(DI::userSession()->getLocalUserId()); $did = DI::atProtocol()->getUserDid(DI::userSession()->getLocalUserId());
$token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token'); $token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'access_token');
$import = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false; $import = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'import') ?? false;
@ -278,7 +272,7 @@ function bluesky_settings(array &$data)
if (DI::config()->get('bluesky', 'friendica_handles')) { if (DI::config()->get('bluesky', 'friendica_handles')) {
$self = User::getById(DI::userSession()->getLocalUserId(), ['nickname']); $self = User::getById(DI::userSession()->getLocalUserId(), ['nickname']);
$host_handle = $self['nickname'] . '.' . DI::baseUrl()->getHost(); $host_handle = $self['nickname'] . '.' . DI::baseUrl()->getHost();
$friendica_handle = ['bluesky_friendica_handle', DI::l10n()->t('Allow to use %s as your AT Protocol handle.', $host_handle), $custom_handle, DI::l10n()->t('When enabled, you can use %s as your AT Protocol handle. After you enabled this option, please go to https://bsky.app/settings and select to change your handle. Select that you have got your own domain. Then enter %s and select "No DNS Panel". Then select "Verify Text File".', $host_handle, $host_handle)]; $friendica_handle = ['bluesky_friendica_handle', DI::l10n()->t('Allow to use %s as your Bluesky handle.', $host_handle), $custom_handle, DI::l10n()->t('When enabled, you can use %s as your Bluesky handle. After you enabled this option, please go to https://bsky.app/settings and select to change your handle. Select that you have got your own domain. Then enter %s and select "No DNS Panel". Then select "Verify Text File".', $host_handle, $host_handle)];
if ($custom_handle) { if ($custom_handle) {
$handle = $host_handle; $handle = $host_handle;
} }
@ -286,31 +280,25 @@ function bluesky_settings(array &$data)
$friendica_handle = []; $friendica_handle = [];
} }
$web_frontend = ['' => 'System Default'];
foreach (DI::config()->get('atprotocol', 'frontends') as $key => $frontend) {
$web_frontend[$key] = $frontend[0];
}
$t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/bluesky/'); $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/bluesky/');
$html = Renderer::replaceMacros($t, [ $html = Renderer::replaceMacros($t, [
'$enable' => ['bluesky', DI::l10n()->t('Enable AT Protocol Addon'), $enabled], '$enable' => ['bluesky', DI::l10n()->t('Enable Bluesky Post Addon'), $enabled],
'$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post via AT Protocol by default'), $def_enabled], '$bydefault' => ['bluesky_bydefault', DI::l10n()->t('Post to Bluesky by default'), $def_enabled],
'$import' => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import], '$import' => ['bluesky_import', DI::l10n()->t('Import the remote timeline'), $import],
'$import_feeds' => ['bluesky_import_feeds', DI::l10n()->t('Import the pinned feeds'), $import_feeds, DI::l10n()->t('When activated, Posts will be imported from all the feeds that you pinned in AT Protocol.')], '$import_feeds' => ['bluesky_import_feeds', DI::l10n()->t('Import the pinned feeds'), $import_feeds, DI::l10n()->t('When activated, Posts will be imported from all the feeds that you pinned in Bluesky.')],
'$complete_threads' => ['bluesky_complete_threads', DI::l10n()->t('Complete the threads'), $complete_threads, DI::l10n()->t('When activated, the system fetches additional replies for the posts in the timeline. This leads to more complete threads.')], '$complete_threads' => ['bluesky_complete_threads', DI::l10n()->t('Complete the threads'), $complete_threads, DI::l10n()->t('When activated, the system fetches additional replies for the posts in the timeline. This leads to more complete threads.')],
'$custom_handle' => $friendica_handle, '$custom_handle' => $friendica_handle,
'$pds' => ['bluesky_pds', DI::l10n()->t('Personal Data Server'), $pds, DI::l10n()->t('The personal data server (PDS) is the system that hosts your profile.'), '', 'readonly'], '$pds' => ['bluesky_pds', DI::l10n()->t('Personal Data Server'), $pds, DI::l10n()->t('The personal data server (PDS) is the system that hosts your profile.'), '', 'readonly'],
'$handle' => ['bluesky_handle', DI::l10n()->t('AT Protocol handle'), $handle, '', '', $custom_handle ? 'readonly' : ''], '$handle' => ['bluesky_handle', DI::l10n()->t('Bluesky handle'), $handle, '', '', $custom_handle ? 'readonly' : ''],
'$did' => ['bluesky_did', DI::l10n()->t('AT Protocol DID'), $did, DI::l10n()->t('This is the unique identifier. It will be fetched automatically, when the handle is entered.'), '', 'readonly'], '$did' => ['bluesky_did', DI::l10n()->t('Bluesky DID'), $did, DI::l10n()->t('This is the unique identifier. It will be fetched automatically, when the handle is entered.'), '', 'readonly'],
'$password' => ['bluesky_password', DI::l10n()->t('AT Protocol app password'), '', DI::l10n()->t("Please don't add your real password here, but instead create a specific app password in the settings of your AT Protocol system.")], '$password' => ['bluesky_password', DI::l10n()->t('Bluesky app password'), '', DI::l10n()->t("Please don't add your real password here, but instead create a specific app password in the Bluesky settings.")],
'$web' => ['bluesky_web', DI::l10n()->t('Web front end'), $web, DI::l10n()->t('Choose your preferred external web front end for displaying posts and profiles.'), $web_frontend, ''],
'$status' => bluesky_get_status($handle, $did, $pds, $token), '$status' => bluesky_get_status($handle, $did, $pds, $token),
]); ]);
$data = [ $data = [
'connector' => 'bluesky', 'connector' => 'bluesky',
'title' => DI::l10n()->t('AT Protocol (Bluesky, Eurosky, Blacksky, ...) Import/Export'), 'title' => DI::l10n()->t('Bluesky Import/Export'),
'image' => 'images/500px-AT_Protocol_logo.png', 'image' => 'images/bluesky.jpg',
'enabled' => $enabled, 'enabled' => $enabled,
'html' => $html, 'html' => $html,
]; ];
@ -340,7 +328,7 @@ function bluesky_get_status(string $handle = null, string $did = null, string $p
switch ($status) { switch ($status) {
case ATProtocol::STATUS_TOKEN_OK: case ATProtocol::STATUS_TOKEN_OK:
return DI::l10n()->t("You are authenticated to the AT Protocol PDS. For security reasons the password isn't stored."); return DI::l10n()->t("You are authenticated to Bluesky. For security reasons the password isn't stored.");
case ATProtocol::STATUS_SUCCESS: case ATProtocol::STATUS_SUCCESS:
return DI::l10n()->t('The communication with the personal data server service (PDS) is established.'); return DI::l10n()->t('The communication with the personal data server service (PDS) is established.');
case ATProtocol::STATUS_API_FAIL; case ATProtocol::STATUS_API_FAIL;
@ -362,8 +350,6 @@ function bluesky_settings_post(array &$b)
return; return;
} }
DI::atProtocol()->setApiForUser(DI::userSession()->getLocalUserId());
$old_pds = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'pds'); $old_pds = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'pds');
$old_handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle'); $old_handle = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'handle');
$old_did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did'); $old_did = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'did');
@ -377,11 +363,6 @@ function bluesky_settings_post(array &$b)
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import_feeds', intval($_POST['bluesky_import_feeds'])); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'import_feeds', intval($_POST['bluesky_import_feeds']));
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'complete_threads', intval($_POST['bluesky_complete_threads'])); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'complete_threads', intval($_POST['bluesky_complete_threads']));
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'friendica_handle', intval($_POST['bluesky_friendica_handle'] ?? false)); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'friendica_handle', intval($_POST['bluesky_friendica_handle'] ?? false));
if ($_POST['bluesky_web'] <> '') {
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'bluesky', 'web', $_POST['bluesky_web']);
} else {
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'bluesky', 'web');
}
if (!empty($handle)) { if (!empty($handle)) {
$did = DI::atProtocol()->getUserDid(DI::userSession()->getLocalUserId(), empty($old_did) || $old_handle != $handle); $did = DI::atProtocol()->getUserDid(DI::userSession()->getLocalUserId(), empty($old_did) || $old_handle != $handle);
@ -420,7 +401,7 @@ function bluesky_jot_nets(array &$jotnets_fields)
'type' => 'checkbox', 'type' => 'checkbox',
'field' => [ 'field' => [
'bluesky_enable', 'bluesky_enable',
DI::l10n()->t('Post via the AT Protocol'), DI::l10n()->t('Post to Bluesky'),
DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default') DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'bluesky', 'post_by_default')
] ]
]; ];
@ -454,8 +435,6 @@ function bluesky_cron()
$pconfigs = DBA::selectToArray('pconfig', [], ["`cat` = ? AND `k` IN (?, ?) AND `v`", 'bluesky', 'import', 'import_feeds']); $pconfigs = DBA::selectToArray('pconfig', [], ["`cat` = ? AND `k` IN (?, ?) AND `v`", 'bluesky', 'import', 'import_feeds']);
foreach ($pconfigs as $pconfig) { foreach ($pconfigs as $pconfig) {
DI::atProtocol()->setApiForUser($pconfig['uid']);
if (empty(DI::atProtocol()->getUserDid($pconfig['uid']))) { if (empty(DI::atProtocol()->getUserDid($pconfig['uid']))) {
DI::logger()->debug('User has got no valid DID', ['uid' => $pconfig['uid']]); DI::logger()->debug('User has got no valid DID', ['uid' => $pconfig['uid']]);
continue; continue;
@ -499,7 +478,7 @@ function bluesky_cron()
$last_clean = DI::keyValue()->get('bluesky_last_clean'); $last_clean = DI::keyValue()->get('bluesky_last_clean');
if (empty($last_clean) || ($last_clean + 86400 < time())) { if (empty($last_clean) || ($last_clean + 86400 < time())) {
DI::logger()->notice('Start contact cleanup'); DI::logger()->notice('Start contact cleanup');
$contacts = DBA::select('account-user-view', ['id', 'pid'], ["`network` = ? AND `uid` != ? AND `rel` = ?", Protocol::ATPROTO, 0, Contact::NOTHING]); $contacts = DBA::select('account-user-view', ['id', 'pid'], ["`network` = ? AND `uid` != ? AND `rel` = ?", Protocol::BLUESKY, 0, Contact::NOTHING]);
while ($contact = DBA::fetch($contacts)) { while ($contact = DBA::fetch($contacts)) {
Worker::add(Worker::PRIORITY_LOW, 'MergeContact', $contact['pid'], $contact['id'], 0); Worker::add(Worker::PRIORITY_LOW, 'MergeContact', $contact['pid'], $contact['id'], 0);
} }
@ -526,9 +505,9 @@ function bluesky_hook_fork(array &$b)
} }
if (DI::pConfig()->get($post['uid'], 'bluesky', 'import')) { if (DI::pConfig()->get($post['uid'], 'bluesky', 'import')) {
// Don't post if it isn't a reply to an AT Protocol post // Don't post if it isn't a reply to a bluesky post
if (($post['gravity'] != Item::GRAVITY_PARENT) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::ATPROTO])) { if (($post['gravity'] != Item::GRAVITY_PARENT) && !Post::exists(['id' => $post['parent'], 'network' => Protocol::BLUESKY])) {
DI::logger()->notice('No AT Protocol parent found', ['item' => $post['id']]); DI::logger()->notice('No bluesky parent found', ['item' => $post['id']]);
$b['execute'] = false; $b['execute'] = false;
return; return;
} }
@ -570,8 +549,6 @@ function bluesky_post_local(array &$b)
function bluesky_send(array &$b) function bluesky_send(array &$b)
{ {
DI::atProtocol()->setApiForUser($b['uid']);
if (($b['created'] !== $b['edited']) && !$b['deleted']) { if (($b['created'] !== $b['edited']) && !$b['deleted']) {
return; return;
} }
@ -586,7 +563,7 @@ function bluesky_send(array &$b)
if ($b['deleted']) { if ($b['deleted']) {
$uri = DI::atpProcessor()->getUriClass($b['uri']); $uri = DI::atpProcessor()->getUriClass($b['uri']);
if (empty($uri)) { if (empty($uri)) {
DI::logger()->debug('Not an AT Protocol post', ['uri' => $b['uri']]); DI::logger()->debug('Not a bluesky post', ['uri' => $b['uri']]);
return; return;
} }
bluesky_delete_post($b['uri'], $b['uid']); bluesky_delete_post($b['uri'], $b['uid']);
@ -597,7 +574,7 @@ function bluesky_send(array &$b)
$parent = DI::atpProcessor()->getUriClass($b['thr-parent']); $parent = DI::atpProcessor()->getUriClass($b['thr-parent']);
if (empty($root) || empty($parent)) { if (empty($root) || empty($parent)) {
DI::logger()->debug('No AT Protocol post', ['parent' => $b['parent'], 'thr-parent' => $b['thr-parent']]); DI::logger()->debug('No bluesky post', ['parent' => $b['parent'], 'thr-parent' => $b['thr-parent']]);
return; return;
} }
@ -616,11 +593,9 @@ function bluesky_send(array &$b)
bluesky_create_post($b); bluesky_create_post($b);
} }
function bluesky_create_activity(array $item, ?stdClass $parent = null) function bluesky_create_activity(array $item, stdClass $parent = null)
{ {
$uid = $item['uid']; $uid = $item['uid'];
DI::atProtocol()->setApiForUser($uid);
$token = DI::atProtocol()->getUserToken($uid); $token = DI::atProtocol()->getUserToken($uid);
if (empty($token)) { if (empty($token)) {
return; return;
@ -672,8 +647,6 @@ function bluesky_create_activity(array $item, ?stdClass $parent = null)
function bluesky_create_post(array $item, stdClass $root = null, stdClass $parent = null) function bluesky_create_post(array $item, stdClass $root = null, stdClass $parent = null)
{ {
$uid = $item['uid']; $uid = $item['uid'];
DI::atProtocol()->setApiForUser($uid);
$token = DI::atProtocol()->getUserToken($uid); $token = DI::atProtocol()->getUserToken($uid);
if (empty($token)) { if (empty($token)) {
return; return;
@ -708,7 +681,7 @@ function bluesky_create_post(array $item, stdClass $root = null, stdClass $paren
$urls = bluesky_get_urls($item['body']); $urls = bluesky_get_urls($item['body']);
$item['body'] = $urls['body']; $item['body'] = $urls['body'];
$msg = Plaintext::getPost($item, 300, false, BBCode::ATPROTOCOL); $msg = Plaintext::getPost($item, 300, false, BBCode::BLUESKY);
foreach ($msg['parts'] as $key => $part) { foreach ($msg['parts'] as $key => $part) {
$facets = bluesky_get_facets($part, $urls['urls']); $facets = bluesky_get_facets($part, $urls['urls']);
@ -973,7 +946,7 @@ function bluesky_upload_blob(int $uid, array $photo): ?stdClass
return null; return null;
} }
Item::incrementOutbound(Protocol::ATPROTO); Item::incrementOutbound(Protocol::BLUESKY);
DI::logger()->debug('Uploaded blob', ['return' => $data, 'uid' => $uid, 'retrial' => $retrial, 'height' => $new_height, 'width' => $new_width, 'size' => $new_size, 'orig-height' => $height, 'orig-width' => $width, 'orig-size' => $size]); DI::logger()->debug('Uploaded blob', ['return' => $data, 'uid' => $uid, 'retrial' => $retrial, 'height' => $new_height, 'width' => $new_width, 'size' => $new_size, 'orig-height' => $height, 'orig-width' => $width, 'orig-size' => $size]);
return $data->blob; return $data->blob;
} }
@ -991,8 +964,6 @@ function bluesky_delete_post(string $uri, int $uid)
function bluesky_fetch_timeline(int $uid) function bluesky_fetch_timeline(int $uid)
{ {
DI::atProtocol()->setApiForUser($uid);
$data = DI::atProtocol()->XRPCGet('app.bsky.feed.getTimeline', [], $uid); $data = DI::atProtocol()->XRPCGet('app.bsky.feed.getTimeline', [], $uid);
if (empty($data)) { if (empty($data)) {
return; return;
@ -1049,7 +1020,7 @@ function bluesky_process_reason(stdClass $reason, string $uri, int $uid)
$contact = DI::atpActor()->getContactByDID($reason->by->did, $uid, 0); $contact = DI::atpActor()->getContactByDID($reason->by->did, $uid, 0);
$item = [ $item = [
'network' => Protocol::ATPROTO, 'network' => Protocol::BLUESKY,
'protocol' => Conversation::PARCEL_CONNECTOR, 'protocol' => Conversation::PARCEL_CONNECTOR,
'uid' => $uid, 'uid' => $uid,
'wall' => false, 'wall' => false,
@ -1086,8 +1057,6 @@ function bluesky_process_reason(stdClass $reason, string $uri, int $uid)
function bluesky_fetch_notifications(int $uid) function bluesky_fetch_notifications(int $uid)
{ {
DI::atProtocol()->setApiForUser($uid);
$data = DI::atProtocol()->XRPCGet('app.bsky.notification.listNotifications', [], $uid); $data = DI::atProtocol()->XRPCGet('app.bsky.notification.listNotifications', [], $uid);
if (empty($data->notifications)) { if (empty($data->notifications)) {
return; return;
@ -1161,8 +1130,6 @@ function bluesky_fetch_notifications(int $uid)
function bluesky_fetch_feed(int $uid, string $feed) function bluesky_fetch_feed(int $uid, string $feed)
{ {
DI::atProtocol()->setApiForUser($uid);
$data = DI::atProtocol()->XRPCGet('app.bsky.feed.getFeed', ['feed' => $feed], $uid); $data = DI::atProtocol()->XRPCGet('app.bsky.feed.getFeed', ['feed' => $feed], $uid);
if (empty($data)) { if (empty($data)) {
return; return;

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-18 13:20+0000\n" "POT-Creation-Date: 2024-09-29 18:16+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,126 +17,117 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: bluesky.php:248 #: bluesky.php:335
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
#: bluesky.php:249 #: bluesky.php:336
msgid "Allow your users to use your hostname for their AT Protocol handles" msgid "Allow your users to use your hostname for their Bluesky handles"
msgstr "" msgstr ""
#: bluesky.php:249 #: bluesky.php:336
#, php-format #, php-format
msgid "Before enabling this option, you have to setup a wildcard domain configuration and you have to enable wildcard requests in your webserver configuration. On Apache this is done by adding \"ServerAlias *.%s\" to your HTTP configuration. You don't need to change the HTTPS configuration." msgid "Before enabling this option, you have to setup a wildcard domain configuration and you have to enable wildcard requests in your webserver configuration. On Apache this is done by adding \"ServerAlias *.%s\" to your HTTP configuration. You don't need to change the HTTPS configuration."
msgstr "" msgstr ""
#: bluesky.php:281 #: bluesky.php:365
#, php-format #, php-format
msgid "Allow to use %s as your AT Protocol handle." msgid "Allow to use %s as your Bluesky handle."
msgstr "" msgstr ""
#: bluesky.php:281 #: bluesky.php:365
#, php-format #, php-format
msgid "When enabled, you can use %s as your AT Protocol handle. After you enabled this option, please go to https://bsky.app/settings and select to change your handle. Select that you have got your own domain. Then enter %s and select \"No DNS Panel\". Then select \"Verify Text File\"." msgid "When enabled, you can use %s as your Bluesky handle. After you enabled this option, please go to https://bsky.app/settings and select to change your handle. Select that you have got your own domain. Then enter %s and select \"No DNS Panel\". Then select \"Verify Text File\"."
msgstr "" msgstr ""
#: bluesky.php:298 #: bluesky.php:375
msgid "Enable AT Protocol Addon" msgid "Enable Bluesky Post Addon"
msgstr "" msgstr ""
#: bluesky.php:299 #: bluesky.php:376
msgid "Post via AT Protocol by default" msgid "Post to Bluesky by default"
msgstr "" msgstr ""
#: bluesky.php:300 #: bluesky.php:377
msgid "Import the remote timeline" msgid "Import the remote timeline"
msgstr "" msgstr ""
#: bluesky.php:301 #: bluesky.php:378
msgid "Import the pinned feeds" msgid "Import the pinned feeds"
msgstr "" msgstr ""
#: bluesky.php:301 #: bluesky.php:378
msgid "When activated, Posts will be imported from all the feeds that you pinned in AT Protocol." msgid "When activated, Posts will be imported from all the feeds that you pinned in Bluesky."
msgstr "" msgstr ""
#: bluesky.php:302 #: bluesky.php:379
msgid "Complete the threads" msgid "Complete the threads"
msgstr "" msgstr ""
#: bluesky.php:302 #: bluesky.php:379
msgid "When activated, the system fetches additional replies for the posts in the timeline. This leads to more complete threads." msgid "When activated, the system fetches additional replies for the posts in the timeline. This leads to more complete threads."
msgstr "" msgstr ""
#: bluesky.php:304 #: bluesky.php:381
msgid "Personal Data Server" msgid "Personal Data Server"
msgstr "" msgstr ""
#: bluesky.php:304 #: bluesky.php:381
msgid "The personal data server (PDS) is the system that hosts your profile." msgid "The personal data server (PDS) is the system that hosts your profile."
msgstr "" msgstr ""
#: bluesky.php:305 #: bluesky.php:382
msgid "AT Protocol handle" msgid "Bluesky handle"
msgstr "" msgstr ""
#: bluesky.php:306 #: bluesky.php:383
msgid "AT Protocol DID" msgid "Bluesky DID"
msgstr "" msgstr ""
#: bluesky.php:306 #: bluesky.php:383
msgid "This is the unique identifier. It will be fetched automatically, when the handle is entered." msgid "This is the unique identifier. It will be fetched automatically, when the handle is entered."
msgstr "" msgstr ""
#: bluesky.php:307 #: bluesky.php:384
msgid "AT Protocol app password" msgid "Bluesky app password"
msgstr "" msgstr ""
#: bluesky.php:307 #: bluesky.php:384
msgid "Please don't add your real password here, but instead create a specific app password in the settings of your AT Protocol system." msgid "Please don't add your real password here, but instead create a specific app password in the Bluesky settings."
msgstr "" msgstr ""
#: bluesky.php:308 #: bluesky.php:390
msgid "Web front end" msgid "Bluesky Import/Export"
msgstr "" msgstr ""
#: bluesky.php:308 #: bluesky.php:400
msgid "Choose your preferred external web front end for displaying posts and profiles."
msgstr ""
#: bluesky.php:314
msgid "AT Protocol (Bluesky, Eurosky, Blacksky, ...) Import/Export"
msgstr ""
#: bluesky.php:324
msgid "You are not authenticated. Please enter your handle and the app password." msgid "You are not authenticated. Please enter your handle and the app password."
msgstr "" msgstr ""
#: bluesky.php:345 #: bluesky.php:420
msgid "You are authenticated to the AT Protocol PDS. For security reasons the password isn't stored." msgid "You are authenticated to Bluesky. For security reasons the password isn't stored."
msgstr "" msgstr ""
#: bluesky.php:347 #: bluesky.php:422
msgid "The communication with the personal data server service (PDS) is established." msgid "The communication with the personal data server service (PDS) is established."
msgstr "" msgstr ""
#: bluesky.php:349 #: bluesky.php:424
#, php-format msgid "Communication issues with the personal data server service (PDS)."
msgid "Communication issues with the personal data server service (PDS): %s"
msgstr "" msgstr ""
#: bluesky.php:351 #: bluesky.php:426
msgid "The DID for the provided handle could not be detected. Please check if you entered the correct handle." msgid "The DID for the provided handle could not be detected. Please check if you entered the correct handle."
msgstr "" msgstr ""
#: bluesky.php:353 #: bluesky.php:428
msgid "The personal data server service (PDS) could not be detected." msgid "The personal data server service (PDS) could not be detected."
msgstr "" msgstr ""
#: bluesky.php:355 #: bluesky.php:430
msgid "The authentication with the provided handle and password failed. Please check if you entered the correct password." msgid "The authentication with the provided handle and password failed. Please check if you entered the correct password."
msgstr "" msgstr ""
#: bluesky.php:425 #: bluesky.php:492
msgid "Post via the AT Protocol" msgid "Post to Bluesky"
msgstr "" msgstr ""

View file

@ -10,5 +10,4 @@
{{include file="field_input.tpl" field=$pds}} {{include file="field_input.tpl" field=$pds}}
{{include file="field_input.tpl" field=$handle}} {{include file="field_input.tpl" field=$handle}}
{{include file="field_input.tpl" field=$did}} {{include file="field_input.tpl" field=$did}}
{{include file="field_input.tpl" field=$password}} {{include file="field_input.tpl" field=$password}}
{{include file="field_select.tpl" field=$web}}

View file

@ -74,7 +74,6 @@ function irc_content()
{ {
$baseurl = DI::baseUrl() . '/addon/irc'; $baseurl = DI::baseUrl() . '/addon/irc';
$o = ''; $o = '';
$usernick = '';
/* set the list of popular channels */ /* set the list of popular channels */
if (DI::userSession()->getLocalUserId()) { if (DI::userSession()->getLocalUserId()) {
@ -82,7 +81,6 @@ function irc_content()
if (!$sitechats) { if (!$sitechats) {
$sitechats = DI::config()->get('irc', 'sitechats'); $sitechats = DI::config()->get('irc', 'sitechats');
} }
$usernick = "nick=" . DI::userSession()->getLocalUserNickname() . "&";
} else { } else {
$sitechats = DI::config()->get('irc','sitechats'); $sitechats = DI::config()->get('irc','sitechats');
} }
@ -119,7 +117,7 @@ function irc_content()
$o .= <<< EOT $o .= <<< EOT
<h2>IRC chat</h2> <h2>IRC chat</h2>
<p><a href="https://tldp.org/HOWTO/IRC/beginners.html" target="_blank" rel="noopener noreferrer">A beginner's guide to using IRC. [en]</a></p> <p><a href="https://tldp.org/HOWTO/IRC/beginners.html" target="_blank" rel="noopener noreferrer">A beginner's guide to using IRC. [en]</a></p>
<iframe src="//web.libera.chat?{$usernick}channels=$channels" style="width:100%; max-width:900px; height: 600px;"></iframe> <iframe src="//web.libera.chat?channels=$channels" style="width:100%; max-width:900px; height: 600px;"></iframe>
EOT; EOT;
return $o; return $o;

View file

@ -166,35 +166,7 @@ function mailstream_send_hook(array $data)
return; return;
} }
$author = DBA::selectFirst('contact', ['nick', 'blocked', 'uri-id'], ['id' => $data['author-id'], 'self' => false]); if (!mailstream_send($data['message_id'], $item, $user)) {
if (!DBA::isResult($author)) {
DI::logger()->error('could not find author', ['guid' => $item['guid'], 'author-id' => $data['author-id']]);
return;
}
if ($author['blocked']) {
DI::logger()->info('author is blocked', ['guid' => $item['guid'], 'author-id' => $data['author-id']]);
return;
}
$collapsed = false;
$user_contact = DBA::selectFirst('user-contact', ['cid', 'blocked', 'ignored', 'collapsed'], ['uid' => $item['uid'], 'uri-id' => $item['author-uri-id']]);
if (!DBA::isResult($user_contact)) {
$user_contact = DBA::selectFirst('user-contact', ['cid', 'blocked', 'ignored', 'collapsed'], ['uid' => $item['uid'], 'cid' => $item['author-id']]);
}
if (DBA::isResult($user_contact)) {
if ($user_contact['blocked']) {
DI::logger()->info('author is blocked', ['guid' => $item['guid'], 'cid' => $user_contact['cid']]);
return;
}
if ($user_contact['ignored']) {
DI::logger()->info('author is ignored', ['guid' => $item['guid'], 'cid' => $user_contact['cid']]);
return;
}
if ($user_contact['collapsed']) {
$collapsed = true;
}
}
if (!mailstream_send($data['message_id'], $item, $user, $collapsed)) {
DI::logger()->debug('send failed, will retry', $data); DI::logger()->debug('send failed, will retry', $data);
if (!Worker::defer()) { if (!Worker::defer()) {
DI::logger()->error('failed and could not defer', $data); DI::logger()->error('failed and could not defer', $data);
@ -248,7 +220,6 @@ function mailstream_post_hook(array &$item)
$send_hook_data = [ $send_hook_data = [
'uid' => $item['uid'], 'uid' => $item['uid'],
'contact-id' => $item['contact-id'], 'contact-id' => $item['contact-id'],
'author-id' => $item['author-id'],
'uri' => $item['uri'], 'uri' => $item['uri'],
'message_id' => $message_id, 'message_id' => $message_id,
'tries' => 0, 'tries' => 0,
@ -435,11 +406,10 @@ function mailstream_subject(array $item): string
* @param string $message_id ID of the message (RFC 1036) * @param string $message_id ID of the message (RFC 1036)
* @param array $item content of the item * @param array $item content of the item
* @param array $user results from the user table * @param array $user results from the user table
* @param bool $collapsed true if the content should be hidden
* *
* @return bool True if this message has been completed. False if it should be retried. * @return bool True if this message has been completed. False if it should be retried.
*/ */
function mailstream_send(string $message_id, array $item, array $user, bool $collapsed): bool function mailstream_send(string $message_id, array $item, array $user): bool
{ {
if (!is_array($item)) { if (!is_array($item)) {
DI::logger()->error('item is empty', ['message_id' => $message_id]); DI::logger()->error('item is empty', ['message_id' => $message_id]);
@ -457,16 +427,10 @@ function mailstream_send(string $message_id, array $item, array $user, bool $col
require_once(dirname(__file__) . '/phpmailer/class.phpmailer.php'); require_once(dirname(__file__) . '/phpmailer/class.phpmailer.php');
if ($collapsed) { $item['body'] = Post\Media::addAttachmentsToBody($item['uri-id'], $item['body']);
$item['body'] = DI::l10n()->t('Content from %s is collapsed', $item['author-name']);
} else {
$item['body'] = Post\Media::addAttachmentsToBody($item['uri-id'], $item['body']);
}
$attachments = []; $attachments = [];
if (!$collapsed) { mailstream_do_images($item, $attachments);
mailstream_do_images($item, $attachments);
}
$frommail = DI::config()->get('mailstream', 'frommail'); $frommail = DI::config()->get('mailstream', 'frommail');
if ($frommail == '') { if ($frommail == '') {
$frommail = 'friendica@localhost.local'; $frommail = 'friendica@localhost.local';

View file

@ -2,7 +2,7 @@
/** /**
* Name: Pnut Connector * Name: Pnut Connector
* Description: Post to pnut.io * Description: Post to pnut.io
* Version: 0.1.2 * Version: 0.2.0
* Author: Morgan McMillian <https://social.clacks.network/profile/spacenerdmo> * Author: Morgan McMillian <https://social.clacks.network/profile/spacenerdmo>
* Status: In Development * Status: In Development
*/ */
@ -11,14 +11,19 @@ require_once 'addon/pnut/lib/phpnut.php';
require_once 'addon/pnut/lib/phpnutException.php'; require_once 'addon/pnut/lib/phpnutException.php';
use Friendica\Content\Text\BBCode; use Friendica\Content\Text\BBCode;
use Friendica\Content\Text\HTML;
use Friendica\Content\Text\Plaintext; use Friendica\Content\Text\Plaintext;
use Friendica\Core\Config\Util\ConfigFileManager; use Friendica\Content\Text\Markdown;
use Friendica\Core\Hook; use Friendica\Core\Hook;
use Friendica\Core\Renderer; use Friendica\Core\Renderer;
use Friendica\Core\System; use Friendica\Core\System;
use Friendica\DI; use Friendica\DI;
use Friendica\Model\Contact;
use Friendica\Model\Circle;
use Friendica\Util\Strings;
use Friendica\Model\Item; use Friendica\Model\Item;
use Friendica\Model\Photo; use Friendica\Model\Photo;
use Friendica\Core\Protocol;
use phpnut\phpnutException; use phpnut\phpnutException;
const PNUT_LIMIT = 256; const PNUT_LIMIT = 256;
@ -32,6 +37,9 @@ function pnut_install()
Hook::register('jot_networks', __FILE__, 'pnut_jot_nets'); Hook::register('jot_networks', __FILE__, 'pnut_jot_nets');
Hook::register('connector_settings', __FILE__, 'pnut_settings'); Hook::register('connector_settings', __FILE__, 'pnut_settings');
Hook::register('connector_settings_post', __FILE__, 'pnut_settings_post'); Hook::register('connector_settings_post', __FILE__, 'pnut_settings_post');
Hook::register('cron', __FILE__, 'pnut_cron');
Hook::register('prepare_body', __FILE__, 'pnut_prepare_body');
Hook::register('check_item_notification', __FILE__, 'pnut_check_item_notification');
} }
function pnut_module() {} function pnut_module() {}
@ -78,6 +86,10 @@ function pnut_connect()
DI::logger()->debug('Got Token', [$token]); DI::logger()->debug('Got Token', [$token]);
$o = DI::l10n()->t('You are now authenticated with pnut.io.'); $o = DI::l10n()->t('You are now authenticated with pnut.io.');
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'access_token', $token); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'access_token', $token);
// Get user info and set ownid
$userdata = $nut->getUser();
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'ownid', $userdata['id']);
} catch (phpnutException $e) { } catch (phpnutException $e) {
$o = DI::l10n()->t('Error fetching token. Please try again.', ['code' => $e->getCode(), 'message' => $e->getMessage()]); $o = DI::l10n()->t('Error fetching token. Please try again.', ['code' => $e->getCode(), 'message' => $e->getMessage()]);
} }
@ -87,9 +99,15 @@ function pnut_connect()
return $o; return $o;
} }
function pnut_load_config(ConfigFileManager $loader) function pnut_check_item_notification($a, &$notification_data)
{ {
DI::appHelper()->getConfigCache()->load($loader->loadAddonConfig('pnut'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC); $own_id = DI::pConfig()->get($notification_data["uid"], 'pnut', 'ownid');
$own_user = DI::dba()->p("SELECT `url` FROM `contact` WHERE `uid` = ? AND `alias` = ?", $notification_data["uid"], "pnut::" . $own_id);
if ($own_user) {
$notification_data["profiles"][] = $own_user[0]["url"];
}
} }
function pnut_addon_admin(string &$o) function pnut_addon_admin(string &$o)
@ -123,9 +141,11 @@ function pnut_settings(array &$data)
$enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'post') ?? false; $enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'post') ?? false;
$def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default') ?? false; $def_enabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default') ?? false;
$importenabled = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'import') ?? false;
$client_id = DI::config()->get('pnut', 'client_id'); $client_id = DI::config()->get('pnut', 'client_id');
$client_secret = DI::config()->get('pnut', 'client_secret'); $client_secret = DI::config()->get('pnut', 'client_secret');
$token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'access_token'); $token = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'access_token');
$ownid = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'ownid');
$user_client = empty($client_id) || empty($client_secret); $user_client = empty($client_id) || empty($client_secret);
if ($user_client) { if ($user_client) {
@ -133,6 +153,8 @@ function pnut_settings(array &$data)
$client_secret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'client_secret'); $client_secret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'pnut', 'client_secret');
} }
$o = '';
if (!empty($client_id) && !empty($client_secret) && empty($token)) { if (!empty($client_id) && !empty($client_secret) && empty($token)) {
$nut = new phpnut\phpnut($client_id, $client_secret); $nut = new phpnut\phpnut($client_id, $client_secret);
$authorize_url = $nut->getAuthUrl($redirectUri, $scope); $authorize_url = $nut->getAuthUrl($redirectUri, $scope);
@ -140,14 +162,22 @@ function pnut_settings(array &$data)
} }
if (!empty($token)) { if (!empty($token)) {
$disconn_btn = DI::l10n()->t('Disconnect'); $nut = new phpnut\phpnut($token);
try {
$userdata = $nut->getUser();
if ($ownid != $userdata["id"]) {
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'ownid', $userdata["id"]);
}
$o .= '<div id="pnut-info" ><img id="pnut-avatar" src="' . $userdata["avatar_image"]["url"] . '" /><p id="pnut-info-block">' . DI::l10n()->t('Currently connected to: ') . '<a href="' . $userdata["canonical_url"] . '" target="_pnut">' . $userdata["username"] . '</a><br /><em>' . $userdata["description"]["text"] . '</em></p></div>';
} catch (phpnutException $e) {
$o .= DI::l10n()->t("<p>Error fetching user profile. Please clear the configuration and try again.</p>");
}
} }
$t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/pnut/'); $t = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/pnut/');
$html = Renderer::replaceMacros($t, [ $html = Renderer::replaceMacros($t, [
'$enable' => ['pnut', DI::l10n()->t('Enable Pnut Post Addon'), $enabled], '$enable' => ['pnut', DI::l10n()->t('Enable Pnut Post Addon'), $enabled],
'$bydefault' => ['pnut_bydefault', DI::l10n()->t('Post to Pnut by default'), $def_enabled], '$bydefault' => ['pnut_bydefault', DI::l10n()->t('Post to Pnut by default'), $def_enabled], '$import' => ['pnut_import', DI::l10n()->t('Import the remote timeline'), $importenabled], '$client_id' => ['pnut_client_id', DI::l10n()->t('Client ID'), $client_id],
'$client_id' => ['pnut_client_id', DI::l10n()->t('Client ID'), $client_id],
'$client_secret' => ['pnut_client_secret', DI::l10n()->t('Client Secret'), $client_secret], '$client_secret' => ['pnut_client_secret', DI::l10n()->t('Client Secret'), $client_secret],
'$access_token' => ['pnut_access_token', DI::l10n()->t('Access Token'), $token, '', '', 'readonly'], '$access_token' => ['pnut_access_token', DI::l10n()->t('Access Token'), $token, '', '', 'readonly'],
'$authorize_url' => $authorize_url ?? '', '$authorize_url' => $authorize_url ?? '',
@ -174,12 +204,14 @@ function pnut_settings_post(array &$b)
if (!empty($_POST['pnut-disconnect'])) { if (!empty($_POST['pnut-disconnect'])) {
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'post'); DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'post');
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default'); DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default');
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'import');
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'client_id'); DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'client_id');
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'client_secret'); DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'client_secret');
DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'access_token'); DI::pConfig()->delete(DI::userSession()->getLocalUserId(), 'pnut', 'access_token');
} else { } else {
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'post', intval($_POST['pnut'])); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'post', intval($_POST['pnut']));
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default', intval($_POST['pnut_bydefault'])); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'post_by_default', intval($_POST['pnut_bydefault']));
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'import', intval($_POST['pnut_import']));
if (!empty($_POST['pnut_client_id'])) { if (!empty($_POST['pnut_client_id'])) {
DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'client_id', $_POST['pnut_client_id']); DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'pnut', 'client_id', $_POST['pnut_client_id']);
} }
@ -275,6 +307,59 @@ function pnut_post_hook(array &$b)
$token = DI::pConfig()->get($b['uid'], 'pnut', 'access_token'); $token = DI::pConfig()->get($b['uid'], 'pnut', 'access_token');
$nut = new phpnut\phpnut($token); $nut = new phpnut\phpnut($token);
$iscomment = false;
$islike = false;
if ($b['parent'] != $b['id']) {
// Looking if its a reply to a pnut post
if ((substr($b["parent-uri"], 0, 6) != "pnut::") && (substr($b["extid"], 0, 6) != "pnut::") && (substr($b["thr-parent"], 0, 6) != "pnut::")) {
DI::logger()->debug("PNUT post: no pnut post " . $b["parent"]);
return;
}
$r = DI::dba()->p("SELECT * FROM item WHERE item.uri = ? AND item.uid = ? LIMIT 1", $b["thr-parent"], intval($b["uid"]));
if (!count($r)) {
DI::logger()->debug("PNUT post: no parent found " . $b["thr-parent"]);
return;
} else {
$iscomment = true;
$orig_post = $r[0];
}
$nicknameplain = preg_replace("=https?://pnut.io/(.*)=ism", "$1", $orig_post["author-link"]);
$nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
$nicknameplain = "@" . $nicknameplain;
DI::logger()->debug('PNUT post: comparing ' . $nickname . ' and ' . $nicknameplain . ' with ' . $b["body"]);
if ((strpos($b["body"], $nickname) === false) && (strpos($b["body"], $nicknameplain) === false)) {
$b["body"] = $nickname . " " . $b["body"];
}
DI::logger()->debug('PNUT post: parent found ' . print_r($orig_post, true));
} elseif ($b['verb'] == 'http://activitystrea.ms/schema/1.0/like') {
DI::logger()->debug('PNUT post: like detected');
$islike = true;
}
if (($b['verb'] == 'http://activitystrea.ms/schema/1.0/post') && $b['deleted']) {
pnut_action($b["uid"], substr($orig_post["uri"], 6), "delete");
return;
}
if ($b['verb'] == 'http://activitystrea.ms/schema/1.0/like') {
if ($b['deleted']) {
pnut_action($b["uid"], substr($b["thr-parent"], 6), "unlike");
} else {
pnut_action($b["uid"], substr($b["thr-parent"], 6), "like");
}
return;
}
if ($b['deleted'] || ($b['created'] !== $b['edited'])) {
return;
}
$msgarr = Plaintext::getPost($b, PNUT_LIMIT, true, BBCode::EXTERNAL); $msgarr = Plaintext::getPost($b, PNUT_LIMIT, true, BBCode::EXTERNAL);
$text = $msgarr['text']; $text = $msgarr['text'];
$raw = []; $raw = [];
@ -319,7 +404,647 @@ function pnut_post_hook(array &$b)
} }
$raw['io.pnut.core.crosspost'][] = ['canonical_url' => $b['plink']]; $raw['io.pnut.core.crosspost'][] = ['canonical_url' => $b['plink']];
$nut->createPost($text, ['raw' => $raw]);
$data = ['raw' => $raw];
if ($iscomment) {
$data['reply_to'] = substr($orig_post["uri"], 6);
}
$nut->createPost($text, $data);
DI::logger()->debug('PNUT post complete', ['id' => $b['id'], 'text' => $text, 'raw' => $raw]); DI::logger()->debug('PNUT post complete', ['id' => $b['id'], 'text' => $text, 'raw' => $raw]);
} }
function pnut_cron($a, $b)
{
$last = DI::config()->get('pnut', 'last_poll');
$poll_interval = intval(DI::config()->get('pnut', 'poll_interval'));
if (!$poll_interval) {
$poll_interval = 5; // default 5 minutes
}
if ($last) {
$next = $last + ($poll_interval * 60);
if ($next > time()) {
DI::logger()->debug('PNUT cron: poll interval not reached');
return;
}
}
DI::logger()->debug('PNUT cron: cron_start');
$abandon_days = intval(DI::config()->get('system', 'account_abandon_days', 0));
if ($abandon_days < 1) {
$abandon_days = 0;
}
$abandon_limit = date("Y-m-d H:i:s", time() - $abandon_days * 86400);
$r = DI::dba()->p("SELECT * FROM `pconfig` WHERE `cat` = 'pnut' AND `k` = 'import' AND `v` = '1' ORDER BY RAND()");
if (count($r)) {
foreach ($r as $rr) {
if ($abandon_days != 0) {
$user = DI::dba()->p("SELECT `login_date` FROM `user` WHERE uid = ? AND `login_date` >= ?", $rr['uid'], $abandon_limit);
if (!count($user)) {
DI::logger()->debug('abandoned account: timeline from user ' . $rr['uid'] . ' will not be imported');
continue;
}
}
DI::logger()->debug('PNUT cron: importing timeline from user ' . $rr['uid']);
pnut_fetchstream($rr["uid"]);
}
}
DI::logger()->debug('PNUT cron: cron_end');
DI::config()->set('pnut', 'last_poll', time());
}
function pnut_fetchstream($uid)
{
$token = DI::pConfig()->get($uid, 'pnut', 'access_token');
$nut = new phpnut\phpnut($token);
$r = DI::dba()->p("SELECT * FROM `contact` WHERE `self` = 1 AND `uid` = ? LIMIT 1", intval($uid));
if (count($r)) {
$me = $r[0];
} else {
DI::logger()->debug("PNUT fetchstream: Own contact not found for user " . $uid);
return;
}
$user = DI::dba()->p("SELECT * FROM `user` WHERE `uid` = ? AND `account_expired` = 0 LIMIT 1", intval($uid));
if (count($user)) {
$user = $user[0];
} else {
DI::logger()->debug("PNUT fetchstream: Own user not found for user " . $uid);
return;
}
$ownid = DI::pConfig()->get($uid, 'pnut', 'ownid');
// Fetch stream
$param = array("count" => 200, "include_deleted" => false, "include_directed_posts" => true,
"include_html" => false, "include_post_annotations" => true);
$lastid = DI::pConfig()->get($uid, 'pnut', 'laststreamid');
if ($lastid <> "") {
$param["since_id"] = $lastid;
}
try {
$stream = $nut->getUserStream($param);
} catch (phpnutException $e) {
DI::logger()->debug("PNUT fetchstream: Error fetching stream for user " . $uid . " " . $e->getMessage());
return;
}
if (!is_array($stream)) {
$stream = array();
}
$stream = array_reverse($stream);
foreach ($stream as $post) {
$postarray = pnut_createpost($uid, $post, $me, $user, $ownid, true);
$item = Item::insert($postarray);
$postarray["id"] = $item;
DI::logger()->debug('PNUT fetchstream: User ' . $uid . ' posted stream item ' . $item);
$lastid = $post["id"];
}
DI::pConfig()->set($uid, 'pnut', 'laststreamid', $lastid);
// Fetch mentions
$param = array("count" => 200, "include_deleted" => false, "include_directed_posts" => true,
"include_html" => false, "include_post_annotations" => true);
$lastid = DI::pConfig()->get($uid, 'pnut', 'lastmentionid');
if ($lastid <> "") {
$param["since_id"] = $lastid;
}
try {
$mentions = $nut->getUserMentions("me", $param);
} catch (phpnutException $e) {
DI::logger()->debug("PNUT fetchstream: Error fetching mentions for user " . $uid . " " . $e->getMessage());
return;
}
if (!is_array($mentions)) {
$mentions = array();
}
$mentions = array_reverse($mentions);
foreach ($mentions as $post) {
$postarray = pnut_createpost($uid, $post, $me, $user, $ownid, false);
if (isset($postarray["id"])) {
$item = $postarray["id"];
$parent_id = $postarray['parent'];
} elseif (isset($postarray["body"])) {
$item = Item::insert($postarray);
$postarray["id"] = $item;
$parent_id = 0;
DI::logger()->debug('PNUT fetchstream: User ' . $uid . ' posted mention item ' . $item);
} else {
$item = 0;
$parent_id = 0;
}
// Fetch the parent and id
if (($parent_id == 0) && ($postarray['uri'] != "")) {
$r = DI::dba()->p("SELECT `id`, `parent` FROM `item` WHERE `uri` = ? AND `uid` = ? LIMIT 1", $postarray['uri'], intval($uid));
if (count($r)) {
$item = $r[0]['id'];
$parent_id = $r[0]['parent'];
}
}
$lastid = $post["id"];
}
DI::pConfig()->set($uid, 'pnut', 'lastmentionid', $lastid);
}
function pnut_createpost($uid, $post, $me, $user, $ownid, $createuser, $threadcompletion = true, $nodupcheck = false)
{
if ($post["machine_only"]) {
return;
}
if ($post["is_deleted"]) {
return;
}
$postarray = array();
$postarray['gravity'] = 0;
$postarray['uid'] = $uid;
$postarray['wall'] = 0;
$postarray['verb'] = 'http://activitystrea.ms/schema/1.0/post';
$postarray['network'] = Protocol::PNUT; // Assuming Protocol::PNUT is defined, else use a string
if (is_array($post["repost_of"])) {
// You can't reply to reposts. So use the original id and thread-id
$postarray['uri'] = "pnut::" . $post["repost_of"]["id"];
$postarray['parent-uri'] = "pnut::" . $post["repost_of"]["thread_id"];
} else {
$postarray['uri'] = "pnut::" . $post["id"];
$postarray['parent-uri'] = "pnut::" . $post["thread_id"];
}
if (!$nodupcheck) {
$r = DI::dba()->p("SELECT * FROM `item` WHERE `uri` = ? AND `uid` = ? LIMIT 1", $postarray['uri'], intval($uid));
if (count($r)) {
return $r[0];
}
$r = DI::dba()->p("SELECT * FROM `item` WHERE `extid` = ? AND `uid` = ? LIMIT 1", $postarray['uri'], intval($uid));
if (count($r)) {
return $r[0];
}
}
if (isset($post["reply_to"]) && ($post["reply_to"] != "")) {
$postarray['thr-parent'] = "pnut::" . $post["reply_to"];
// Complete the thread (if the parent doesn't exists)
if ($threadcompletion) {
DI::logger()->debug("PNUT createpost: completing thread " . $post["thread_id"] . " for user " . $uid);
$token = DI::pConfig()->get($uid, 'pnut', 'access_token');
$nut = new phpnut\phpnut($token);
$param = array("count" => 200, "include_deleted" => false, "include_directed_posts" => true,
"include_html" => false, "include_post_annotations" => true);
try {
$thread = [];
} catch (phpnutException $e) {
DI::logger()->debug("PNUT createpost: Error fetching thread for user " . $uid . " " . $e->getMessage());
}
$thread = array_reverse($thread);
DI::logger()->debug("PNUT createpost: fetched " . count($thread) . " items for thread " . $post["thread_id"] . " for user " . $uid);
foreach ($thread as $tpost) {
$threadpost = pnut_createpost($uid, $tpost, $me, $user, $ownid, false, false);
$item = Item::insert($threadpost);
$threadpost["id"] = $item;
DI::logger()->debug("PNUT createpost: stored post " . $post["id"] . " thread " . $post["thread_id"] . " in item " . $item);
}
}
// Don't create accounts of people who just comment something
$createuser = false;
$postarray['object-type'] = 'http://activitystrea.ms/schema/1.0/comment';
} else {
$postarray['thr-parent'] = $postarray['uri'];
$postarray['object-type'] = 'http://activitystrea.ms/schema/1.0/note';
}
if (($post["user"]["id"] != $ownid) || ($postarray['thr-parent'] == $postarray['uri'])) {
$postarray['owner-name'] = $post["user"]["name"];
$postarray['owner-link'] = $post["user"]["canonical_url"];
$postarray['owner-avatar'] = $post["user"]["avatar_image"]["url"];
$postarray['contact-id'] = pnut_fetchcontact($uid, $post["user"], $me, $createuser);
} else {
$postarray['owner-name'] = $me["name"];
$postarray['owner-link'] = $me["url"];
$postarray['owner-avatar'] = $me["thumb"];
$postarray['contact-id'] = $me["id"];
}
if (is_array($post["repost_of"])) {
$postarray['author-name'] = $post["repost_of"]["user"]["name"];
$postarray['author-link'] = $post["repost_of"]["user"]["canonical_url"];
$postarray['author-avatar'] = $post["repost_of"]["user"]["avatar_image"]["url"];
$content = $post["repost_of"];
} else {
$postarray['author-name'] = $postarray['owner-name'];
$postarray['author-link'] = $postarray['owner-link'];
$postarray['author-avatar'] = $postarray['owner-avatar'];
$content = $post;
}
$postarray['plink'] = $content["canonical_url"];
if (is_array($content["entities"])) {
$converted = pnut_expand_entities($content["text"], $content["entities"]);
$postarray['body'] = $converted["body"];
$postarray['tag'] = $converted["tags"];
} else {
$postarray['body'] = $content["text"];
}
if (sizeof($content["entities"]["links"])) {
foreach ($content["entities"]["links"] as $link) {
$url = Strings::normaliseLink($link["url"]);
$links[$url] = $link["url"];
}
}
$page_info = "";
if (is_array($content["annotations"])) {
$photo = pnut_expand_annotations($content["annotations"]);
if (($photo["large"] != "") && ($photo["url"] != "")) {
$page_info = "\n[url=" . $photo["url"] . "][img]" . $photo["large"] . "[/img][/url]";
} elseif ($photo["url"] != "") {
$page_info = "\n[img]" . $photo["url"] . "[/img]";
}
if ($photo["url"] != "") {
$postarray['object-type'] = 'http://activitystrea.ms/schema/1.0/image';
}
} else {
$photo = array("url" => "", "large" => "");
}
if (sizeof($links)) {
$link = array_pop($links);
$url = str_replace(array('/', '.'), array('\/', '\.'), $link);
$page_info = "";
if (trim($page_info) != "") {
$removedlink = preg_replace("/\[url\=" . $url . "\](.*?)\[\/url\]/ism", '', $postarray['body']);
if (($removedlink == "") || strstr($postarray['body'], $removedlink)) {
$postarray['body'] = $removedlink;
}
}
}
$postarray['body'] .= $page_info;
$postarray['created'] = (new DateTime($post["created_at"]))->format(DateTime::ATOM);
$postarray['edited'] = (new DateTime($post["created_at"]))->format(DateTime::ATOM);
$postarray['app'] = $post["source"]["name"];
return $postarray;
}
function pnut_expand_entities($body, $entities)
{
if (!function_exists('substr_unicode')) {
function substr_unicode($str, $s, $l = null) {
return join("", array_slice(
preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY), $s, $l));
}
}
$tags_arr = array();
$replace = array();
foreach ($entities["mentions"] as $mention) {
$url = "@[url=https://pnut.io/" . rawurlencode($mention["name"]) . "]" . $mention["name"] . "[/url]";
$tags_arr["@" . $mention["name"]] = $url;
$replace[$mention["pos"]] = array("pos" => $mention["pos"], "len" => $mention["len"], "replace" => $url);
}
foreach ($entities["hashtags"] as $hashtag) {
$url = "#[url=" . DI::baseUrl() . "/search?tag=" . rawurlencode($hashtag["name"]) . "]" . $hashtag["name"] . "[/url]";
$tags_arr["#" . $hashtag["name"]] = $url;
$replace[$hashtag["pos"]] = array("pos" => $hashtag["pos"], "len" => $hashtag["len"], "replace" => $url);
}
foreach ($entities["links"] as $links) {
$url = "[url=" . $links["url"] . "]" . $links["text"] . "[/url]";
if (isset($links["amended_len"]) && ($links["amended_len"] > $links["len"])) {
$replace[$links["pos"]] = array("pos" => $links["pos"], "len" => $links["amended_len"], "replace" => $url);
} else {
$replace[$links["pos"]] = array("pos" => $links["pos"], "len" => $links["len"], "replace" => $url);
}
}
if (sizeof($replace)) {
krsort($replace);
foreach ($replace as $entity) {
$pre = substr_unicode($body, 0, $entity["pos"]);
$post = substr_unicode($body, $entity["pos"] + $entity["len"]);
$body = $pre . $entity["replace"] . $post;
}
}
return array("body" => $body, "tags" => implode(",", $tags_arr));
}
function pnut_expand_annotations($annotations)
{
$photo = array("url" => "", "large" => "");
foreach ($annotations as $annotation) {
if (($annotation["type"] == "io.pnut.core.oembed") &&
($annotation["value"]["type"] == "photo")) {
if ($annotation["value"]["url"] != "") {
$photo["url"] = $annotation["value"]["url"];
}
if ($annotation["value"]["thumbnail_large_url"] != "") {
$photo["large"] = $annotation["value"]["thumbnail_large_url"];
}
}
}
return $photo;
}
function pnut_action($uid, $post_id, $action)
{
// TODO: Implement pnut action API call
DI::logger()->info('PNUT action', ['uid' => $uid, 'post_id' => $post_id, 'action' => $action]);
}
function pnut_fetchcontact($uid, $contact, $me, $create_user)
{
$r = DI::dba()->p("SELECT * FROM `contact` WHERE `uid` = ? AND `alias` = ? LIMIT 1", intval($uid), "pnut::" . $contact["id"]);
if (!count($r) && !$create_user) {
return $me["id"];
}
if ($contact["canonical_url"] == "") {
return $me["id"];
}
if (count($r) && ($r[0]["readonly"] || $r[0]["blocked"])) {
DI::logger()->debug("PNUT fetchcontact: Contact '" . $r[0]["nick"] . "' is blocked or readonly.");
return -1;
}
if (!count($r)) {
if ($contact["name"] == "") {
$contact["name"] = $contact["username"];
}
if ($contact["username"] == "") {
$contact["username"] = $contact["name"];
}
// create contact record
DI::dba()->p("INSERT INTO `contact` (`uid`, `created`, `url`, `nurl`, `addr`, `alias`, `notify`, `poll`,
`name`, `nick`, `photo`, `network`, `rel`, `priority`,
`about`, `writable`, `blocked`, `readonly`, `pending` )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0 )",
intval($uid),
new DateTime('now', new DateTimeZone('UTC')),
$contact["canonical_url"],
Strings::normaliseLink($contact["canonical_url"]),
$contact["username"] . "@pnut.io",
"pnut::" . $contact["id"],
'',
"pnut::" . $contact["id"],
$contact["name"],
$contact["username"],
$contact["avatar_image"]["url"],
Protocol::PNUT,
intval(Contact::SHARING),
intval(1),
$contact["description"]["text"],
intval(1)
);
$r = DI::dba()->p("SELECT * FROM `contact` WHERE `alias` = ? AND `uid` = ? LIMIT 1",
"pnut::" . $contact["id"], intval($uid));
if (!count($r)) {
return false;
}
$contact_id = $r[0]['id'];
$g = DI::dba()->p("SELECT def_gid FROM user WHERE uid = ? LIMIT 1", intval($uid));
if ($g && intval($g[0]['def_gid'])) {
require_once('include/group.php');
Circle::addMember($uid, '', $contact_id, $g[0]['def_gid']);
}
$photos = Photo::importProfilePhoto($contact["avatar_image"]["url"], $uid, $contact_id);
DI::dba()->p("UPDATE `contact` SET `photo` = ?, `thumb` = ?, `micro` = ?,
`name-date` = ?, `uri-date` = ?, `avatar-date` = ? WHERE `id` = ?",
$photos[0],
$photos[1],
$photos[2],
new DateTime('now', new DateTimeZone('UTC')),
new DateTime('now', new DateTimeZone('UTC')),
new DateTime('now', new DateTimeZone('UTC')),
intval($contact_id)
);
} else {
// update profile photos once every two weeks as we have no notification of when they change.
$update_photo = ($r[0]['avatar-date'] < (new DateTime('now', new DateTimeZone('UTC')))->modify('-12 hours'));
// check that we have all the photos, this has been known to fail on occasion
if ((!$r[0]['photo']) || (!$r[0]['thumb']) || (!$r[0]['micro']) || ($update_photo)) {
DI::logger()->debug("PNUT fetchcontact: Updating contact " . $contact["username"]);
$photos = Photo::importProfilePhoto($contact["avatar_image"]["url"], $uid, $r[0]['id']);
DI::dba()->p("UPDATE `contact` SET `photo` = ?, `thumb` = ?, `micro` = ?,
`name-date` = ?, `uri-date` = ?, `avatar-date` = ?,
`url` = ?, `nurl` = ?, `addr` = ?, `name` = ?, `nick` = ?, `about` = ? WHERE `id` = ?",
$photos[0],
$photos[1],
$photos[2],
new DateTime('now', new DateTimeZone('UTC')),
new DateTime('now', new DateTimeZone('UTC')),
new DateTime('now', new DateTimeZone('UTC')),
$contact["canonical_url"],
Strings::normaliseLink($contact["canonical_url"]),
$contact["username"] . "@pnut.io",
$contact["name"],
$contact["username"],
$contact["description"]["text"],
intval($r[0]['id'])
);
}
}
return $r[0]["id"];
}
function pnut_prepare_body($a, &$b)
{
if ($b["item"]["network"] != Protocol::PNUT) {
return;
}
if ($b["preview"]) {
$max_char = 256;
require_once("include/plaintext.php");
$item = $b["item"];
$item["plink"] = DI::baseUrl() . "/display/" . $a->user["nickname"] . "/" . $item["parent"];
$r = DI::dba()->p("SELECT `author-link` FROM item WHERE item.uri = ? AND item.uid = ? LIMIT 1",
$item["thr-parent"], intval(DI::userSession()->getLocalUserId()));
if (count($r)) {
$orig_post = $r[0];
$nicknameplain = preg_replace("=https?://pnut.io/(.*)=ism", "$1", $orig_post["author-link"]);
$nickname = "@[url=" . $orig_post["author-link"] . "]" . $nicknameplain . "[/url]";
$nicknameplain = "@" . $nicknameplain;
if ((strpos($item["body"], $nickname) === false) && (strpos($item["body"], $nicknameplain) === false)) {
$item["body"] = $nickname . " " . $item["body"];
}
}
$msgarr = Plaintext::getPost($a, $item, $max_char, true);
$msg = pnut_create_entities($a, $item, $msgarr);
$msg = Markdown::convert($msg);
$b['html'] = $msg;
}
}
function pnut_create_entities($a, $b, $postdata)
{
require_once("include/bbcode.php");
require_once("include/plaintext.php");
$bbcode = $b["body"];
$bbcode = BBCode::removeShareInformation($bbcode, false, true);
// Change pure links in text to bbcode uris
$bbcode = preg_replace("/([^\]\='".'"'."]|^)(https?\:\/\/[a-zA-Z0-9\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,]+)/ism", '$1[url=$2]$2[/url]', $bbcode);
$URLSearchString = "^\[\]";
$bbcode = preg_replace("/#\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",'#$2',$bbcode);
$bbcode = preg_replace("/@\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",'@$2',$bbcode);
$bbcode = preg_replace("/\[bookmark\=([$URLSearchString]*)\](.*?)\[\/bookmark\]/ism",'[url=$1]$2[/url]',$bbcode);
$bbcode = preg_replace("/\[video\](.*?)\[\/video\]/ism",'[url=$1]$1[/url]',$bbcode);
$bbcode = preg_replace("/\[youtube\]https?:\/\/(.*?)\[\/youtube\]/ism",'[url=https://$1]https://$1[/url]',$bbcode);
$bbcode = preg_replace("/\[youtube\]([A-Za-z0-9\-_=]+)(.*?)\[\/youtube\]/ism",
'[url=https://www.youtube.com/watch?v=$1]https://www.youtube.com/watch?v=$1[/url]', $bbcode);
$bbcode = preg_replace("/\[vimeo\]https?:\/\/(.*?)\[\/vimeo\]/ism",'[url=https://$1]https://$1[/url]',$bbcode);
$bbcode = preg_replace("/\[vimeo\]([0-9]+)(.*?)\[\/vimeo\]/ism",
'[url=https://vimeo.com/$1]https://vimeo.com/$1[/url]', $bbcode);
$bbcode = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $bbcode);
preg_match_all("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism", $bbcode, $urls, PREG_SET_ORDER);
$bbcode = preg_replace("/\[url\=([$URLSearchString]*)\](.*?)\[\/url\]/ism",'$1',$bbcode);
$b["body"] = $bbcode;
$plaintext = Plaintext::getPost($a, $b, 0, false, 6);
$text = $plaintext["text"];
$start = 0;
$entities = array();
foreach ($urls as $url) {
$lenurl = iconv_strlen($url[1], "UTF-8");
$len = iconv_strlen($url[2], "UTF-8");
$pos = iconv_strpos($text, $url[1], $start, "UTF-8");
$pre = iconv_substr($text, 0, $pos, "UTF-8");
$post = iconv_substr($text, $pos + $lenurl, 1000000, "UTF-8");
$mid = $url[2];
$html = BBCode::convert($mid, false, false, 6);
$mid = HTML::toPlaintext($html, 0, true);
$mid = trim(html_entity_decode($mid, ENT_QUOTES, 'UTF-8'));
$text = $pre . $mid . $post;
if ($mid != "") {
$entities[] = array("pos" => $pos, "len" => $len, "url" => $url[1], "text" => $mid);
}
$start = $pos + 1;
}
if (isset($postdata["url"]) && isset($postdata["title"]) && ($postdata["type"] != "photo")) {
$postdata["title"] = Plaintext::shorten($postdata["title"], 90);
$max = 256 - strlen($postdata["title"]);
$text = Plaintext::shorten($text, $max);
$text .= "\n[" . $postdata["title"] . "](" . $postdata["url"] . ")";
} elseif (isset($postdata["url"]) && ($postdata["type"] != "photo")) {
$postdata["url"] = Strings::normaliseLink($postdata["url"]);
$max = 240;
$text = Plaintext::shorten($text, $max);
$text .= " [" . $postdata["url"] . "](" . $postdata["url"] . ")";
} else {
$max = 256;
$text = Plaintext::shorten($text, $max);
}
if (iconv_strlen($text, "UTF-8") < $max) {
$max = iconv_strlen($text, "UTF-8");
}
krsort($entities);
foreach ($entities as $entity) {
if (($entity["pos"] + $entity["len"]) <= $max) {
$pre = iconv_substr($text, 0, $entity["pos"], "UTF-8");
$post = iconv_substr($text, $entity["pos"] + $entity["len"], 1000000, "UTF-8");
$text = $pre . "[" . $entity["text"] . "](" . $entity["url"] . ")" . $post;
}
}
return $text;
}

View file

@ -1,6 +1,7 @@
<p>{{$status}}</p> <p>{{$status}}</p>
{{include file="field_checkbox.tpl" field=$enable}} {{include file="field_checkbox.tpl" field=$enable}}
{{include file="field_checkbox.tpl" field=$bydefault}} {{include file="field_checkbox.tpl" field=$bydefault}}
{{include file="field_checkbox.tpl" field=$import}}
{{if $user_client}} {{if $user_client}}
{{include file="field_input.tpl" field=$client_id}} {{include file="field_input.tpl" field=$client_id}}
{{include file="field_input.tpl" field=$client_secret}} {{include file="field_input.tpl" field=$client_secret}}

View file

@ -1,45 +0,0 @@
# QuickPhoto Addon for Friendica
QuickPhoto is a Friendica addon that simplifies working with images in the editor. It automatically replaces long, cumbersome BBCode structures with a compact shorthand notation, without affecting functionality or compatibility.
---
## Features
- **Automatic Simplification:** Converts "monster BBCodes" like `[url=...][img=...]...[/img][/url]` instantly into the handy format `[img]|filename description[/img]`.
- **Intelligent Reconstruction:** Before submitting or previewing, the shorthand code is quickly converted back into the original, valid Friendica BBCode.
- **Real-Time Processing:** Responds immediately to drag & drop, copy & paste, and inserting images via editor buttons.
- **Focus Safety:** Cursor management ensures the focus remains stable during automatic conversion while typing.
- **Maximum Compatibility:** Supports both the standard Jot editor and the Compose module, as well as reply fields.
- **Local Cache:** Image data is securely stored in the browser's localStorage and automatically cleared after 12 hours.
---
## How It Works
The addon operates in a hybrid manner:
- **Frontend:** A JavaScript watcher scans textareas and simplifies complex image links for better readability while writing.
- **Interface:** It integrates deeply with Friendica's jQuery functions to ensure that preview and save functions always receive the correct original data.
- **Events:** By intercepting submit and preview clicks, it guarantees that shorthand codes are never sent to the server in a format it cannot interpret.
---
## Installation
1. Create a folder named `quickphoto` in the `addon/` directory of your Friendica installation.
2. Place the file `quickphoto.php` in this folder.
3. Place the file `quickphoto.js` in the same folder.
4. Enable the addon in the Friendica administration area under **Addons**.
---
MIT License
Copyright (c) 2024-2026 Friendica Project & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,22 +0,0 @@
# ADDON quickphoto
# Copyright (C)
# This file is distributed under the same license as the Friendica quickphoto addon package.
#
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-07 10:18+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: quickphoto.php:18
msgid "Image description"
msgstr ""

View file

@ -1,126 +0,0 @@
(function() {
const monsterPattern = /\[url=(.*?)\]\[img=(.*?)\](.*?)\[\/img\]\[\/url\]/gi;
let throttleTimer;
const i18nDesc = (window.qp_i18n && window.qp_i18n.imageDesc) ? window.qp_i18n.imageDesc : "Image description";
const cleanupOldEntries = () => {
const now = Date.now();
const twelveHours = 12 * 60 * 60 * 1000;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('qp_')) {
try {
const data = JSON.parse(localStorage.getItem(key));
if (data && data.timestamp && (now - data.timestamp > twelveHours)) {
localStorage.removeItem(key);
}
} catch (e) { localStorage.removeItem(key); }
}
}
};
const simplify = (text) => {
if (!text || !text.includes('[url=')) return text;
return text.replace(monsterPattern, (match, urlPart, imgPart, existingDesc) => {
const fileName = imgPart.split('/').pop();
const storageKey = `qp_${fileName}`;
localStorage.setItem(storageKey, JSON.stringify({
url: urlPart,
img: imgPart,
timestamp: Date.now()
}));
let userDesc = existingDesc.trim() || i18nDesc;
return `[img]${fileName}|${userDesc}[/img]`;
});
};
const reconstruct = (text) => {
if (!text || !text.includes('[img]')) return text;
return text.replace(/\[img\](.*?)\|(.*?)\[\/img\]/g, (match, fileName, desc) => {
const data = localStorage.getItem(`qp_${fileName}`);
if (data) {
const parsed = JSON.parse(data);
const finalDesc = (desc === i18nDesc) ? "" : desc;
return `[url=${parsed.url}][img=${parsed.img}]${finalDesc}[/img][/url]`;
}
return match;
});
};
const applySimplify = (textarea) => {
if (!textarea || !textarea.value || !textarea.value.includes('[/img]')) return;
(window.requestIdleCallback || function(cb) { return setTimeout(cb, 1); })(() => {
const current = textarea.value;
const simple = simplify(current);
if (current !== simple) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = simple;
textarea.setSelectionRange(start, end);
}
});
};
if (typeof jQuery !== 'undefined') {
const originalVal = jQuery.fn.val;
jQuery.fn.val = function(value) {
if (arguments.length === 0 && this.is('textarea')) {
return reconstruct(originalVal.call(this));
}
if (arguments.length > 0 && this.is('textarea')) {
return originalVal.call(this, simplify(value));
}
return originalVal.apply(this, arguments);
};
}
document.addEventListener('drop', (e) => {
if (e.target.tagName === 'TEXTAREA') {
setTimeout(() => applySimplify(e.target), 150);
}
}, true);
document.addEventListener('input', (e) => {
if (e.target.tagName === 'TEXTAREA') {
clearTimeout(throttleTimer);
throttleTimer = setTimeout(() => applySimplify(e.target), 500);
}
});
document.addEventListener('click', (e) => {
const btn = e.target.closest(
'#wall-submit-preview, #profile-jot-submit, #wall-submit-submit, #jot-submit, ' +
'[id^="comment-edit-submit-"], [id^="comment-edit-preview-link-"]'
);
if (btn) {
const textareas = document.querySelectorAll('textarea');
if (textareas.length > 0) {
textareas.forEach(textarea => {
textarea.value = reconstruct(textarea.value);
if (btn.id.includes('preview')) {
setTimeout(() => applySimplify(textarea), 1000);
}
});
}
}
}, true);
setInterval(() => {
if (document.hidden) return;
const textareas = document.querySelectorAll('textarea');
if (textareas.length === 0) return;
textareas.forEach(textarea => {
if (textarea.offsetParent !== null) {
applySimplify(textarea);
}
});
}, 2500);
cleanupOldEntries();
})();

View file

@ -1,28 +0,0 @@
<?php
/**
* Name: QuickPhoto
* Description: Replaces the BBCode for inserted images and provides a placeholder for image descriptions.
* Version: 1.2
* Author: Matthias Ebers <https://loma.ml/profile/feb>
*/
use Friendica\Core\Hook;
use Friendica\DI;
function quickphoto_install() {
Hook::register('page_header', 'addon/quickphoto/quickphoto.php', 'quickphoto_header');
Hook::register('post_post', 'addon/quickphoto/quickphoto.php', 'quickphoto_post_hook');
}
function quickphoto_header(&$header) {
$desc_label = DI::l10n()->t('Image description');
$js_label = addslashes($desc_label);
$header .= "\n" . '<script type="text/javascript">var qp_i18n = { imageDesc: "' . $js_label . '" };</script>';
$header .= "\n" . '<script type="text/javascript" src="/addon/quickphoto/quickphoto.js?v=5.1"></script>' . "\n";
}
function quickphoto_post_hook(&$item) {
// Placeholder
}