yammerimporter.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. <?php
  2. /*
  3. * StatusNet - the distributed open-source microblogging tool
  4. * Copyright (C) 2010, StatusNet, Inc.
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Affero General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Affero General Public License
  17. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. */
  19. /**
  20. * Basic client class for Yammer's OAuth/JSON API.
  21. *
  22. * Depends on Favorite plugin
  23. *
  24. * @package YammerImportPlugin
  25. * @author Brion Vibber <brion@status.net>
  26. */
  27. class YammerImporter
  28. {
  29. protected $client;
  30. function __construct(SNYammerClient $client)
  31. {
  32. $this->client = $client;
  33. }
  34. /**
  35. * Load or create an imported profile from Yammer data.
  36. *
  37. * @param object $item loaded JSON data for Yammer importer
  38. * @return Profile
  39. */
  40. function importUser($item)
  41. {
  42. $data = $this->prepUser($item);
  43. $nickname = $data['options']['nickname'];
  44. $profileId = $this->findImportedUser($data['orig_id']);
  45. if ($profileId) {
  46. return Profile::getKV('id', $profileId);
  47. } else {
  48. $user = User::getKV('nickname', $nickname);
  49. if ($user) {
  50. common_log(LOG_WARNING, "Copying Yammer profile info onto existing user $nickname");
  51. $profile = $user->getProfile();
  52. $this->savePropertiesOn($profile, $data['options'],
  53. array('fullname', 'homepage', 'bio', 'location'));
  54. } else {
  55. $user = User::register($data['options']);
  56. $profile = $user->getProfile();
  57. }
  58. if ($data['avatar']) {
  59. try {
  60. $this->saveAvatar($data['avatar'], $profile);
  61. } catch (Exception $e) {
  62. common_log(LOG_ERR, "Error importing Yammer avatar: " . $e->getMessage());
  63. }
  64. }
  65. $this->recordImportedUser($data['orig_id'], $profile->id);
  66. return $profile;
  67. }
  68. }
  69. /**
  70. * Load or create an imported group from Yammer data.
  71. *
  72. * @param object $item loaded JSON data for Yammer importer
  73. * @return User_group
  74. */
  75. function importGroup($item)
  76. {
  77. $data = $this->prepGroup($item);
  78. $nickname = $data['options']['nickname'];
  79. $groupId = $this->findImportedGroup($data['orig_id']);
  80. if ($groupId) {
  81. return User_group::getKV('id', $groupId);
  82. } else {
  83. $local = Local_group::getKV('nickname', $nickname);
  84. if ($local) {
  85. common_log(LOG_WARNING, "Copying Yammer group info onto existing group $nickname");
  86. $group = User_group::getKV('id', $local->group_id);
  87. $this->savePropertiesOn($group, $data['options'],
  88. array('fullname', 'description'));
  89. } else {
  90. $group = User_group::register($data['options']);
  91. }
  92. if ($data['avatar']) {
  93. try {
  94. $this->saveAvatar($data['avatar'], $group);
  95. } catch (Exception $e) {
  96. common_log(LOG_ERR, "Error importing Yammer avatar: " . $e->getMessage());
  97. }
  98. }
  99. $this->recordImportedGroup($data['orig_id'], $group->id);
  100. return $group;
  101. }
  102. }
  103. private function savePropertiesOn($target, $options, $propList)
  104. {
  105. $changed = 0;
  106. $orig = clone($target);
  107. foreach ($propList as $prop) {
  108. if (!empty($options[$prop]) && $target->$prop != $options[$prop]) {
  109. $target->$prop = $options[$prop];
  110. $changed++;
  111. }
  112. }
  113. $target->update($orig);
  114. }
  115. /**
  116. * Load or create an imported notice from Yammer data.
  117. *
  118. * @param object $item loaded JSON data for Yammer importer
  119. * @return Notice
  120. */
  121. function importNotice($item)
  122. {
  123. $data = $this->prepNotice($item);
  124. $noticeId = $this->findImportedNotice($data['orig_id']);
  125. if ($noticeId) {
  126. return Notice::getKV('id', $noticeId);
  127. } else {
  128. $notice = Notice::getKV('uri', $data['options']['uri']);
  129. $content = $data['content'];
  130. $user = User::getKV($data['profile']);
  131. // Fetch file attachments and add the URLs...
  132. $uploads = array();
  133. foreach ($data['attachments'] as $url) {
  134. try {
  135. $upload = $this->saveAttachment($url, $user);
  136. $content .= ' ' . $upload->shortUrl();
  137. $uploads[] = $upload;
  138. } catch (Exception $e) {
  139. common_log(LOG_ERR, "Error importing Yammer attachment: " . $e->getMessage());
  140. }
  141. }
  142. // Here's the meat! Actually save the dang ol' notice.
  143. $notice = Notice::saveNew($user->id,
  144. $content,
  145. $data['source'],
  146. $data['options']);
  147. // Save "likes" as favorites...
  148. foreach ($data['faves'] as $nickname) {
  149. $user = User::getKV('nickname', $nickname);
  150. if ($user) {
  151. try {
  152. Fave::addNew($user->getProfile(), $notice);
  153. } catch (Exception $e) {
  154. // failed, let's move to the next
  155. common_debug('YammerImport failed favoriting a notice: '.$e->getMessage());
  156. }
  157. }
  158. }
  159. // And finally attach the upload records...
  160. foreach ($uploads as $upload) {
  161. $upload->attachToNotice($notice);
  162. }
  163. $this->recordImportedNotice($data['orig_id'], $notice->id);
  164. return $notice;
  165. }
  166. }
  167. /**
  168. * Pull relevant info out of a Yammer data record for a user import.
  169. *
  170. * @param array $item
  171. * @return array
  172. */
  173. function prepUser($item)
  174. {
  175. if ($item['type'] != 'user') {
  176. // TRANS: Exception thrown when a non-user item type is used, but expected.
  177. throw new Exception(_m('Wrong item type sent to Yammer user import processing.'));
  178. }
  179. $origId = $item['id'];
  180. $origUrl = $item['url'];
  181. // @fixme check username rules?
  182. $options['nickname'] = $item['name'];
  183. $options['fullname'] = trim($item['full_name']);
  184. // Avatar... this will be the "_small" variant.
  185. // Remove that (pre-extension) suffix to get the orig-size image.
  186. $avatar = $item['mugshot_url'];
  187. // The following info is only available in full data, not in the reference version.
  188. // There can be extensive contact info, but for now we'll only pull the primary email.
  189. if (isset($item['contact'])) {
  190. foreach ($item['contact']['email_addresses'] as $addr) {
  191. if ($addr['type'] == 'primary') {
  192. $options['email'] = $addr['address'];
  193. $options['email_confirmed'] = true;
  194. break;
  195. }
  196. }
  197. }
  198. // There can be multiple external URLs; for now pull the first one as home page.
  199. if (isset($item['external_urls'])) {
  200. foreach ($item['external_urls'] as $url) {
  201. if (common_valid_http_url($url)) {
  202. $options['homepage'] = $url;
  203. break;
  204. }
  205. }
  206. }
  207. // Combine a few bits into the bio...
  208. $bio = array();
  209. if (!empty($item['job_title'])) {
  210. $bio[] = $item['job_title'];
  211. }
  212. if (!empty($item['summary'])) {
  213. $bio[] = $item['summary'];
  214. }
  215. if (!empty($item['expertise'])) {
  216. // TRANS: Used as a prefix for the Yammer expertise field contents.
  217. $bio[] = _m('Expertise:') . ' ' . $item['expertise'];
  218. }
  219. $options['bio'] = implode("\n\n", $bio);
  220. // Pull raw location string, may be lookupable
  221. if (!empty($item['location'])) {
  222. $options['location'] = $item['location'];
  223. }
  224. // Timezone is in format like 'Pacific Time (US & Canada)'
  225. // We need to convert that to a zone id. :P
  226. // @fixme timezone not yet supported at registration time :)
  227. if (!empty($item['timezone'])) {
  228. $tz = $this->timezone($item['timezone']);
  229. if ($tz) {
  230. $options['timezone'] = $tz;
  231. }
  232. }
  233. return array('orig_id' => $origId,
  234. 'orig_url' => $origUrl,
  235. 'avatar' => $avatar,
  236. 'options' => $options);
  237. }
  238. /**
  239. * Pull relevant info out of a Yammer data record for a group import.
  240. *
  241. * @param array $item
  242. * @return array
  243. */
  244. function prepGroup($item)
  245. {
  246. if ($item['type'] != 'group') {
  247. // TRANS: Exception thrown when a non-group item type is used, but expected.
  248. throw new Exception(_m('Wrong item type sent to Yammer group import processing.'));
  249. }
  250. $origId = $item['id'];
  251. $origUrl = $item['url'];
  252. $privacy = $item['privacy']; // Warning! only public groups in SN so far
  253. $options['nickname'] = $item['name'];
  254. $options['fullname'] = $item['full_name'];
  255. $options['description'] = $item['description'];
  256. $options['created'] = $this->timestamp($item['created_at']);
  257. $avatar = $item['mugshot_url']; // as with user profiles...
  258. $options['mainpage'] = common_local_url('showgroup',
  259. array('nickname' => $options['nickname']));
  260. // Set some default vals or User_group::register will whine
  261. $options['homepage'] = '';
  262. $options['location'] = '';
  263. $options['aliases'] = array();
  264. // @todo FIXME: What about admin user for the group?
  265. $options['local'] = true;
  266. return array('orig_id' => $origId,
  267. 'orig_url' => $origUrl,
  268. 'options' => $options,
  269. 'avatar' => $avatar);
  270. }
  271. /**
  272. * Pull relevant info out of a Yammer data record for a notice import.
  273. *
  274. * @param array $item
  275. * @return array
  276. */
  277. function prepNotice($item)
  278. {
  279. if (isset($item['type']) && $item['type'] != 'message') {
  280. // TRANS: Exception thrown when a non-message item type is used, but expected.
  281. throw new Exception(_m('Wrong item type sent to Yammer message import processing.'));
  282. }
  283. $origId = $item['id'];
  284. $origUrl = $item['url'];
  285. $profile = $this->findImportedUser($item['sender_id']);
  286. $content = $item['body']['plain'];
  287. $source = 'yammer';
  288. $options = array();
  289. if ($item['replied_to_id']) {
  290. $replyTo = $this->findImportedNotice($item['replied_to_id']);
  291. if ($replyTo) {
  292. $options['reply_to'] = $replyTo;
  293. }
  294. }
  295. $options['created'] = $this->timestamp($item['created_at']);
  296. if (!empty($item['group_id'])) {
  297. $groupId = $this->findImportedGroup($item['group_id']);
  298. if ($groupId) {
  299. $options['groups'] = array($groupId);
  300. // @fixme if we see a group link inline, don't add this?
  301. $group = User_group::getKV('id', $groupId);
  302. if ($group) {
  303. $content .= ' !' . $group->nickname;
  304. }
  305. }
  306. }
  307. $faves = array();
  308. foreach ($item['liked_by']['names'] as $liker) {
  309. // "permalink" is the username. wtf?
  310. $faves[] = $liker['permalink'];
  311. }
  312. $attachments = array();
  313. foreach ($item['attachments'] as $attach) {
  314. if ($attach['type'] == 'image' || $attach['type'] == 'file') {
  315. $attachments[] = $attach[$attach['type']]['url'];
  316. } else {
  317. common_log(LOG_WARNING, "Unrecognized Yammer attachment type: " . $attach['type']);
  318. }
  319. }
  320. return array('orig_id' => $origId,
  321. 'orig_url' => $origUrl,
  322. 'profile' => $profile,
  323. 'content' => $content,
  324. 'source' => $source,
  325. 'options' => $options,
  326. 'faves' => $faves,
  327. 'attachments' => $attachments);
  328. }
  329. private function findImportedUser($origId)
  330. {
  331. $map = Yammer_user::getKV('id', $origId);
  332. return $map ? $map->user_id : null;
  333. }
  334. private function findImportedGroup($origId)
  335. {
  336. $map = Yammer_group::getKV('id', $origId);
  337. return $map ? $map->group_id : null;
  338. }
  339. private function findImportedNotice($origId)
  340. {
  341. $map = Yammer_notice::getKV('id', $origId);
  342. return $map ? $map->notice_id : null;
  343. }
  344. private function recordImportedUser($origId, $userId)
  345. {
  346. Yammer_user::record($origId, $userId);
  347. }
  348. private function recordImportedGroup($origId, $groupId)
  349. {
  350. Yammer_group::record($origId, $groupId);
  351. }
  352. private function recordImportedNotice($origId, $noticeId)
  353. {
  354. Yammer_notice::record($origId, $noticeId);
  355. }
  356. /**
  357. * Normalize timestamp format.
  358. * @param string $ts
  359. * @return string
  360. */
  361. private function timestamp($ts)
  362. {
  363. return common_sql_date(strtotime($ts));
  364. }
  365. private function timezone($tz)
  366. {
  367. // Blaaaaaarf!
  368. $known = array('Pacific Time (US & Canada)' => 'America/Los_Angeles',
  369. 'Eastern Time (US & Canada)' => 'America/New_York');
  370. if (array_key_exists($tz, $known)) {
  371. return $known[$tz];
  372. } else {
  373. return false;
  374. }
  375. }
  376. /**
  377. * Download and update given avatar image
  378. *
  379. * @param string $url
  380. * @param mixed $dest either a Profile or User_group object
  381. * @throws Exception in various failure cases
  382. */
  383. private function saveAvatar($url, $dest)
  384. {
  385. // Yammer API data mostly gives us the small variant.
  386. // Try hitting the source image if we can!
  387. // @fixme no guarantee of this URL scheme I think.
  388. $url = preg_replace('/_small(\..*?)$/', '$1', $url);
  389. if (!common_valid_http_url($url)) {
  390. // TRANS: Server exception thrown when an avatar URL is invalid.
  391. // TRANS: %s is the invalid avatar URL.
  392. throw new ServerException(sprintf(_m('Invalid avatar URL %s.'), $url));
  393. }
  394. // @fixme this should be better encapsulated
  395. // ripped from oauthstore.php (for old OMB client)
  396. $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
  397. try {
  398. if (!copy($url, $temp_filename)) {
  399. // TRANS: Server exception thrown when an avatar could not be fetched.
  400. // TRANS: %s is the failed avatar URL.
  401. throw new ServerException(sprintf(_m('Unable to fetch avatar from %s.'), $url));
  402. }
  403. $id = $dest->id;
  404. // @fixme should we be using different ids?
  405. $imagefile = new ImageFile($id, $temp_filename);
  406. $filename = Avatar::filename($id,
  407. image_type_to_extension($imagefile->type),
  408. null,
  409. common_timestamp());
  410. rename($temp_filename, Avatar::path($filename));
  411. } catch (Exception $e) {
  412. unlink($temp_filename);
  413. throw $e;
  414. }
  415. // @fixme hardcoded chmod is lame, but seems to be necessary to
  416. // keep from accidentally saving images from command-line (queues)
  417. // that can't be read from web server, which causes hard-to-notice
  418. // problems later on:
  419. //
  420. // http://status.net/open-source/issues/2663
  421. chmod(Avatar::path($filename), 0644);
  422. $dest->setOriginal($filename);
  423. }
  424. /**
  425. * Fetch an attachment from Yammer and save it into our system.
  426. * Unlike avatars, the attachment URLs are guarded by authentication,
  427. * so we need to run the HTTP hit through our OAuth API client.
  428. *
  429. * @param string $url
  430. * @param User $user
  431. * @return MediaFile
  432. *
  433. * @throws Exception on low-level network or HTTP error
  434. */
  435. private function saveAttachment($url, User $user)
  436. {
  437. // Fetch the attachment...
  438. // WARNING: file must fit in memory here :(
  439. $body = $this->client->fetchUrl($url);
  440. // Save to a temporary file and shove it into our file-attachment space...
  441. $temp = tmpfile();
  442. fwrite($temp, $body);
  443. try {
  444. $upload = MediaFile::fromFilehandle($temp, $user->getProfile());
  445. fclose($temp);
  446. return $upload;
  447. } catch (Exception $e) {
  448. fclose($temp);
  449. throw $e;
  450. }
  451. }
  452. }