]> git.mxchange.org Git - friendica-addons.git/blob - twitter/twitter.php
Bluesky: Improved on the connector page
[friendica-addons.git] / twitter / twitter.php
1 <?php
2 /**
3  * Name: Twitter Post Connector
4  * Description: Post to Twitter
5  * Version: 2.0
6  * Author: Tobias Diekershoff <https://f.diekershoff.de/profile/tobias>
7  * Author: Michael Vogel <https://pirati.ca/profile/heluecht>
8  * Maintainer: Hypolite Petovan <https://friendica.mrpetovan.com/profile/hypolite>
9  * Maintainer: Michael Vogel <https://pirati.ca/profile/heluecht>
10  *
11  * Copyright (c) 2011-2023 Tobias Diekershoff, Michael Vogel, Hypolite Petovan
12  * All rights reserved.
13  *
14  * Redistribution and use in source and binary forms, with or without
15  * modification, are permitted provided that the following conditions are met:
16  *    * Redistributions of source code must retain the above copyright notice,
17  *     this list of conditions and the following disclaimer.
18  *    * Redistributions in binary form must reproduce the above
19  *    * copyright notice, this list of conditions and the following disclaimer in
20  *      the documentation and/or other materials provided with the distribution.
21  *    * Neither the name of the <organization> nor the names of its contributors
22  *      may be used to endorse or promote products derived from this software
23  *      without specific prior written permission.
24  *
25  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
26  * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
27  * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28  * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT,
29  * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
30  * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
31  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
32  * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
33  * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
34  * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35  *
36  */
37
38 use Friendica\Content\Text\BBCode;
39 use Friendica\Content\Text\Plaintext;
40 use Friendica\Core\Hook;
41 use Friendica\Core\Logger;
42 use Friendica\Core\Renderer;
43 use Friendica\Core\Worker;
44 use Friendica\DI;
45 use Friendica\Model\Item;
46 use Friendica\Model\Post;
47 use Friendica\Core\Config\Util\ConfigFileManager;
48 use Friendica\Model\Photo;
49 use Friendica\Object\Image;
50 use GuzzleHttp\Client;
51 use GuzzleHttp\Exception\RequestException;
52 use GuzzleHttp\HandlerStack;
53 use GuzzleHttp\Subscriber\Oauth\Oauth1;
54
55 const TWITTER_IMAGE_SIZE = [2000000, 1000000, 500000, 100000, 50000];
56
57 function twitter_install()
58 {
59         Hook::register('load_config', __FILE__, 'twitter_load_config');
60         Hook::register('connector_settings', __FILE__, 'twitter_settings');
61         Hook::register('connector_settings_post', __FILE__, 'twitter_settings_post');
62         Hook::register('hook_fork', __FILE__, 'twitter_hook_fork');
63         Hook::register('post_local', __FILE__, 'twitter_post_local');
64         Hook::register('notifier_normal', __FILE__, 'twitter_post_hook');
65         Hook::register('jot_networks', __FILE__, 'twitter_jot_nets');
66 }
67
68 function twitter_load_config(ConfigFileManager $loader)
69 {
70         DI::app()->getConfigCache()->load($loader->loadAddonConfig('twitter'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
71 }
72
73 function twitter_jot_nets(array &$jotnets_fields)
74 {
75         if (!DI::userSession()->getLocalUserId()) {
76                 return;
77         }
78
79         if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post')) {
80                 $jotnets_fields[] = [
81                         'type' => 'checkbox',
82                         'field' => [
83                                 'twitter_enable',
84                                 DI::l10n()->t('Post to Twitter'),
85                                 DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default')
86                         ]
87                 ];
88         }
89 }
90
91 function twitter_settings_post()
92 {
93         if (!DI::userSession()->getLocalUserId() || empty($_POST['twitter-submit'])) {
94                 return;
95         }
96
97         $api_key       = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'api_key');
98         $api_secret    = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'api_secret');
99         $access_token  = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'access_token');
100         $access_secret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'access_secret');
101
102         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post',            (bool)$_POST['twitter-enable']);
103         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default', (bool)$_POST['twitter-default']);
104         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'api_key',         $_POST['twitter-api-key']);
105         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'api_secret',      $_POST['twitter-api-secret']);
106         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'access_token',    $_POST['twitter-access-token']);
107         DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'twitter', 'access_secret',   $_POST['twitter-access-secret']);
108
109         if (
110                 empty(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'last_status')) ||
111                 ($api_key != $_POST['twitter-api-key']) || ($api_secret != $_POST['twitter-api-secret']) ||
112                 ($access_token != $_POST['twitter-access-token']) || ($access_secret != $_POST['twitter-access-secret'])
113         ) {
114                 twitter_test_connection(DI::userSession()->getLocalUserId());
115         }
116 }
117
118 function twitter_settings(array &$data)
119 {
120         if (!DI::userSession()->getLocalUserId()) {
121                 return;
122         }
123
124         $enabled      = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post') ?? false;
125         $def_enabled  = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default') ?? false;
126
127         $api_key       = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'api_key');
128         $api_secret    = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'api_secret');
129         $access_token  = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'access_token');
130         $access_secret = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'access_secret');
131
132         $last_status = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'last_status');
133         if (!empty($last_status['code']) && !empty($last_status['reason'])) {
134                 $status_title = sprintf('%d - %s', $last_status['code'], $last_status['reason']);
135         } else {
136                 $status_title = DI::l10n()->t('No status.');
137         }
138         $status_content = $last_status['content'] ?? '';
139
140         $t    = Renderer::getMarkupTemplate('connector_settings.tpl', 'addon/twitter/');
141         $html = Renderer::replaceMacros($t, [
142                 '$enable'        => ['twitter-enable', DI::l10n()->t('Allow posting to Twitter'), $enabled, DI::l10n()->t('If enabled all your <strong>public</strong> postings can be posted to the associated Twitter account. You can choose to do so by default (here) or for every posting separately in the posting options when writing the entry.')],
143                 '$default'       => ['twitter-default', DI::l10n()->t('Send public postings to Twitter by default'), $def_enabled],
144                 '$api_key'       => ['twitter-api-key', DI::l10n()->t('API Key'), $api_key],
145                 '$api_secret'    => ['twitter-api-secret', DI::l10n()->t('API Secret'), $api_secret],
146                 '$access_token'  => ['twitter-access-token', DI::l10n()->t('Access Token'), $access_token],
147                 '$access_secret' => ['twitter-access-secret', DI::l10n()->t('Access Secret'), $access_secret],
148                 '$help'          => DI::l10n()->t('Each user needs to register their own app to be able to post to Twitter. Please visit https://developer.twitter.com/en/portal/projects-and-apps to register a project. Inside the project you then have to register an app. You will find the needed data for the connector on the page "Keys and token" in the app settings.'),
149                 '$status_title'  => ['twitter-status-title', DI::l10n()->t('Last Status Summary'), $status_title, '', '', 'readonly'],
150                 '$status'        => ['twitter-status', DI::l10n()->t('Last Status Content'), $status_content, '', '', 'readonly'],
151         ]);
152
153         $data = [
154                 'connector' => 'twitter',
155                 'title'     => DI::l10n()->t('Twitter Export'),
156                 'enabled'   => $enabled,
157                 'image'     => 'images/twitter.png',
158                 'html'      => $html,
159         ];
160 }
161
162 function twitter_hook_fork(array &$b)
163 {
164         DI::logger()->debug('twitter_hook_fork', $b);
165
166         if ($b['name'] != 'notifier_normal') {
167                 return;
168         }
169
170         $post = $b['data'];
171
172         if (
173                 $post['deleted'] || $post['private'] || ($post['created'] !== $post['edited']) ||
174                 !strstr($post['postopts'], 'twitter') || ($post['gravity'] != Item::GRAVITY_PARENT)
175         ) {
176                 $b['execute'] = false;
177                 return;
178         }
179 }
180
181 function twitter_post_local(array &$b)
182 {
183         if (!DI::userSession()->getLocalUserId() || (DI::userSession()->getLocalUserId() != $b['uid'])) {
184                 return;
185         }
186
187         if ($b['edit'] || $b['private'] || $b['parent']) {
188                 return;
189         }
190
191         $twitter_post   = (bool)DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post');
192         $twitter_enable = (($twitter_post && !empty($_REQUEST['twitter_enable'])) ? (bool)$_REQUEST['twitter_enable'] : false);
193
194         // if API is used, default to the chosen settings
195         if ($b['api_source'] && intval(DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'twitter', 'post_by_default'))) {
196                 $twitter_enable = true;
197         }
198
199         if (!$twitter_enable) {
200                 return;
201         }
202
203         if (strlen($b['postopts'])) {
204                 $b['postopts'] .= ',';
205         }
206
207         $b['postopts'] .= 'twitter';
208 }
209
210 function twitter_post_hook(array &$b)
211 {
212         DI::logger()->debug('Invoke post hook', $b);
213
214         if (($b['gravity'] != Item::GRAVITY_PARENT) || !strstr($b['postopts'], 'twitter') || $b['private'] || $b['deleted'] || ($b['created'] !== $b['edited'])) {
215                 return;
216         }
217
218         $b['body'] = Post\Media::addAttachmentsToBody($b['uri-id'], DI::contentItem()->addSharedPost($b));
219
220         Logger::notice('twitter post invoked', ['id' => $b['id'], 'guid' => $b['guid']]);
221
222         DI::pConfig()->load($b['uid'], 'twitter');
223
224         $api_key       = DI::pConfig()->get($b['uid'], 'twitter', 'api_key');
225         $api_secret    = DI::pConfig()->get($b['uid'], 'twitter', 'api_secret');
226         $access_token  = DI::pConfig()->get($b['uid'], 'twitter', 'access_token');
227         $access_secret = DI::pConfig()->get($b['uid'], 'twitter', 'access_secret');
228
229         if (empty($api_key) || empty($api_secret) || empty($access_token) || empty($access_secret)) {
230                 Logger::info('Missing keys, secrets or tokens.');
231                 return;
232         }
233
234         $msgarr = Plaintext::getPost($b, 280, true, BBCode::TWITTER);
235         Logger::debug('Got plaintext', ['id' => $b['id'], 'message' => $msgarr]);
236
237         $media_ids = [];
238
239         if (!empty($msgarr['images']) || !empty($msgarr['remote_images'])) {
240                 Logger::info('Got images', ['id' => $b['id'], 'images' => $msgarr['images'] ?? []]);
241
242                 $retrial = Worker::getRetrial();
243                 if ($retrial > 4) {
244                         return;
245                 }
246                 foreach ($msgarr['images'] ?? [] as $image) {
247                         if (count($media_ids) == 4) {
248                                 continue;
249                         }
250                         try {
251                                 $media_ids[] = twitter_upload_image($b['uid'], $image, $retrial);
252                         } catch (RequestException $exception) {
253                                 Logger::warning('Error while uploading image', ['image' => $image, 'code' => $exception->getCode(), 'message' => $exception->getMessage()]);
254                                 Worker::defer();
255                                 return;
256                         }
257                 }
258         }
259
260         $in_reply_to_tweet_id = 0;
261
262         Logger::debug('Post message', ['id' => $b['id'], 'parts' => count($msgarr['parts'])]);
263         foreach ($msgarr['parts'] as $key => $part) {
264                 try {
265                         $id = twitter_post_status($b['uid'], $part, $media_ids, $in_reply_to_tweet_id);
266                         Logger::info('twitter_post send', ['part' => $key, 'id' => $b['id'], 'result' => $id]);
267                 } catch (RequestException $exception) {
268                         Logger::warning('Error while posting message', ['part' => $key, 'id' => $b['id'], 'code' => $exception->getCode(), 'message' => $exception->getMessage()]);
269                         $status = [
270                                 'code'    => $exception->getCode(),
271                                 'reason'  => $exception->getResponse()->getReasonPhrase(),
272                                 'content' => $exception->getMessage()
273                         ];
274                         DI::pConfig()->set($b['uid'], 'twitter', 'last_status', $status);
275                         if ($key == 0) {
276                                 Worker::defer();
277                         }
278                         break;
279                 }
280
281                 $in_reply_to_tweet_id = $id;
282                 $media_ids = [];
283         }
284 }
285
286 function twitter_post_status(int $uid, string $status, array $media_ids = [], string $in_reply_to_tweet_id = ''): string
287 {
288         $parameters = ['text' => $status];
289         if (!empty($media_ids)) {
290                 $parameters['media'] = ['media_ids' => $media_ids];
291         }
292         if (!empty($in_reply_to_tweet_id)) {
293                 $parameters['reply'] = ['in_reply_to_tweet_id' => $in_reply_to_tweet_id];
294         }
295
296         $response = twitter_post($uid, 'https://api.twitter.com/2/tweets', 'json', $parameters);
297
298         return $response->data->id;
299 }
300
301 function twitter_upload_image(int $uid, array $image, int $retrial)
302 {
303         if (!empty($image['id'])) {
304                 $photo = Photo::selectFirst([], ['id' => $image['id']]);
305         } else {
306                 $photo = Photo::createPhotoForExternalResource($image['url']);
307         }
308
309         $picturedata = Photo::getImageForPhoto($photo);
310
311         $picture = new Image($picturedata, $photo['type']);
312         $height  = $picture->getHeight();
313         $width   = $picture->getWidth();
314         $size    = strlen($picturedata);
315
316         $picture     = Photo::resizeToFileSize($picture, TWITTER_IMAGE_SIZE[$retrial]);
317         $new_height  = $picture->getHeight();
318         $new_width   = $picture->getWidth();
319         $picturedata = $picture->asString();
320         $new_size    = strlen($picturedata);
321
322         Logger::info('Uploading', ['uid' => $uid, 'retrial' => $retrial, 'height' => $new_height, 'width' => $new_width, 'size' => $new_size, 'orig-height' => $height, 'orig-width' => $width, 'orig-size' => $size, 'image' => $image]);
323         $media = twitter_post($uid, 'https://upload.twitter.com/1.1/media/upload.json', 'form_params', ['media' => base64_encode($picturedata)]);
324         Logger::info('Uploading done', ['uid' => $uid, 'retrial' => $retrial, 'height' => $new_height, 'width' => $new_width, 'size' => $new_size, 'orig-height' => $height, 'orig-width' => $width, 'orig-size' => $size, 'image' => $image]);
325
326         if (isset($media->media_id_string)) {
327                 $media_id = $media->media_id_string;
328
329                 if (!empty($image['description'])) {
330                         $data = [
331                                 'media_id' => $media->media_id_string,
332                                 'alt_text' => [
333                                         'text' => substr($image['description'], 0, 1000)
334                                 ]
335                         ];
336                         $ret = twitter_post($uid, 'https://upload.twitter.com/1.1/media/metadata/create.json', 'json', $data);
337                         Logger::info('Metadata create', ['uid' => $uid, 'data' => $data, 'return' => $ret]);
338                 }
339         } else {
340                 Logger::error('Failed upload', ['uid' => $uid, 'size' => strlen($picturedata), 'image' => $image['url'], 'return' => $media]);
341                 throw new Exception('Failed upload of ' . $image['url']);
342         }
343
344         return $media_id;
345 }
346
347 function twitter_post(int $uid, string $url, string $type, array $data): stdClass
348 {
349         $stack = HandlerStack::create();
350
351         $middleware = new Oauth1([
352                 'consumer_key'    => DI::pConfig()->get($uid, 'twitter', 'api_key'),
353                 'consumer_secret' => DI::pConfig()->get($uid, 'twitter', 'api_secret'),
354                 'token'           => DI::pConfig()->get($uid, 'twitter', 'access_token'),
355                 'token_secret'    => DI::pConfig()->get($uid, 'twitter', 'access_secret'),
356         ]);
357
358         $stack->push($middleware);
359
360         $client = new Client([
361                 'handler' => $stack
362         ]);
363
364         $response = $client->post($url, ['auth' => 'oauth', $type => $data]);
365         $body     = $response->getBody()->getContents();
366
367         $status = [
368                 'code'    => $response->getStatusCode(),
369                 'reason'  => $response->getReasonPhrase(),
370                 'content' => $body
371         ];
372
373         DI::pConfig()->set($uid, 'twitter', 'last_status', $status);
374
375         $content = json_decode($body) ?? new stdClass;
376         Logger::debug('Success', ['content' => $content]);
377         return $content;
378 }
379
380 function twitter_test_connection(int $uid)
381 {
382         $stack = HandlerStack::create();
383
384         $middleware = new Oauth1([
385                 'consumer_key'    => DI::pConfig()->get($uid, 'twitter', 'api_key'),
386                 'consumer_secret' => DI::pConfig()->get($uid, 'twitter', 'api_secret'),
387                 'token'           => DI::pConfig()->get($uid, 'twitter', 'access_token'),
388                 'token_secret'    => DI::pConfig()->get($uid, 'twitter', 'access_secret'),
389         ]);
390
391         $stack->push($middleware);
392
393         $client = new Client([
394                 'handler' => $stack
395         ]);
396
397         try {
398                 $response = $client->get('https://api.twitter.com/2/users/me', ['auth' => 'oauth']);
399                 $status = [
400                         'code'   => $response->getStatusCode(),
401                         'reason'  => $response->getReasonPhrase(),
402                         'content' => $response->getBody()->getContents()
403                 ];
404                 DI::pConfig()->set(1, 'twitter', 'last_status',  $status);
405                 Logger::info('Test successful', ['uid' => $uid]);
406         } catch (RequestException $exception) {
407                 $status = [
408                         'code'    => $exception->getCode(),
409                         'reason'  => $exception->getResponse()->getReasonPhrase(),
410                         'content' => $exception->getMessage()
411                 ];
412                 DI::pConfig()->set(1, 'twitter', 'last_status',  $status);
413                 Logger::info('Test failed', ['uid' => $uid]);
414         }
415 }