Merge pull request #909 from MrPetovan/task/4090-move-profile_photo-to-src
[friendica-addons.git/.git] / discourse / discourse.php
1 <?php
2
3 /**
4  * Name: Discourse Mail Connector
5  * Description: Improves mails from Discourse in mailing list mode
6  * Version: 0.1
7  * Author: Michael Vogel <http://pirati.ca/profile/heluecht>
8  *
9  */
10 use Friendica\App;
11 use Friendica\Core\Hook;
12 use Friendica\Core\L10n;
13 use Friendica\Core\Logger;
14 use Friendica\Core\PConfig;
15 use Friendica\Core\Renderer;
16 use Friendica\Core\Protocol;
17 use Friendica\Database\DBA;
18 use Friendica\Model\Contact;
19 use Friendica\Util\XML;
20 use Friendica\Content\Text\Markdown;
21 use Friendica\Util\Network;
22 use Friendica\Util\Strings;
23 Use Friendica\Util\DateTimeFormat;
24
25 /* Todo:
26  * - Obtaining API tokens to be able to read non public posts as well
27  * - Handling duplicates (possibly using some non visible marker)
28  * - Fetching missing posts
29  * - Fetch topic information
30  * - Support mail free mode when write tokens are available
31  * - Fix incomplete (relative) links (hosts are missing)
32 */
33
34 function discourse_install()
35 {
36         Hook::register('email_getmessage',        __FILE__, 'discourse_email_getmessage');
37         Hook::register('connector_settings',      __FILE__, 'discourse_settings');
38         Hook::register('connector_settings_post', __FILE__, 'discourse_settings_post');
39 }
40
41 function discourse_settings(App $a, &$s)
42 {
43         if (!local_user()) {
44                 return;
45         }
46
47         $enabled = intval(PConfig::get(local_user(), 'discourse', 'enabled'));
48
49         $t = Renderer::getMarkupTemplate('settings.tpl', 'addon/discourse/');
50         $s .= Renderer::replaceMacros($t, [
51                 '$title'   => L10n::t('Discourse'),
52                 '$enabled' => ['enabled', L10n::t('Enable processing of Discourse mailing list mails'), $enabled, L10n::t('If enabled, incoming mails from Discourse will be improved so they look much better. To make it work, you have to configure the e-mail settings in Friendica. You also have to enable the mailing list mode in Discourse. Then you have to add the Discourse mail account as contact.')],
53                 '$submit'  => L10n::t('Save Settings'),
54         ]);
55 }
56
57 function discourse_settings_post(App $a)
58 {
59         if (!local_user() || empty($_POST['discourse-submit'])) {
60                 return;
61         }
62
63         PConfig::set(local_user(), 'discourse', 'enabled', intval($_POST['enabled']));
64 }
65
66 function discourse_email_getmessage(App $a, &$message)
67 {
68         if (empty($message['item']['uid'])) {
69                 return;
70         }
71
72         if (!PConfig::get($message['item']['uid'], 'discourse', 'enabled')) {
73                 return;
74         }
75
76         // We do assume that all Discourse servers are running with SSL
77         if (preg_match('=topic/(.*\d)/(.*\d)@(.*)=', $message['item']['uri'], $matches) &&
78                 discourse_fetch_post_from_api($message, $matches[2], $matches[3])) {
79                 Logger::info('Fetched comment via API (message-id mode)', ['host' => $matches[3], 'topic' => $matches[1], 'post' => $matches[2]]);
80                 return;
81         }
82
83         if (preg_match('=topic/(.*\d)@(.*)=', $message['item']['uri'], $matches) &&
84                 discourse_fetch_topic_from_api($message, 'https://' . $matches[2], $matches[1], 1)) {
85                 Logger::info('Fetched starting post via API (message-id mode)', ['host' => $matches[2], 'topic' => $matches[1]]);
86                 return;
87         }
88
89         // Search in the text part for the link to the discourse entry and the text body
90         if (!empty($message['text'])) {
91                 $message = discourse_get_text($message);
92         }
93
94         if (empty($message['item']['plink']) || !preg_match('=(http.*)/t/.*/(.*\d)/(.*\d)=', $message['item']['plink'], $matches)) {
95                 Logger::info('This is no Discourse post');
96                 return;
97         }
98
99         if (discourse_fetch_topic_from_api($message, $matches[1], $matches[2], $matches[3])) {
100                 Logger::info('Fetched post via API (plink mode)', ['host' => $matches[1], 'topic' => $matches[2], 'id' => $matches[3]]);
101                 return;
102         }
103
104         Logger::info('Fallback mode', ['plink' => $message['item']['plink']]);
105         // Search in the HTML part for the discourse entry and the author profile
106         if (!empty($message['html'])) {
107                 $message = discourse_get_html($message);
108         }
109
110         // Remove the title on comments, they don't serve any purpose there
111         if ($message['item']['parent-uri'] != $message['item']['uri']) {
112                 unset($message['item']['title']);
113         }
114 }
115
116 function discourse_fetch_post($host, $topic, $pid)
117 {
118         $url = $host . '/t/' . $topic . '/' . $pid . '.json';
119         $curlResult = Network::curl($url);
120         if (!$curlResult->isSuccess()) {
121                 Logger::info('No success', ['url' => $url]);
122                 return false;
123         }
124
125         $raw = $curlResult->getBody();
126         $data = json_decode($raw, true);
127         $posts = $data['post_stream']['posts'];
128         foreach($posts as $post) {
129                 if ($post['post_number'] != $pid) {
130                         /// @todo Possibly fetch missing posts here
131                         continue;
132                 }
133                 Logger::info('Got post data from topic', $post);
134                 return $post;
135         }
136
137         Logger::info('Post not found', ['host' => $host, 'topic' => $topic, 'pid' => $pid]);
138         return false;
139 }
140
141 function discourse_fetch_topic_from_api(&$message, $host, $topic, $pid)
142 {
143         $post = discourse_fetch_post($host, $topic, $pid);
144         if (empty($post)) {
145                 return false;
146         }
147
148         $message = discourse_process_post($message, $post, $host);
149         return true;
150 }
151
152 function discourse_fetch_post_from_api(&$message, $post, $host)
153 {
154         $hostaddr = 'https://' . $host;
155         $url = $hostaddr . '/posts/' . $post . '.json';
156         $curlResult = Network::curl($url);
157         if (!$curlResult->isSuccess()) {
158                 return false;
159         }
160
161         $raw = $curlResult->getBody();
162         $data = json_decode($raw, true);
163         if (empty($data)) {
164                 return false;
165         }
166
167         $message = discourse_process_post($message, $data, $hostaddr);
168
169         Logger::info('Got API data', $message);
170         return true;
171 }
172
173 function discourse_get_user($post, $hostaddr)
174 {
175         $host = parse_url($hostaddr, PHP_URL_HOST);
176
177         // Currently unused contact fields:
178         // - display_username
179         // - user_id
180
181         $contact = [];
182         $contact['uid'] = 0;
183         $contact['network'] = Protocol::DISCOURSE;
184         $contact['name'] = $contact['nick'] = $post['username'];
185         if (!empty($post['name'])) {
186                 $contact['name'] = $post['name'];
187         }
188
189         $contact['about'] = $post['user_title'];
190
191         if (parse_url($post['avatar_template'], PHP_URL_SCHEME)) {
192                 $contact['photo'] = str_replace('{size}', '300', $post['avatar_template']);
193         } else {
194                 $contact['photo'] = $hostaddr . str_replace('{size}', '300', $post['avatar_template']);
195         }
196
197         $contact['addr'] = $contact['nick'] . '@' . $host;
198         $contact['contact-type'] = Contact::TYPE_PERSON;
199         $contact['url'] = $hostaddr . '/u/' . $contact['nick'];
200         $contact['nurl'] = Strings::normaliseLink($contact['url']);
201         $contact['baseurl'] = $hostaddr;
202         Logger::info('Contact', $contact);
203         $contact['id'] = Contact::getIdForURL($contact['url'], 0, true, $contact);
204         if (!empty($contact['id'])) {
205                 $avatar = $contact['photo'];
206                 unset($contact['photo']);
207                 DBA::update('contact', $contact, ['id' => $contact['id']]);
208                 Contact::updateAvatar($avatar, 0, $contact['id']);
209                 $contact['photo'] = $avatar;
210         }
211
212         return $contact;
213 }
214
215 function discourse_process_post($message, $post, $hostaddr)
216 {
217         $host = parse_url($hostaddr, PHP_URL_HOST);
218
219         $message['html'] = $post['cooked'];
220
221         $contact = discourse_get_user($post, $hostaddr);
222         $message['item']['author-id'] = $contact['id'];
223         $message['item']['author-link'] = $contact['url'];
224         $message['item']['author-name'] = $contact['name'];
225         $message['item']['author-avatar'] = $contact['photo'];
226         $message['item']['created'] = DateTimeFormat::utc($post['created_at']);
227         $message['item']['plink'] = $hostaddr . '/t/' . $post['topic_slug'] . '/' . $post['topic_id'] . '/' . $post['post_number'];
228
229         if ($post['post_number'] == 1) {
230                 $message['item']['parent-uri'] = $message['item']['uri'] = 'topic/' . $post['topic_id'] . '@' . $host;
231
232                 // Remove the Discourse forum name from the subject
233                 $pattern = '=\[.*\].*\s(\[.*\].*)=';
234                 if (preg_match($pattern, $message['item']['title'])) {
235                         $message['item']['title'] = preg_replace($pattern, '$1', $message['item']['title']);
236                 }
237                 /// @ToDo Fetch thread information
238         } else {
239                 $message['item']['uri'] = 'topic/' . $post['topic_id'] . '/' . $post['id'] . '@' . $host;
240                 unset($message['item']['title']);
241                 if (empty($post['reply_to_post_number']) || $post['reply_to_post_number'] == 1) {
242                         $message['item']['parent-uri'] = 'topic/' . $post['topic_id'] . '@' . $host;
243                 } else {
244                         $reply = discourse_fetch_post($hostaddr, $post['topic_id'], $post['reply_to_post_number']);
245                         $message['item']['parent-uri'] = 'topic/' . $post['topic_id'] . '/' . $reply['id'] . '@' . $host;
246                 }
247         }
248
249         return $message;
250 }
251
252 function discourse_get_html($message)
253 {
254         $doc = new DOMDocument();
255         $doc2 = new DOMDocument();
256         $doc->preserveWhiteSpace = false;
257
258         $html = mb_convert_encoding($message['html'], 'HTML-ENTITIES', "UTF-8");
259         @$doc->loadHTML($html, LIBXML_HTML_NODEFDTD);
260
261         $xpath = new DomXPath($doc);
262
263         // Fetch the first 'div' before the 'hr' - hopefully this fits for all systems
264         $result = $xpath->query("//hr//preceding::div[1]");
265         $div = $doc2->importNode($result->item(0), true);
266         $doc2->appendChild($div);
267         $message['html'] = $doc2->saveHTML();
268         Logger::info('Found html body', ['html' => $message['html']]);
269
270         $profile = discourse_get_profile($xpath);
271         if (!empty($profile['url'])) {
272                 Logger::info('Found profile', $profile);
273                 $message['item']['author-id'] = Contact::getIdForURL($profile['url'], 0, true, $profile);
274                 $message['item']['author-link'] = $profile['url'];
275                 $message['item']['author-name'] = $profile['name'];
276                 $message['item']['author-avatar'] = $profile['photo'];
277         }
278
279         return $message;
280 }
281
282 function discourse_get_text($message)
283 {
284         $text = $message['text'];
285         $text = str_replace("\r", '', $text);
286         $pos = strpos($text, "\n---\n");
287         if ($pos == 0) {
288                 Logger::info('No separator found', ['text' => $text]);
289                 return $message;
290         }
291
292         $message['text'] = trim(substr($text, 0, $pos));
293
294         Logger::info('Found text body', ['text' => $message['text']]);
295
296         $message['text'] = Markdown::toBBCode($message['text']);
297
298         $text = substr($text, $pos);
299         Logger::info('Found footer', ['text' => $text]);
300         if (preg_match('=\((http.*/t/.*/.*\d/.*\d)\)=', $text, $link)) {
301                 $message['item']['plink'] = $link[1];
302                 Logger::info('Found plink', ['plink' => $message['item']['plink']]);
303         }
304         return $message;
305 }
306
307 function discourse_get_profile($xpath)
308 {
309         $profile = [];
310         $list = $xpath->query("//td//following::img");
311         foreach ($list as $node) {
312                 $attr = [];
313                 foreach ($node->attributes as $attribute) {
314                         $attr[$attribute->name] = $attribute->value;
315                 }
316
317                 if (!empty($attr['src']) && !empty($attr['title'])
318                         && !empty($attr['width']) && !empty($attr['height'])
319                         && ($attr['width'] == $attr['height'])) {
320                         $profile = ['photo' => $attr['src'], 'name' => $attr['title']];
321                         break;
322                 }
323         }
324
325         $list = $xpath->query("//td//following::a");
326         foreach ($list as $node) {
327                 if (!empty(trim($node->textContent)) && $node->attributes->length) {
328                         $attr = [];
329                         foreach ($node->attributes as $attribute) {
330                                 $attr[$attribute->name] = $attribute->value;
331                         }
332                         if (!empty($attr['href']) && (strpos($attr['href'], '/' . $profile['name']))) {
333                                 $profile['url'] = $attr['href'];
334                                 break;
335                         }
336                 }
337         }
338         return $profile;
339 }