]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' of git@gitorious.org:statusnet/mainline into testing
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-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 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             $object = new ActivityObject();
154             $object->type = 'http://activitystrea.ms/schema/1.0/group';
155             $object->id = $this->uri;
156             $self = $this->localGroup();
157
158             // @fixme put a standard getAvatar() interface on groups too
159             if ($self->homepage_logo) {
160                 $object->avatar = $self->homepage_logo;
161                 $map = array('png' => 'image/png',
162                              'jpg' => 'image/jpeg',
163                              'jpeg' => 'image/jpeg',
164                              'gif' => 'image/gif');
165                 $extension = pathinfo(parse_url($object->avatar, PHP_URL_PATH), PATHINFO_EXTENSION);
166                 if (isset($map[$extension])) {
167                     // @fixme this ain't used/saved yet
168                     $object->avatarType = $map[$extension];
169                 }
170             }
171
172             $object->link = $this->uri; // @fixme accurate?
173             return $object;
174         } else {
175             return ActivityObject::fromProfile($this->localProfile());
176         }
177     }
178
179     /**
180      * Returns an XML string fragment with profile information as an
181      * Activity Streams noun object with the given element type.
182      *
183      * Assumes that 'activity' namespace has been previously defined.
184      *
185      * @fixme replace with wrappers on asActivityObject when it's got everything.
186      *
187      * @param string $element one of 'actor', 'subject', 'object', 'target'
188      * @return string
189      */
190     function asActivityNoun($element)
191     {
192         $xs = new XMLStringer(true);
193         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
194         $avatarType = 'image/png';
195         if ($this->isGroup()) {
196             $type = 'http://activitystrea.ms/schema/1.0/group';
197             $self = $this->localGroup();
198
199             // @fixme put a standard getAvatar() interface on groups too
200             if ($self->homepage_logo) {
201                 $avatarHref = $self->homepage_logo;
202                 $map = array('png' => 'image/png',
203                              'jpg' => 'image/jpeg',
204                              'jpeg' => 'image/jpeg',
205                              'gif' => 'image/gif');
206                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
207                 if (isset($map[$extension])) {
208                     $avatarType = $map[$extension];
209                 }
210             }
211         } else {
212             $type = 'http://activitystrea.ms/schema/1.0/person';
213             $self = $this->localProfile();
214             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
215             if ($avatar) {
216                   $avatarHref = $avatar->url;
217                   $avatarType = $avatar->mediatype;
218             }
219         }
220         $xs->elementStart('activity:' . $element);
221         $xs->element(
222             'activity:object-type',
223             null,
224             $type
225         );
226         $xs->element(
227             'id',
228             null,
229             $this->uri); // ?
230         $xs->element('title', null, $self->getBestName());
231
232         $xs->element(
233             'link', array(
234                 'type' => $avatarType,
235                 'href' => $avatarHref
236             ),
237             ''
238         );
239
240         $xs->elementEnd('activity:' . $element);
241
242         return $xs->getString();
243     }
244
245     /**
246      * @return boolean true if this is a remote group
247      */
248     function isGroup()
249     {
250         if ($this->profile_id && !$this->group_id) {
251             return false;
252         } else if ($this->group_id && !$this->profile_id) {
253             return true;
254         } else if ($this->group_id && $this->profile_id) {
255             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
256         } else {
257             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
258         }
259     }
260
261     /**
262      * Subscribe a local user to this remote user.
263      * PuSH subscription will be started if necessary, and we'll
264      * send a Salmon notification to the remote server if available
265      * notifying them of the sub.
266      *
267      * @param User $user
268      * @return boolean success
269      * @throws FeedException
270      */
271     public function subscribeLocalToRemote(User $user)
272     {
273         if ($this->isGroup()) {
274             throw new ServerException("Can't subscribe to a remote group");
275         }
276
277         if ($this->subscribe()) {
278             if ($user->subscribeTo($this->localProfile())) {
279                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
280                 return true;
281             }
282         }
283         return false;
284     }
285
286     /**
287      * Mark this remote profile as subscribing to the given local user,
288      * and send appropriate notifications to the user.
289      *
290      * This will generally be in response to a subscription notification
291      * from a foreign site to our local Salmon response channel.
292      *
293      * @param User $user
294      * @return boolean success
295      */
296     public function subscribeRemoteToLocal(User $user)
297     {
298         if ($this->isGroup()) {
299             throw new ServerException("Remote groups can't subscribe to local users");
300         }
301
302         Subscription::start($this->localProfile(), $user->getProfile());
303
304         return true;
305     }
306
307     /**
308      * Send a subscription request to the hub for this feed.
309      * The hub will later send us a confirmation POST to /main/push/callback.
310      *
311      * @return bool true on success, false on failure
312      * @throws ServerException if feed state is not valid
313      */
314     public function subscribe()
315     {
316         $feedsub = FeedSub::ensureFeed($this->feeduri);
317         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
318             return true;
319         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
320             return $feedsub->subscribe();
321         } else if ('unsubscribe') {
322             throw new FeedSubException("Unsub is pending, can't subscribe...");
323         }
324     }
325
326     /**
327      * Send a PuSH unsubscription request to the hub for this feed.
328      * The hub will later send us a confirmation POST to /main/push/callback.
329      *
330      * @return bool true on success, false on failure
331      * @throws ServerException if feed state is not valid
332      */
333     public function unsubscribe() {
334         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
335         if (!$feedsub) {
336             return true;
337         }
338         if ($feedsub->sub_state == 'active') {
339             return $feedsub->unsubscribe();
340         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
341             return true;
342         } else if ($feedsub->sub_state == 'subscribe') {
343             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
344         }
345     }
346
347     /**
348      * Check if this remote profile has any active local subscriptions, and
349      * if not drop the PuSH subscription feed.
350      *
351      * @return boolean
352      */
353     public function garbageCollect()
354     {
355         if ($this->isGroup()) {
356             $members = $this->localGroup()->getMembers(0, 1);
357             $count = $members->N;
358         } else {
359             $count = $this->localProfile()->subscriberCount();
360         }
361         if ($count == 0) {
362             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
363             $this->unsubscribe();
364             return true;
365         } else {
366             return false;
367         }
368     }
369
370     /**
371      * Send an Activity Streams notification to the remote Salmon endpoint,
372      * if so configured.
373      *
374      * @param Profile $actor  Actor who did the activity
375      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
376      * @param Object  $object object of the action; must define asActivityNoun($tag)
377      */
378     public function notify($actor, $verb, $object=null)
379     {
380         if (!($actor instanceof Profile)) {
381             $type = gettype($actor);
382             if ($type == 'object') {
383                 $type = get_class($actor);
384             }
385             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
386         }
387         if ($object == null) {
388             $object = $this;
389         }
390         if ($this->salmonuri) {
391
392             $text = 'update';
393             $id = TagURI::mint('%s:%s:%s',
394                                $verb,
395                                $actor->getURI(),
396                                common_date_iso8601(time()));
397
398             // @fixme consolidate all these NS settings somewhere
399             $attributes = array('xmlns' => Activity::ATOM,
400                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
401                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
402                                 'xmlns:georss' => 'http://www.georss.org/georss',
403                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
404                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
405                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
406
407             $entry = new XMLStringer();
408             $entry->elementStart('entry', $attributes);
409             $entry->element('id', null, $id);
410             $entry->element('title', null, $text);
411             $entry->element('summary', null, $text);
412             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
413
414             $entry->element('activity:verb', null, $verb);
415             $entry->raw($actor->asAtomAuthor());
416             $entry->raw($actor->asActivityActor());
417             $entry->raw($object->asActivityNoun('object'));
418             $entry->elementEnd('entry');
419
420             $xml = $entry->getString();
421             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
422
423             $salmon = new Salmon(); // ?
424             return $salmon->post($this->salmonuri, $xml);
425         }
426         return false;
427     }
428
429     /**
430      * Send a Salmon notification ping immediately, and confirm that we got
431      * an acceptable response from the remote site.
432      *
433      * @param mixed $entry XML string, Notice, or Activity
434      * @return boolean success
435      */
436     public function notifyActivity($entry)
437     {
438         if ($this->salmonuri) {
439             $salmon = new Salmon();
440             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry));
441         }
442
443         return false;
444     }
445
446     /**
447      * Queue a Salmon notification for later. If queues are disabled we'll
448      * send immediately but won't get the return value.
449      *
450      * @param mixed $entry XML string, Notice, or Activity
451      * @return boolean success
452      */
453     public function notifyDeferred($entry)
454     {
455         if ($this->salmonuri) {
456             $data = array('salmonuri' => $this->salmonuri,
457                           'entry' => $this->notifyPrepXml($entry));
458
459             $qm = QueueManager::get();
460             return $qm->enqueue($data, 'salmon');
461         }
462
463         return false;
464     }
465
466     protected function notifyPrepXml($entry)
467     {
468         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
469         if (is_string($entry)) {
470             return $entry;
471         } else if ($entry instanceof Activity) {
472             return $preamble . $entry->asString(true);
473         } else if ($entry instanceof Notice) {
474             return $preamble . $entry->asAtomEntry(true, true);
475         } else {
476             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
477         }
478     }
479
480     function getBestName()
481     {
482         if ($this->isGroup()) {
483             return $this->localGroup()->getBestName();
484         } else {
485             return $this->localProfile()->getBestName();
486         }
487     }
488
489     function atomFeed($actor)
490     {
491         $feed = new Atom10Feed();
492         // @fixme should these be set up somewhere else?
493         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
494         $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
495         $feed->addNamespace('georss', 'http://www.georss.org/georss');
496         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
497
498         $taguribase = common_config('integration', 'taguri');
499         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
500
501         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
502         $feed->setUpdated(time());
503         $feed->setPublished(time());
504
505         $feed->addLink(common_local_url('ApiTimelineUser',
506                                         array('id' => $actor->id,
507                                               'type' => 'atom')),
508                        array('rel' => 'self',
509                              'type' => 'application/atom+xml'));
510
511         $feed->addLink(common_local_url('userbyid',
512                                         array('id' => $actor->id)),
513                        array('rel' => 'alternate',
514                              'type' => 'text/html'));
515
516         return $feed;
517     }
518
519     /**
520      * Read and post notices for updates from the feed.
521      * Currently assumes that all items in the feed are new,
522      * coming from a PuSH hub.
523      *
524      * @param DOMDocument $feed
525      */
526     public function processFeed($feed, $source)
527     {
528         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
529         if ($entries->length == 0) {
530             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
531             return;
532         }
533
534         for ($i = 0; $i < $entries->length; $i++) {
535             $entry = $entries->item($i);
536             $this->processEntry($entry, $feed, $source);
537         }
538     }
539
540     /**
541      * Process a posted entry from this feed source.
542      *
543      * @param DOMElement $entry
544      * @param DOMElement $feed for context
545      */
546     public function processEntry($entry, $feed, $source)
547     {
548         $activity = new Activity($entry, $feed);
549
550         if ($activity->verb == ActivityVerb::POST) {
551             $this->processPost($activity, $source);
552         } else {
553             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
554         }
555     }
556
557     /**
558      * Process an incoming post activity from this remote feed.
559      * @param Activity $activity
560      * @param string $method 'push' or 'salmon'
561      * @return mixed saved Notice or false
562      * @fixme break up this function, it's getting nasty long
563      */
564     public function processPost($activity, $method)
565     {
566         if ($this->isGroup()) {
567             // A group feed will contain posts from multiple authors.
568             // @fixme validate these profiles in some way!
569             $oprofile = self::ensureActorProfile($activity);
570             if ($oprofile->isGroup()) {
571                 // Groups can't post notices in StatusNet.
572                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
573                 return false;
574             }
575         } else {
576             // Individual user feeds may contain only posts from themselves.
577             // Authorship is validated against the profile URI on upper layers,
578             // through PuSH setup or Salmon signature checks.
579             $actorUri = self::getActorProfileURI($activity);
580             if ($actorUri == $this->uri) {
581                 // Check if profile info has changed and update it
582                 $this->updateFromActivityObject($activity->actor);
583             } else {
584                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
585                 return false;
586             }
587             $oprofile = $this;
588         }
589
590         // The id URI will be used as a unique identifier for for the notice,
591         // protecting against duplicate saves. It isn't required to be a URL;
592         // tag: URIs for instance are found in Google Buzz feeds.
593         $sourceUri = $activity->object->id;
594         $dupe = Notice::staticGet('uri', $sourceUri);
595         if ($dupe) {
596             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
597             return false;
598         }
599
600         // We'll also want to save a web link to the original notice, if provided.
601         $sourceUrl = null;
602         if ($activity->object->link) {
603             $sourceUrl = $activity->object->link;
604         } else if ($activity->link) {
605             $sourceUrl = $activity->link;
606         } else if (preg_match('!^https?://!', $activity->object->id)) {
607             $sourceUrl = $activity->object->id;
608         }
609
610         // Get (safe!) HTML and text versions of the content
611         $rendered = $this->purify($activity->object->content);
612         $content = html_entity_decode(strip_tags($rendered));
613
614         $shortened = common_shorten_links($content);
615
616         // If it's too long, try using the summary, and make the
617         // HTML an attachment.
618
619         $attachment = null;
620
621         if (Notice::contentTooLong($shortened)) {
622             $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
623             $summary = $activity->object->summary;
624             if (empty($summary)) {
625                 $summary = $content;
626             }
627             $shortSummary = common_shorten_links($summary);
628             if (Notice::contentTooLong($shortSummary)) {
629                 $url = common_shorten_url(common_local_url('attachment',
630                                                            array('attachment' => $attachment->id)));
631                 $shortSummary = substr($shortSummary,
632                                        0,
633                                        Notice::maxContent() - (mb_strlen($url) + 2));
634                 $shortSummary .= '… ' . $url;
635                 $content = $shortSummary;
636                 $rendered = common_render_text($content);
637             }
638         }
639
640         $options = array('is_local' => Notice::REMOTE_OMB,
641                         'url' => $sourceUrl,
642                         'uri' => $sourceUri,
643                         'rendered' => $rendered,
644                         'replies' => array(),
645                         'groups' => array(),
646                         'tags' => array());
647
648         // Check for optional attributes...
649
650         if (!empty($activity->time)) {
651             $options['created'] = common_sql_date($activity->time);
652         }
653
654         if ($activity->context) {
655             // Any individual or group attn: targets?
656             $replies = $activity->context->attention;
657             $options['groups'] = $this->filterReplies($oprofile, $replies);
658             $options['replies'] = $replies;
659
660             // Maintain direct reply associations
661             // @fixme what about conversation ID?
662             if (!empty($activity->context->replyToID)) {
663                 $orig = Notice::staticGet('uri',
664                                           $activity->context->replyToID);
665                 if (!empty($orig)) {
666                     $options['reply_to'] = $orig->id;
667                 }
668             }
669
670             $location = $activity->context->location;
671             if ($location) {
672                 $options['lat'] = $location->lat;
673                 $options['lon'] = $location->lon;
674                 if ($location->location_id) {
675                     $options['location_ns'] = $location->location_ns;
676                     $options['location_id'] = $location->location_id;
677                 }
678             }
679         }
680
681         // Atom categories <-> hashtags
682         foreach ($activity->categories as $cat) {
683             if ($cat->term) {
684                 $term = common_canonical_tag($cat->term);
685                 if ($term) {
686                     $options['tags'][] = $term;
687                 }
688             }
689         }
690
691         try {
692             $saved = Notice::saveNew($oprofile->profile_id,
693                                      $content,
694                                      'ostatus',
695                                      $options);
696             if ($saved) {
697                 Ostatus_source::saveNew($saved, $this, $method);
698                 if (!empty($attachment)) {
699                     File_to_post::processNew($attachment->id, $saved->id);
700                 }
701             }
702         } catch (Exception $e) {
703             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
704             throw $e;
705         }
706         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
707         return $saved;
708     }
709
710     /**
711      * Clean up HTML
712      */
713     protected function purify($html)
714     {
715         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
716         $config = array('safe' => 1);
717         return htmLawed($html, $config);
718     }
719
720     /**
721      * Filters a list of recipient ID URIs to just those for local delivery.
722      * @param Ostatus_profile local profile of sender
723      * @param array in/out &$attention_uris set of URIs, will be pruned on output
724      * @return array of group IDs
725      */
726     protected function filterReplies($sender, &$attention_uris)
727     {
728         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
729         $groups = array();
730         $replies = array();
731         foreach ($attention_uris as $recipient) {
732             // Is the recipient a local user?
733             $user = User::staticGet('uri', $recipient);
734             if ($user) {
735                 // @fixme sender verification, spam etc?
736                 $replies[] = $recipient;
737                 continue;
738             }
739
740             // Is the recipient a remote group?
741             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
742             if ($oprofile) {
743                 if ($oprofile->isGroup()) {
744                     // Deliver to local members of this remote group.
745                     // @fixme sender verification?
746                     $groups[] = $oprofile->group_id;
747                 } else {
748                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
749                 }
750                 continue;
751             }
752
753             // Is the recipient a local group?
754             // @fixme we need a uri on user_group
755             // $group = User_group::staticGet('uri', $recipient);
756             $template = common_local_url('groupbyid', array('id' => '31337'));
757             $template = preg_quote($template, '/');
758             $template = str_replace('31337', '(\d+)', $template);
759             if (preg_match("/$template/", $recipient, $matches)) {
760                 $id = $matches[1];
761                 $group = User_group::staticGet('id', $id);
762                 if ($group) {
763                     // Deliver to all members of this local group if allowed.
764                     $profile = $sender->localProfile();
765                     if ($profile->isMember($group)) {
766                         $groups[] = $group->id;
767                     } else {
768                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
769                     }
770                     continue;
771                 } else {
772                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
773                 }
774             }
775
776             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
777
778         }
779         $attention_uris = $replies;
780         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
781         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
782         return $groups;
783     }
784
785     /**
786      * @param string $profile_url
787      * @return Ostatus_profile
788      * @throws FeedSubException
789      */
790     public static function ensureProfile($profile_uri, $hints=array())
791     {
792         // Get the canonical feed URI and check it
793         $discover = new FeedDiscovery();
794         $feeduri = $discover->discoverFromURL($profile_uri);
795
796         //$feedsub = FeedSub::ensureFeed($feeduri, $discover->feed);
797         $huburi = $discover->getAtomLink('hub');
798         $salmonuri = $discover->getAtomLink('salmon');
799
800         if (!$huburi) {
801             // We can only deal with folks with a PuSH hub
802             throw new FeedSubNoHubException();
803         }
804
805         // Try to get a profile from the feed activity:subject
806
807         $feedEl = $discover->feed->documentElement;
808
809         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
810
811         if (!empty($subject)) {
812             $subjObject = new ActivityObject($subject);
813             return self::ensureActivityObjectProfile($subjObject, $feeduri, $salmonuri, $hints);
814         }
815
816         // Otherwise, try the feed author
817
818         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
819
820         if (!empty($author)) {
821             $authorObject = new ActivityObject($author);
822             return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
823         }
824
825         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
826         // entries.
827
828         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
829
830         if (!empty($entries) && $entries->length > 0) {
831
832             $entry = $entries->item(0);
833
834             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
835
836             if (!empty($actor)) {
837                 $actorObject = new ActivityObject($actor);
838                 return self::ensureActivityObjectProfile($actorObject, $feeduri, $salmonuri, $hints);
839
840             }
841
842             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
843
844             if (!empty($author)) {
845                 $authorObject = new ActivityObject($author);
846                 return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
847             }
848         }
849
850         // XXX: make some educated guesses here
851
852         throw new FeedSubException("Can't find enough profile information to make a feed.");
853     }
854
855     /**
856      *
857      * Download and update given avatar image
858      * @param string $url
859      * @throws Exception in various failure cases
860      */
861     protected function updateAvatar($url)
862     {
863         if ($url == $this->avatar) {
864             // We've already got this one.
865             return;
866         }
867
868         if ($this->isGroup()) {
869             $self = $this->localGroup();
870         } else {
871             $self = $this->localProfile();
872         }
873         if (!$self) {
874             throw new ServerException(sprintf(
875                 _m("Tried to update avatar for unsaved remote profile %s"),
876                 $this->uri));
877         }
878
879         // @fixme this should be better encapsulated
880         // ripped from oauthstore.php (for old OMB client)
881         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
882         if (!copy($url, $temp_filename)) {
883             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
884         }
885
886         if ($this->isGroup()) {
887             $id = $this->group_id;
888         } else {
889             $id = $this->profile_id;
890         }
891         // @fixme should we be using different ids?
892         $imagefile = new ImageFile($id, $temp_filename);
893         $filename = Avatar::filename($id,
894                                      image_type_to_extension($imagefile->type),
895                                      null,
896                                      common_timestamp());
897         rename($temp_filename, Avatar::path($filename));
898         $self->setOriginal($filename);
899
900         $orig = clone($this);
901         $this->avatar = $url;
902         $this->update($orig);
903     }
904
905     /**
906      * Pull avatar URL from ActivityObject or profile hints
907      *
908      * @param ActivityObject $object
909      * @param array $hints
910      * @return mixed URL string or false
911      */
912
913     protected static function getActivityObjectAvatar($object, $hints=array())
914     {
915         if ($object->avatar) {
916             return $object->avatar;
917         } else if (array_key_exists('avatar', $hints)) {
918             return $hints['avatar'];
919         }
920         return false;
921     }
922
923     /**
924      * Get an appropriate avatar image source URL, if available.
925      *
926      * @param ActivityObject $actor
927      * @param DOMElement $feed
928      * @return string
929      */
930
931     protected static function getAvatar($actor, $feed)
932     {
933         $url = '';
934         $icon = '';
935         if ($actor->avatar) {
936             $url = trim($actor->avatar);
937         }
938         if (!$url) {
939             // Check <atom:logo> and <atom:icon> on the feed
940             $els = $feed->childNodes();
941             if ($els && $els->length) {
942                 for ($i = 0; $i < $els->length; $i++) {
943                     $el = $els->item($i);
944                     if ($el->namespaceURI == Activity::ATOM) {
945                         if (empty($url) && $el->localName == 'logo') {
946                             $url = trim($el->textContent);
947                             break;
948                         }
949                         if (empty($icon) && $el->localName == 'icon') {
950                             // Use as a fallback
951                             $icon = trim($el->textContent);
952                         }
953                     }
954                 }
955             }
956             if ($icon && !$url) {
957                 $url = $icon;
958             }
959         }
960         if ($url) {
961             $opts = array('allowed_schemes' => array('http', 'https'));
962             if (Validate::uri($url, $opts)) {
963                 return $url;
964             }
965         }
966         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
967     }
968
969     /**
970      * Fetch, or build if necessary, an Ostatus_profile for the actor
971      * in a given Activity Streams activity.
972      *
973      * @param Activity $activity
974      * @param string $feeduri if we already know the canonical feed URI!
975      * @param string $salmonuri if we already know the salmon return channel URI
976      * @return Ostatus_profile
977      */
978
979     public static function ensureActorProfile($activity, $feeduri=null, $salmonuri=null)
980     {
981         return self::ensureActivityObjectProfile($activity->actor, $feeduri, $salmonuri);
982     }
983
984     public static function ensureActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
985     {
986         $profile = self::getActivityObjectProfile($object);
987         if ($profile) {
988             $profile->updateFromActivityObject($object, $hints);
989         } else {
990             $profile = self::createActivityObjectProfile($object, $feeduri, $salmonuri, $hints);
991         }
992         return $profile;
993     }
994
995     /**
996      * @param Activity $activity
997      * @return mixed matching Ostatus_profile or false if none known
998      */
999     public static function getActorProfile($activity)
1000     {
1001         return self::getActivityObjectProfile($activity->actor);
1002     }
1003
1004     protected static function getActivityObjectProfile($object)
1005     {
1006         $uri = self::getActivityObjectProfileURI($object);
1007         return Ostatus_profile::staticGet('uri', $uri);
1008     }
1009
1010     protected static function getActorProfileURI($activity)
1011     {
1012         return self::getActivityObjectProfileURI($activity->actor);
1013     }
1014
1015     /**
1016      * @param Activity $activity
1017      * @return string
1018      * @throws ServerException
1019      */
1020     protected static function getActivityObjectProfileURI($object)
1021     {
1022         $opts = array('allowed_schemes' => array('http', 'https'));
1023         if ($object->id && Validate::uri($object->id, $opts)) {
1024             return $object->id;
1025         }
1026         if ($object->link && Validate::uri($object->link, $opts)) {
1027             return $object->link;
1028         }
1029         throw new ServerException("No author ID URI found");
1030     }
1031
1032     /**
1033      * @fixme validate stuff somewhere
1034      */
1035
1036     protected static function createActorProfile($activity, $feeduri=null, $salmonuri=null)
1037     {
1038         $actor = $activity->actor;
1039
1040         self::createActivityObjectProfile($actor, $feeduri, $salmonuri);
1041     }
1042
1043     /**
1044      * Create local ostatus_profile and profile/user_group entries for
1045      * the provided remote user or group.
1046      *
1047      * @param ActivityObject $object
1048      * @param string $feeduri
1049      * @param string $salmonuri
1050      * @param array $hints
1051      *
1052      * @fixme fold $feeduri/$salmonuri into $hints
1053      * @return Ostatus_profile
1054      */
1055     protected static function createActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
1056     {
1057         $homeuri  = $object->id;
1058
1059         if (!$homeuri) {
1060             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1061             throw new ServerException("No profile URI");
1062         }
1063
1064         if (empty($feeduri)) {
1065             if (array_key_exists('feedurl', $hints)) {
1066                 $feeduri = $hints['feedurl'];
1067             }
1068         }
1069
1070         if (empty($salmonuri)) {
1071             if (array_key_exists('salmon', $hints)) {
1072                 $salmonuri = $hints['salmon'];
1073             }
1074         }
1075
1076         if (!$feeduri || !$salmonuri) {
1077             // Get the canonical feed URI and check it
1078             $discover = new FeedDiscovery();
1079             $feeduri = $discover->discoverFromURL($homeuri);
1080
1081             $huburi = $discover->getAtomLink('hub');
1082             $salmonuri = $discover->getAtomLink('salmon');
1083
1084             if (!$huburi) {
1085                 // We can only deal with folks with a PuSH hub
1086                 throw new FeedSubNoHubException();
1087             }
1088         }
1089
1090         $oprofile = new Ostatus_profile();
1091
1092         $oprofile->uri        = $homeuri;
1093         $oprofile->feeduri    = $feeduri;
1094         $oprofile->salmonuri  = $salmonuri;
1095
1096         $oprofile->created    = common_sql_now();
1097         $oprofile->modified   = common_sql_now();
1098
1099         if ($object->type == ActivityObject::PERSON) {
1100             $profile = new Profile();
1101             $profile->created = common_sql_now();
1102             self::updateProfile($profile, $object, $hints);
1103
1104             $oprofile->profile_id = $profile->insert();
1105             if (!$oprofile->profile_id) {
1106                 throw new ServerException("Can't save local profile");
1107             }
1108         } else {
1109             $group = new User_group();
1110             $group->uri = $homeuri;
1111             $group->created = common_sql_now();
1112             self::updateGroup($group, $object, $hints);
1113
1114             $oprofile->group_id = $group->insert();
1115             if (!$oprofile->group_id) {
1116                 throw new ServerException("Can't save local profile");
1117             }
1118         }
1119
1120         $ok = $oprofile->insert();
1121
1122         if ($ok) {
1123             $avatar = self::getActivityObjectAvatar($object, $hints);
1124             if ($avatar) {
1125                 $oprofile->updateAvatar($avatar);
1126             }
1127             return $oprofile;
1128         } else {
1129             throw new ServerException("Can't save OStatus profile");
1130         }
1131     }
1132
1133     /**
1134      * Save any updated profile information to our local copy.
1135      * @param ActivityObject $object
1136      * @param array $hints
1137      */
1138     public function updateFromActivityObject($object, $hints=array())
1139     {
1140         if ($this->isGroup()) {
1141             $group = $this->localGroup();
1142             self::updateGroup($group, $object, $hints);
1143         } else {
1144             $profile = $this->localProfile();
1145             self::updateProfile($profile, $object, $hints);
1146         }
1147         $avatar = self::getActivityObjectAvatar($object, $hints);
1148         if ($avatar) {
1149             $this->updateAvatar($avatar);
1150         }
1151     }
1152
1153     protected static function updateProfile($profile, $object, $hints=array())
1154     {
1155         $orig = clone($profile);
1156
1157         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1158
1159         if (!empty($object->title)) {
1160             $profile->fullname = $object->title;
1161         } else if (array_key_exists('fullname', $hints)) {
1162             $profile->fullname = $hints['fullname'];
1163         }
1164
1165         if (!empty($object->link)) {
1166             $profile->profileurl = $object->link;
1167         } else if (array_key_exists('profileurl', $hints)) {
1168             $profile->profileurl = $hints['profileurl'];
1169         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1170             $profile->profileurl = $object->id;
1171         }
1172
1173         $profile->bio      = self::getActivityObjectBio($object, $hints);
1174         $profile->location = self::getActivityObjectLocation($object, $hints);
1175         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1176
1177         if (!empty($object->geopoint)) {
1178             $location = ActivityContext::locationFromPoint($object->geopoint);
1179             if (!empty($location)) {
1180                 $profile->lat = $location->lat;
1181                 $profile->lon = $location->lon;
1182             }
1183         }
1184
1185         // @fixme tags/categories
1186         // @todo tags from categories
1187
1188         if ($profile->id) {
1189             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1190             $profile->update($orig);
1191         }
1192     }
1193
1194     protected static function updateGroup($group, $object, $hints=array())
1195     {
1196         $orig = clone($group);
1197
1198         $group->nickname = self::getActivityObjectNickname($object, $hints);
1199         $group->fullname = $object->title;
1200
1201         if (!empty($object->link)) {
1202             $group->mainpage = $object->link;
1203         } else if (array_key_exists('profileurl', $hints)) {
1204             $group->mainpage = $hints['profileurl'];
1205         }
1206
1207         // @todo tags from categories
1208         $group->description = self::getActivityObjectBio($object, $hints);
1209         $group->location = self::getActivityObjectLocation($object, $hints);
1210         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1211
1212         if ($group->id) {
1213             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1214             $group->update($orig);
1215         }
1216     }
1217
1218     protected static function getActivityObjectHomepage($object, $hints=array())
1219     {
1220         $homepage = null;
1221         $poco     = $object->poco;
1222
1223         if (!empty($poco)) {
1224             $url = $poco->getPrimaryURL();
1225             if ($url->type == 'homepage') {
1226                 $homepage = $url->value;
1227             }
1228         }
1229
1230         // @todo Try for a another PoCo URL?
1231
1232         return $homepage;
1233     }
1234
1235     protected static function getActivityObjectLocation($object, $hints=array())
1236     {
1237         $location = null;
1238
1239         if (!empty($object->poco) &&
1240             isset($object->poco->address->formatted)) {
1241             $location = $object->poco->address->formatted;
1242         } else if (array_key_exists('location', $hints)) {
1243             $location = $hints['location'];
1244         }
1245
1246         if (!empty($location)) {
1247             if (mb_strlen($location) > 255) {
1248                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1249             }
1250         }
1251
1252         // @todo Try to find location some othe way? Via goerss point?
1253
1254         return $location;
1255     }
1256
1257     protected static function getActivityObjectBio($object, $hints=array())
1258     {
1259         $bio  = null;
1260
1261         if (!empty($object->poco)) {
1262             $note = $object->poco->note;
1263         } else if (array_key_exists('bio', $hints)) {
1264             $note = $hints['bio'];
1265         }
1266
1267         if (!empty($note)) {
1268             if (Profile::bioTooLong($note)) {
1269                 // XXX: truncate ok?
1270                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1271             } else {
1272                 $bio = $note;
1273             }
1274         }
1275
1276         // @todo Try to get bio info some other way?
1277
1278         return $bio;
1279     }
1280
1281     protected static function getActivityObjectNickname($object, $hints=array())
1282     {
1283         if ($object->poco) {
1284             if (!empty($object->poco->preferredUsername)) {
1285                 return common_nicknamize($object->poco->preferredUsername);
1286             }
1287         }
1288
1289         if (!empty($object->nickname)) {
1290             return common_nicknamize($object->nickname);
1291         }
1292
1293         if (array_key_exists('nickname', $hints)) {
1294             return $hints['nickname'];
1295         }
1296
1297         // Try the definitive ID
1298
1299         $nickname = self::nicknameFromURI($object->id);
1300
1301         // Try a Webfinger if one was passed (way) down
1302
1303         if (empty($nickname)) {
1304             if (array_key_exists('webfinger', $hints)) {
1305                 $nickname = self::nicknameFromURI($hints['webfinger']);
1306             }
1307         }
1308
1309         // Try the name
1310
1311         if (empty($nickname)) {
1312             $nickname = common_nicknamize($object->title);
1313         }
1314
1315         return $nickname;
1316     }
1317
1318     protected static function nicknameFromURI($uri)
1319     {
1320         preg_match('/(\w+):/', $uri, $matches);
1321
1322         $protocol = $matches[1];
1323
1324         switch ($protocol) {
1325         case 'acct':
1326         case 'mailto':
1327             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1328                 return common_canonical_nickname($matches[1]);
1329             }
1330             return null;
1331         case 'http':
1332             return common_url_to_nickname($uri);
1333             break;
1334         default:
1335             return null;
1336         }
1337     }
1338
1339     public static function ensureWebfinger($addr)
1340     {
1341         // First, look it up
1342
1343         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1344
1345         if (!empty($oprofile)) {
1346             return $oprofile;
1347         }
1348
1349         // Now, try some discovery
1350
1351         $wf = new Webfinger();
1352
1353         $result = $wf->lookup($addr);
1354
1355         if (!$result) {
1356             return null;
1357         }
1358
1359         foreach ($result->links as $link) {
1360             switch ($link['rel']) {
1361             case Webfinger::PROFILEPAGE:
1362                 $profileUrl = $link['href'];
1363                 break;
1364             case 'salmon':
1365                 $salmonEndpoint = $link['href'];
1366                 break;
1367             case Webfinger::UPDATESFROM:
1368                 $feedUrl = $link['href'];
1369                 break;
1370             case Webfinger::HCARD:
1371                 $hcardUrl = $link['href'];
1372                 break;
1373             default:
1374                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1375                 break;
1376             }
1377         }
1378
1379         $hints = array('webfinger' => $addr,
1380                        'profileurl' => $profileUrl,
1381                        'feedurl' => $feedUrl,
1382                        'salmon' => $salmonEndpoint);
1383
1384         if (isset($hcardUrl)) {
1385             $hcardHints = self::slurpHcard($hcardUrl);
1386             // Note: Webfinger > hcard
1387             $hints = array_merge($hcardHints, $hints);
1388         }
1389
1390         // If we got a feed URL, try that
1391
1392         if (isset($feedUrl)) {
1393             try {
1394                 $oprofile = self::ensureProfile($feedUrl, $hints);
1395                 return $oprofile;
1396             } catch (Exception $e) {
1397                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1398                 // keep looking
1399             }
1400         }
1401
1402         // If we got a profile page, try that!
1403
1404         if (isset($profileUrl)) {
1405             try {
1406                 $oprofile = self::ensureProfile($profileUrl, $hints);
1407                 return $oprofile;
1408             } catch (Exception $e) {
1409                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1410                 // keep looking
1411             }
1412         }
1413
1414         // XXX: try hcard
1415         // XXX: try FOAF
1416
1417         if (isset($salmonEndpoint)) {
1418
1419             // An account URL, a salmon endpoint, and a dream? Not much to go
1420             // on, but let's give it a try
1421
1422             $uri = 'acct:'.$addr;
1423
1424             $profile = new Profile();
1425
1426             $profile->nickname = self::nicknameFromUri($uri);
1427             $profile->created  = common_sql_now();
1428
1429             if (isset($profileUrl)) {
1430                 $profile->profileurl = $profileUrl;
1431             }
1432
1433             $profile_id = $profile->insert();
1434
1435             if (!$profile_id) {
1436                 common_log_db_error($profile, 'INSERT', __FILE__);
1437                 throw new Exception("Couldn't save profile for '$addr'");
1438             }
1439
1440             $oprofile = new Ostatus_profile();
1441
1442             $oprofile->uri        = $uri;
1443             $oprofile->salmonuri  = $salmonEndpoint;
1444             $oprofile->profile_id = $profile_id;
1445             $oprofile->created    = common_sql_now();
1446
1447             if (isset($feedUrl)) {
1448                 $profile->feeduri = $feedUrl;
1449             }
1450
1451             $result = $oprofile->insert();
1452
1453             if (!$result) {
1454                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1455                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1456             }
1457
1458             return $oprofile;
1459         }
1460
1461         return null;
1462     }
1463
1464     function saveHTMLFile($title, $rendered)
1465     {
1466         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1467                          '<body><div>%s</div></body></html>',
1468                          htmlspecialchars($title),
1469                          $rendered);
1470
1471         $filename = File::filename($this->localProfile(),
1472                                    'ostatus', // ignored?
1473                                    'text/html');
1474
1475         $filepath = File::path($filename);
1476
1477         file_put_contents($filepath, $final);
1478
1479         $file = new File;
1480
1481         $file->filename = $filename;
1482         $file->url      = File::url($filename);
1483         $file->size     = filesize($filepath);
1484         $file->date     = time();
1485         $file->mimetype = 'text/html';
1486
1487         $file_id = $file->insert();
1488
1489         if ($file_id === false) {
1490             common_log_db_error($file, "INSERT", __FILE__);
1491             throw new ServerException(_('Could not store HTML content of long post as file.'));
1492         }
1493
1494         return $file;
1495     }
1496
1497     protected static function slurpHcard($url)
1498     {
1499         set_include_path(get_include_path() . PATH_SEPARATOR . INSTALLDIR . '/plugins/OStatus/extlib/hkit/');
1500         require_once('hkit.class.php');
1501
1502         $h      = new hKit;
1503
1504         // Google Buzz hcards need to be tidied. Probably others too.
1505
1506         $h->tidy_mode = 'proxy'; // 'proxy', 'exec', 'php' or 'none'
1507
1508         // Get by URL
1509         $hcards = $h->getByURL('hcard', $url);
1510
1511         if (empty($hcards)) {
1512             return array();
1513         }
1514
1515         // @fixme more intelligent guess on multi-hcard pages
1516         $hcard = $hcards[0];
1517
1518         $hints = array();
1519
1520         $hints['profileurl'] = $url;
1521
1522         if (array_key_exists('nickname', $hcard)) {
1523             $hints['nickname'] = $hcard['nickname'];
1524         }
1525
1526         if (array_key_exists('fn', $hcard)) {
1527             $hints['fullname'] = $hcard['fn'];
1528         } else if (array_key_exists('n', $hcard)) {
1529             $hints['fullname'] = implode(' ', $hcard['n']);
1530         }
1531
1532         if (array_key_exists('photo', $hcard)) {
1533             $hints['avatar'] = $hcard['photo'];
1534         }
1535
1536         if (array_key_exists('note', $hcard)) {
1537             $hints['bio'] = $hcard['note'];
1538         }
1539
1540         if (array_key_exists('adr', $hcard)) {
1541             if (is_string($hcard['adr'])) {
1542                 $hints['location'] = $hcard['adr'];
1543             } else if (is_array($hcard['adr'])) {
1544                 $hints['location'] = implode(' ', $hcard['adr']);
1545             }
1546         }
1547
1548         if (array_key_exists('url', $hcard)) {
1549             if (is_string($hcard['url'])) {
1550                 $hints['homepage'] = $hcard['url'];
1551             } else if (is_array($hcard['adr'])) {
1552                 // HACK get the last one; that's how our hcards look
1553                 $hints['homepage'] = $hcard['url'][count($hcard['url'])-1];
1554             }
1555         }
1556
1557         return $hints;
1558     }
1559 }