1<?php
2
3namespace Sabre\VObject\ITip;
4
5use Sabre\VObject\Component\VCalendar;
6use Sabre\VObject\DateTimeParser;
7use Sabre\VObject\Reader;
8use Sabre\VObject\Recur\EventIterator;
9
10/**
11 * The ITip\Broker class is a utility class that helps with processing
12 * so-called iTip messages.
13 *
14 * iTip is defined in rfc5546, stands for iCalendar Transport-Independent
15 * Interoperability Protocol, and describes the underlying mechanism for
16 * using iCalendar for scheduling for for example through email (also known as
17 * IMip) and CalDAV Scheduling.
18 *
19 * This class helps by:
20 *
21 * 1. Creating individual invites based on an iCalendar event for each
22 *    attendee.
23 * 2. Generating invite updates based on an iCalendar update. This may result
24 *    in new invites, updates and cancellations for attendees, if that list
25 *    changed.
26 * 3. On the receiving end, it can create a local iCalendar event based on
27 *    a received invite.
28 * 4. It can also process an invite update on a local event, ensuring that any
29 *    overridden properties from attendees are retained.
30 * 5. It can create a accepted or declined iTip reply based on an invite.
31 * 6. It can process a reply from an invite and update an events attendee
32 *     status based on a reply.
33 *
34 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
35 * @author Evert Pot (http://evertpot.com/)
36 * @license http://sabre.io/license/ Modified BSD License
37 */
38class Broker
39{
40    /**
41     * This setting determines whether the rules for the SCHEDULE-AGENT
42     * parameter should be followed.
43     *
44     * This is a parameter defined on ATTENDEE properties, introduced by RFC
45     * 6638. This parameter allows a caldav client to tell the server 'Don't do
46     * any scheduling operations'.
47     *
48     * If this setting is turned on, any attendees with SCHEDULE-AGENT set to
49     * CLIENT will be ignored. This is the desired behavior for a CalDAV
50     * server, but if you're writing an iTip application that doesn't deal with
51     * CalDAV, you may want to ignore this parameter.
52     *
53     * @var bool
54     */
55    public $scheduleAgentServerRules = true;
56
57    /**
58     * The broker will try during 'parseEvent' figure out whether the change
59     * was significant.
60     *
61     * It uses a few different ways to do this. One of these ways is seeing if
62     * certain properties changed values. This list of specified here.
63     *
64     * This list is taken from:
65     * * http://tools.ietf.org/html/rfc5546#section-2.1.4
66     *
67     * @var string[]
68     */
69    public $significantChangeProperties = [
70        'DTSTART',
71        'DTEND',
72        'DURATION',
73        'DUE',
74        'RRULE',
75        'RDATE',
76        'EXDATE',
77        'STATUS',
78    ];
79
80    /**
81     * This method is used to process an incoming itip message.
82     *
83     * Examples:
84     *
85     * 1. A user is an attendee to an event. The organizer sends an updated
86     * meeting using a new iTip message with METHOD:REQUEST. This function
87     * will process the message and update the attendee's event accordingly.
88     *
89     * 2. The organizer cancelled the event using METHOD:CANCEL. We will update
90     * the users event to state STATUS:CANCELLED.
91     *
92     * 3. An attendee sent a reply to an invite using METHOD:REPLY. We can
93     * update the organizers event to update the ATTENDEE with its correct
94     * PARTSTAT.
95     *
96     * The $existingObject is updated in-place. If there is no existing object
97     * (because it's a new invite for example) a new object will be created.
98     *
99     * If an existing object does not exist, and the method was CANCEL or
100     * REPLY, the message effectively gets ignored, and no 'existingObject'
101     * will be created.
102     *
103     * The updated $existingObject is also returned from this function.
104     *
105     * If the iTip message was not supported, we will always return false.
106     *
107     * @param VCalendar $existingObject
108     *
109     * @return VCalendar|null
110     */
111    public function processMessage(Message $itipMessage, VCalendar $existingObject = null)
112    {
113        // We only support events at the moment.
114        if ('VEVENT' !== $itipMessage->component) {
115            return false;
116        }
117
118        switch ($itipMessage->method) {
119            case 'REQUEST':
120                return $this->processMessageRequest($itipMessage, $existingObject);
121
122            case 'CANCEL':
123                return $this->processMessageCancel($itipMessage, $existingObject);
124
125            case 'REPLY':
126                return $this->processMessageReply($itipMessage, $existingObject);
127
128            default:
129                // Unsupported iTip message
130                return;
131        }
132
133        return $existingObject;
134    }
135
136    /**
137     * This function parses a VCALENDAR object and figure out if any messages
138     * need to be sent.
139     *
140     * A VCALENDAR object will be created from the perspective of either an
141     * attendee, or an organizer. You must pass a string identifying the
142     * current user, so we can figure out who in the list of attendees or the
143     * organizer we are sending this message on behalf of.
144     *
145     * It's possible to specify the current user as an array, in case the user
146     * has more than one identifying href (such as multiple emails).
147     *
148     * It $oldCalendar is specified, it is assumed that the operation is
149     * updating an existing event, which means that we need to look at the
150     * differences between events, and potentially send old attendees
151     * cancellations, and current attendees updates.
152     *
153     * If $calendar is null, but $oldCalendar is specified, we treat the
154     * operation as if the user has deleted an event. If the user was an
155     * organizer, this means that we need to send cancellation notices to
156     * people. If the user was an attendee, we need to make sure that the
157     * organizer gets the 'declined' message.
158     *
159     * @param VCalendar|string $calendar
160     * @param string|array     $userHref
161     * @param VCalendar|string $oldCalendar
162     *
163     * @return array
164     */
165    public function parseEvent($calendar = null, $userHref, $oldCalendar = null)
166    {
167        if ($oldCalendar) {
168            if (is_string($oldCalendar)) {
169                $oldCalendar = Reader::read($oldCalendar);
170            }
171            if (!isset($oldCalendar->VEVENT)) {
172                // We only support events at the moment
173                return [];
174            }
175
176            $oldEventInfo = $this->parseEventInfo($oldCalendar);
177        } else {
178            $oldEventInfo = [
179                'organizer' => null,
180                'significantChangeHash' => '',
181                'attendees' => [],
182            ];
183        }
184
185        $userHref = (array) $userHref;
186
187        if (!is_null($calendar)) {
188            if (is_string($calendar)) {
189                $calendar = Reader::read($calendar);
190            }
191            if (!isset($calendar->VEVENT)) {
192                // We only support events at the moment
193                return [];
194            }
195            $eventInfo = $this->parseEventInfo($calendar);
196            if (!$eventInfo['attendees'] && !$oldEventInfo['attendees']) {
197                // If there were no attendees on either side of the equation,
198                // we don't need to do anything.
199                return [];
200            }
201            if (!$eventInfo['organizer'] && !$oldEventInfo['organizer']) {
202                // There was no organizer before or after the change.
203                return [];
204            }
205
206            $baseCalendar = $calendar;
207
208            // If the new object didn't have an organizer, the organizer
209            // changed the object from a scheduling object to a non-scheduling
210            // object. We just copy the info from the old object.
211            if (!$eventInfo['organizer'] && $oldEventInfo['organizer']) {
212                $eventInfo['organizer'] = $oldEventInfo['organizer'];
213                $eventInfo['organizerName'] = $oldEventInfo['organizerName'];
214            }
215        } else {
216            // The calendar object got deleted, we need to process this as a
217            // cancellation / decline.
218            if (!$oldCalendar) {
219                // No old and no new calendar, there's no thing to do.
220                return [];
221            }
222
223            $eventInfo = $oldEventInfo;
224
225            if (in_array($eventInfo['organizer'], $userHref)) {
226                // This is an organizer deleting the event.
227                $eventInfo['attendees'] = [];
228                // Increasing the sequence, but only if the organizer deleted
229                // the event.
230                ++$eventInfo['sequence'];
231            } else {
232                // This is an attendee deleting the event.
233                foreach ($eventInfo['attendees'] as $key => $attendee) {
234                    if (in_array($attendee['href'], $userHref)) {
235                        $eventInfo['attendees'][$key]['instances'] = ['master' => ['id' => 'master', 'partstat' => 'DECLINED'],
236                        ];
237                    }
238                }
239            }
240            $baseCalendar = $oldCalendar;
241        }
242
243        if (in_array($eventInfo['organizer'], $userHref)) {
244            return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo);
245        } elseif ($oldCalendar) {
246            // We need to figure out if the user is an attendee, but we're only
247            // doing so if there's an oldCalendar, because we only want to
248            // process updates, not creation of new events.
249            foreach ($eventInfo['attendees'] as $attendee) {
250                if (in_array($attendee['href'], $userHref)) {
251                    return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']);
252                }
253            }
254        }
255
256        return [];
257    }
258
259    /**
260     * Processes incoming REQUEST messages.
261     *
262     * This is message from an organizer, and is either a new event
263     * invite, or an update to an existing one.
264     *
265     * @param VCalendar $existingObject
266     *
267     * @return VCalendar|null
268     */
269    protected function processMessageRequest(Message $itipMessage, VCalendar $existingObject = null)
270    {
271        if (!$existingObject) {
272            // This is a new invite, and we're just going to copy over
273            // all the components from the invite.
274            $existingObject = new VCalendar();
275            foreach ($itipMessage->message->getComponents() as $component) {
276                $existingObject->add(clone $component);
277            }
278        } else {
279            // We need to update an existing object with all the new
280            // information. We can just remove all existing components
281            // and create new ones.
282            foreach ($existingObject->getComponents() as $component) {
283                $existingObject->remove($component);
284            }
285            foreach ($itipMessage->message->getComponents() as $component) {
286                $existingObject->add(clone $component);
287            }
288        }
289
290        return $existingObject;
291    }
292
293    /**
294     * Processes incoming CANCEL messages.
295     *
296     * This is a message from an organizer, and means that either an
297     * attendee got removed from an event, or an event got cancelled
298     * altogether.
299     *
300     * @param VCalendar $existingObject
301     *
302     * @return VCalendar|null
303     */
304    protected function processMessageCancel(Message $itipMessage, VCalendar $existingObject = null)
305    {
306        if (!$existingObject) {
307            // The event didn't exist in the first place, so we're just
308            // ignoring this message.
309        } else {
310            foreach ($existingObject->VEVENT as $vevent) {
311                $vevent->STATUS = 'CANCELLED';
312                $vevent->SEQUENCE = $itipMessage->sequence;
313            }
314        }
315
316        return $existingObject;
317    }
318
319    /**
320     * Processes incoming REPLY messages.
321     *
322     * The message is a reply. This is for example an attendee telling
323     * an organizer he accepted the invite, or declined it.
324     *
325     * @param VCalendar $existingObject
326     *
327     * @return VCalendar|null
328     */
329    protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null)
330    {
331        // A reply can only be processed based on an existing object.
332        // If the object is not available, the reply is ignored.
333        if (!$existingObject) {
334            return;
335        }
336        $instances = [];
337        $requestStatus = '2.0';
338
339        // Finding all the instances the attendee replied to.
340        foreach ($itipMessage->message->VEVENT as $vevent) {
341            $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
342            $attendee = $vevent->ATTENDEE;
343            $instances[$recurId] = $attendee['PARTSTAT']->getValue();
344            if (isset($vevent->{'REQUEST-STATUS'})) {
345                $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
346                list($requestStatus) = explode(';', $requestStatus);
347            }
348        }
349
350        // Now we need to loop through the original organizer event, to find
351        // all the instances where we have a reply for.
352        $masterObject = null;
353        foreach ($existingObject->VEVENT as $vevent) {
354            $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
355            if ('master' === $recurId) {
356                $masterObject = $vevent;
357            }
358            if (isset($instances[$recurId])) {
359                $attendeeFound = false;
360                if (isset($vevent->ATTENDEE)) {
361                    foreach ($vevent->ATTENDEE as $attendee) {
362                        if ($attendee->getValue() === $itipMessage->sender) {
363                            $attendeeFound = true;
364                            $attendee['PARTSTAT'] = $instances[$recurId];
365                            $attendee['SCHEDULE-STATUS'] = $requestStatus;
366                            // Un-setting the RSVP status, because we now know
367                            // that the attendee already replied.
368                            unset($attendee['RSVP']);
369                            break;
370                        }
371                    }
372                }
373                if (!$attendeeFound) {
374                    // Adding a new attendee. The iTip documentation calls this
375                    // a party crasher.
376                    $attendee = $vevent->add('ATTENDEE', $itipMessage->sender, [
377                        'PARTSTAT' => $instances[$recurId],
378                    ]);
379                    if ($itipMessage->senderName) {
380                        $attendee['CN'] = $itipMessage->senderName;
381                    }
382                }
383                unset($instances[$recurId]);
384            }
385        }
386
387        if (!$masterObject) {
388            // No master object, we can't add new instances.
389            return;
390        }
391        // If we got replies to instances that did not exist in the
392        // original list, it means that new exceptions must be created.
393        foreach ($instances as $recurId => $partstat) {
394            $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
395            $found = false;
396            $iterations = 1000;
397            do {
398                $newObject = $recurrenceIterator->getEventObject();
399                $recurrenceIterator->next();
400
401                if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue() === $recurId) {
402                    $found = true;
403                }
404                --$iterations;
405            } while ($recurrenceIterator->valid() && !$found && $iterations);
406
407            // Invalid recurrence id. Skipping this object.
408            if (!$found) {
409                continue;
410            }
411
412            unset(
413                $newObject->RRULE,
414                $newObject->EXDATE,
415                $newObject->RDATE
416            );
417            $attendeeFound = false;
418            if (isset($newObject->ATTENDEE)) {
419                foreach ($newObject->ATTENDEE as $attendee) {
420                    if ($attendee->getValue() === $itipMessage->sender) {
421                        $attendeeFound = true;
422                        $attendee['PARTSTAT'] = $partstat;
423                        break;
424                    }
425                }
426            }
427            if (!$attendeeFound) {
428                // Adding a new attendee
429                $attendee = $newObject->add('ATTENDEE', $itipMessage->sender, [
430                    'PARTSTAT' => $partstat,
431                ]);
432                if ($itipMessage->senderName) {
433                    $attendee['CN'] = $itipMessage->senderName;
434                }
435            }
436            $existingObject->add($newObject);
437        }
438
439        return $existingObject;
440    }
441
442    /**
443     * This method is used in cases where an event got updated, and we
444     * potentially need to send emails to attendees to let them know of updates
445     * in the events.
446     *
447     * We will detect which attendees got added, which got removed and create
448     * specific messages for these situations.
449     *
450     * @return array
451     */
452    protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo)
453    {
454        // Merging attendee lists.
455        $attendees = [];
456        foreach ($oldEventInfo['attendees'] as $attendee) {
457            $attendees[$attendee['href']] = [
458                'href' => $attendee['href'],
459                'oldInstances' => $attendee['instances'],
460                'newInstances' => [],
461                'name' => $attendee['name'],
462                'forceSend' => null,
463            ];
464        }
465        foreach ($eventInfo['attendees'] as $attendee) {
466            if (isset($attendees[$attendee['href']])) {
467                $attendees[$attendee['href']]['name'] = $attendee['name'];
468                $attendees[$attendee['href']]['newInstances'] = $attendee['instances'];
469                $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend'];
470            } else {
471                $attendees[$attendee['href']] = [
472                    'href' => $attendee['href'],
473                    'oldInstances' => [],
474                    'newInstances' => $attendee['instances'],
475                    'name' => $attendee['name'],
476                    'forceSend' => $attendee['forceSend'],
477                ];
478            }
479        }
480
481        $messages = [];
482
483        foreach ($attendees as $attendee) {
484            // An organizer can also be an attendee. We should not generate any
485            // messages for those.
486            if ($attendee['href'] === $eventInfo['organizer']) {
487                continue;
488            }
489
490            $message = new Message();
491            $message->uid = $eventInfo['uid'];
492            $message->component = 'VEVENT';
493            $message->sequence = $eventInfo['sequence'];
494            $message->sender = $eventInfo['organizer'];
495            $message->senderName = $eventInfo['organizerName'];
496            $message->recipient = $attendee['href'];
497            $message->recipientName = $attendee['name'];
498
499            // Creating the new iCalendar body.
500            $icalMsg = new VCalendar();
501
502            foreach ($calendar->select('VTIMEZONE') as $timezone) {
503                $icalMsg->add(clone $timezone);
504            }
505
506            if (!$attendee['newInstances']) {
507                // If there are no instances the attendee is a part of, it
508                // means the attendee was removed and we need to send him a
509                // CANCEL.
510                $message->method = 'CANCEL';
511
512                $icalMsg->METHOD = $message->method;
513
514                $event = $icalMsg->add('VEVENT', [
515                    'UID' => $message->uid,
516                    'SEQUENCE' => $message->sequence,
517                    'DTSTAMP' => gmdate('Ymd\\THis\\Z'),
518                ]);
519                if (isset($calendar->VEVENT->SUMMARY)) {
520                    $event->add('SUMMARY', $calendar->VEVENT->SUMMARY->getValue());
521                }
522                $event->add(clone $calendar->VEVENT->DTSTART);
523                if (isset($calendar->VEVENT->DTEND)) {
524                    $event->add(clone $calendar->VEVENT->DTEND);
525                } elseif (isset($calendar->VEVENT->DURATION)) {
526                    $event->add(clone $calendar->VEVENT->DURATION);
527                }
528                $org = $event->add('ORGANIZER', $eventInfo['organizer']);
529                if ($eventInfo['organizerName']) {
530                    $org['CN'] = $eventInfo['organizerName'];
531                }
532                $event->add('ATTENDEE', $attendee['href'], [
533                    'CN' => $attendee['name'],
534                ]);
535                $message->significantChange = true;
536            } else {
537                // The attendee gets the updated event body
538                $message->method = 'REQUEST';
539
540                $icalMsg->METHOD = $message->method;
541
542                // We need to find out that this change is significant. If it's
543                // not, systems may opt to not send messages.
544                //
545                // We do this based on the 'significantChangeHash' which is
546                // some value that changes if there's a certain set of
547                // properties changed in the event, or simply if there's a
548                // difference in instances that the attendee is invited to.
549
550                $message->significantChange =
551                    'REQUEST' === $attendee['forceSend'] ||
552                    array_keys($attendee['oldInstances']) != array_keys($attendee['newInstances']) ||
553                    $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash'];
554
555                foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) {
556                    $currentEvent = clone $eventInfo['instances'][$instanceId];
557                    if ('master' === $instanceId) {
558                        // We need to find a list of events that the attendee
559                        // is not a part of to add to the list of exceptions.
560                        $exceptions = [];
561                        foreach ($eventInfo['instances'] as $instanceId => $vevent) {
562                            if (!isset($attendee['newInstances'][$instanceId])) {
563                                $exceptions[] = $instanceId;
564                            }
565                        }
566
567                        // If there were exceptions, we need to add it to an
568                        // existing EXDATE property, if it exists.
569                        if ($exceptions) {
570                            if (isset($currentEvent->EXDATE)) {
571                                $currentEvent->EXDATE->setParts(array_merge(
572                                    $currentEvent->EXDATE->getParts(),
573                                    $exceptions
574                                ));
575                            } else {
576                                $currentEvent->EXDATE = $exceptions;
577                            }
578                        }
579
580                        // Cleaning up any scheduling information that
581                        // shouldn't be sent along.
582                        unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
583                        unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
584
585                        foreach ($currentEvent->ATTENDEE as $attendee) {
586                            unset($attendee['SCHEDULE-FORCE-SEND']);
587                            unset($attendee['SCHEDULE-STATUS']);
588
589                            // We're adding PARTSTAT=NEEDS-ACTION to ensure that
590                            // iOS shows an "Inbox Item"
591                            if (!isset($attendee['PARTSTAT'])) {
592                                $attendee['PARTSTAT'] = 'NEEDS-ACTION';
593                            }
594                        }
595                    }
596
597                    $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z');
598                    $icalMsg->add($currentEvent);
599                }
600            }
601
602            $message->message = $icalMsg;
603            $messages[] = $message;
604        }
605
606        return $messages;
607    }
608
609    /**
610     * Parse an event update for an attendee.
611     *
612     * This function figures out if we need to send a reply to an organizer.
613     *
614     * @param string $attendee
615     *
616     * @return Message[]
617     */
618    protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee)
619    {
620        if ($this->scheduleAgentServerRules && 'CLIENT' === $eventInfo['organizerScheduleAgent']) {
621            return [];
622        }
623
624        // Don't bother generating messages for events that have already been
625        // cancelled.
626        if ('CANCELLED' === $eventInfo['status']) {
627            return [];
628        }
629
630        $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ?
631            $oldEventInfo['attendees'][$attendee]['instances'] :
632            [];
633
634        $instances = [];
635        foreach ($oldInstances as $instance) {
636            $instances[$instance['id']] = [
637                'id' => $instance['id'],
638                'oldstatus' => $instance['partstat'],
639                'newstatus' => null,
640            ];
641        }
642        foreach ($eventInfo['attendees'][$attendee]['instances'] as $instance) {
643            if (isset($instances[$instance['id']])) {
644                $instances[$instance['id']]['newstatus'] = $instance['partstat'];
645            } else {
646                $instances[$instance['id']] = [
647                    'id' => $instance['id'],
648                    'oldstatus' => null,
649                    'newstatus' => $instance['partstat'],
650                ];
651            }
652        }
653
654        // We need to also look for differences in EXDATE. If there are new
655        // items in EXDATE, it means that an attendee deleted instances of an
656        // event, which means we need to send DECLINED specifically for those
657        // instances.
658        // We only need to do that though, if the master event is not declined.
659        if (isset($instances['master']) && 'DECLINED' !== $instances['master']['newstatus']) {
660            foreach ($eventInfo['exdate'] as $exDate) {
661                if (!in_array($exDate, $oldEventInfo['exdate'])) {
662                    if (isset($instances[$exDate])) {
663                        $instances[$exDate]['newstatus'] = 'DECLINED';
664                    } else {
665                        $instances[$exDate] = [
666                            'id' => $exDate,
667                            'oldstatus' => null,
668                            'newstatus' => 'DECLINED',
669                        ];
670                    }
671                }
672            }
673        }
674
675        // Gathering a few extra properties for each instance.
676        foreach ($instances as $recurId => $instanceInfo) {
677            if (isset($eventInfo['instances'][$recurId])) {
678                $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART;
679            } else {
680                $instances[$recurId]['dtstart'] = $recurId;
681            }
682        }
683
684        $message = new Message();
685        $message->uid = $eventInfo['uid'];
686        $message->method = 'REPLY';
687        $message->component = 'VEVENT';
688        $message->sequence = $eventInfo['sequence'];
689        $message->sender = $attendee;
690        $message->senderName = $eventInfo['attendees'][$attendee]['name'];
691        $message->recipient = $eventInfo['organizer'];
692        $message->recipientName = $eventInfo['organizerName'];
693
694        $icalMsg = new VCalendar();
695        $icalMsg->METHOD = 'REPLY';
696
697        foreach ($calendar->select('VTIMEZONE') as $timezone) {
698            $icalMsg->add(clone $timezone);
699        }
700
701        $hasReply = false;
702
703        foreach ($instances as $instance) {
704            if ($instance['oldstatus'] == $instance['newstatus'] && 'REPLY' !== $eventInfo['organizerForceSend']) {
705                // Skip
706                continue;
707            }
708
709            $event = $icalMsg->add('VEVENT', [
710                'UID' => $message->uid,
711                'SEQUENCE' => $message->sequence,
712            ]);
713            $summary = isset($calendar->VEVENT->SUMMARY) ? $calendar->VEVENT->SUMMARY->getValue() : '';
714            // Adding properties from the correct source instance
715            if (isset($eventInfo['instances'][$instance['id']])) {
716                $instanceObj = $eventInfo['instances'][$instance['id']];
717                $event->add(clone $instanceObj->DTSTART);
718                if (isset($instanceObj->DTEND)) {
719                    $event->add(clone $instanceObj->DTEND);
720                } elseif (isset($instanceObj->DURATION)) {
721                    $event->add(clone $instanceObj->DURATION);
722                }
723                if (isset($instanceObj->SUMMARY)) {
724                    $event->add('SUMMARY', $instanceObj->SUMMARY->getValue());
725                } elseif ($summary) {
726                    $event->add('SUMMARY', $summary);
727                }
728            } else {
729                // This branch of the code is reached, when a reply is
730                // generated for an instance of a recurring event, through the
731                // fact that the instance has disappeared by showing up in
732                // EXDATE
733                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
734                // Treat is as a DATE field
735                if (strlen($instance['id']) <= 8) {
736                    $event->add('DTSTART', $dt, ['VALUE' => 'DATE']);
737                } else {
738                    $event->add('DTSTART', $dt);
739                }
740                if ($summary) {
741                    $event->add('SUMMARY', $summary);
742                }
743            }
744            if ('master' !== $instance['id']) {
745                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
746                // Treat is as a DATE field
747                if (strlen($instance['id']) <= 8) {
748                    $event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']);
749                } else {
750                    $event->add('RECURRENCE-ID', $dt);
751                }
752            }
753            $organizer = $event->add('ORGANIZER', $message->recipient);
754            if ($message->recipientName) {
755                $organizer['CN'] = $message->recipientName;
756            }
757            $attendee = $event->add('ATTENDEE', $message->sender, [
758                'PARTSTAT' => $instance['newstatus'],
759            ]);
760            if ($message->senderName) {
761                $attendee['CN'] = $message->senderName;
762            }
763            $hasReply = true;
764        }
765
766        if ($hasReply) {
767            $message->message = $icalMsg;
768
769            return [$message];
770        } else {
771            return [];
772        }
773    }
774
775    /**
776     * Returns attendee information and information about instances of an
777     * event.
778     *
779     * Returns an array with the following keys:
780     *
781     * 1. uid
782     * 2. organizer
783     * 3. organizerName
784     * 4. organizerScheduleAgent
785     * 5. organizerForceSend
786     * 6. instances
787     * 7. attendees
788     * 8. sequence
789     * 9. exdate
790     * 10. timezone - strictly the timezone on which the recurrence rule is
791     *                based on.
792     * 11. significantChangeHash
793     * 12. status
794     *
795     * @param VCalendar $calendar
796     *
797     * @return array
798     */
799    protected function parseEventInfo(VCalendar $calendar = null)
800    {
801        $uid = null;
802        $organizer = null;
803        $organizerName = null;
804        $organizerForceSend = null;
805        $sequence = null;
806        $timezone = null;
807        $status = null;
808        $organizerScheduleAgent = 'SERVER';
809
810        $significantChangeHash = '';
811
812        // Now we need to collect a list of attendees, and which instances they
813        // are a part of.
814        $attendees = [];
815
816        $instances = [];
817        $exdate = [];
818
819        foreach ($calendar->VEVENT as $vevent) {
820            $rrule = [];
821
822            if (is_null($uid)) {
823                $uid = $vevent->UID->getValue();
824            } else {
825                if ($uid !== $vevent->UID->getValue()) {
826                    throw new ITipException('If a calendar contained more than one event, they must have the same UID.');
827                }
828            }
829
830            if (!isset($vevent->DTSTART)) {
831                throw new ITipException('An event MUST have a DTSTART property.');
832            }
833
834            if (isset($vevent->ORGANIZER)) {
835                if (is_null($organizer)) {
836                    $organizer = $vevent->ORGANIZER->getNormalizedValue();
837                    $organizerName = isset($vevent->ORGANIZER['CN']) ? $vevent->ORGANIZER['CN'] : null;
838                } else {
839                    if (strtoupper($organizer) !== strtoupper($vevent->ORGANIZER->getNormalizedValue())) {
840                        throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.');
841                    }
842                }
843                $organizerForceSend =
844                    isset($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) ?
845                    strtoupper($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) :
846                    null;
847                $organizerScheduleAgent =
848                    isset($vevent->ORGANIZER['SCHEDULE-AGENT']) ?
849                    strtoupper((string) $vevent->ORGANIZER['SCHEDULE-AGENT']) :
850                    'SERVER';
851            }
852            if (is_null($sequence) && isset($vevent->SEQUENCE)) {
853                $sequence = $vevent->SEQUENCE->getValue();
854            }
855            if (isset($vevent->EXDATE)) {
856                foreach ($vevent->select('EXDATE') as $val) {
857                    $exdate = array_merge($exdate, $val->getParts());
858                }
859                sort($exdate);
860            }
861            if (isset($vevent->RRULE)) {
862                foreach ($vevent->select('RRULE') as $rr) {
863                    foreach ($rr->getParts() as $key => $val) {
864                        // ignore default values (https://github.com/sabre-io/vobject/issues/126)
865                        if ('INTERVAL' === $key && 1 == $val) {
866                            continue;
867                        }
868                        if (is_array($val)) {
869                            $val = implode(',', $val);
870                        }
871                        $rrule[] = "$key=$val";
872                    }
873                }
874                sort($rrule);
875            }
876            if (isset($vevent->STATUS)) {
877                $status = strtoupper($vevent->STATUS->getValue());
878            }
879
880            $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
881            if (is_null($timezone)) {
882                if ('master' === $recurId) {
883                    $timezone = $vevent->DTSTART->getDateTime()->getTimeZone();
884                } else {
885                    $timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone();
886                }
887            }
888            if (isset($vevent->ATTENDEE)) {
889                foreach ($vevent->ATTENDEE as $attendee) {
890                    if ($this->scheduleAgentServerRules &&
891                        isset($attendee['SCHEDULE-AGENT']) &&
892                        'CLIENT' === strtoupper($attendee['SCHEDULE-AGENT']->getValue())
893                    ) {
894                        continue;
895                    }
896                    $partStat =
897                        isset($attendee['PARTSTAT']) ?
898                        strtoupper($attendee['PARTSTAT']) :
899                        'NEEDS-ACTION';
900
901                    $forceSend =
902                        isset($attendee['SCHEDULE-FORCE-SEND']) ?
903                        strtoupper($attendee['SCHEDULE-FORCE-SEND']) :
904                        null;
905
906                    if (isset($attendees[$attendee->getNormalizedValue()])) {
907                        $attendees[$attendee->getNormalizedValue()]['instances'][$recurId] = [
908                            'id' => $recurId,
909                            'partstat' => $partStat,
910                            'forceSend' => $forceSend,
911                        ];
912                    } else {
913                        $attendees[$attendee->getNormalizedValue()] = [
914                            'href' => $attendee->getNormalizedValue(),
915                            'instances' => [
916                                $recurId => [
917                                    'id' => $recurId,
918                                    'partstat' => $partStat,
919                                ],
920                            ],
921                            'name' => isset($attendee['CN']) ? (string) $attendee['CN'] : null,
922                            'forceSend' => $forceSend,
923                        ];
924                    }
925                }
926                $instances[$recurId] = $vevent;
927            }
928
929            foreach ($this->significantChangeProperties as $prop) {
930                if (isset($vevent->$prop)) {
931                    $propertyValues = $vevent->select($prop);
932
933                    $significantChangeHash .= $prop.':';
934
935                    if ('EXDATE' === $prop) {
936                        $significantChangeHash .= implode(',', $exdate).';';
937                    } elseif ('RRULE' === $prop) {
938                        $significantChangeHash .= implode(',', $rrule).';';
939                    } else {
940                        foreach ($propertyValues as $val) {
941                            $significantChangeHash .= $val->getValue().';';
942                        }
943                    }
944                }
945            }
946        }
947        $significantChangeHash = md5($significantChangeHash);
948
949        return compact(
950            'uid',
951            'organizer',
952            'organizerName',
953            'organizerScheduleAgent',
954            'organizerForceSend',
955            'instances',
956            'attendees',
957            'sequence',
958            'exdate',
959            'timezone',
960            'significantChangeHash',
961            'status'
962        );
963    }
964}
965