kopano-mr-accept 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. #!/usr/bin/env php
  2. <?php
  3. # -*- Mode: php -*-
  4. /*
  5. * Copyright 2005 - 2014 Zarafa B.V.
  6. *
  7. * This program is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU Affero General Public License, version 3,
  9. * as published by the Free Software Foundation.
  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. include('mapi/mapi.util.php');
  21. include('mapi/mapidefs.php');
  22. include('mapi/mapicode.php');
  23. include('mapi/mapitags.php');
  24. include('mapi/mapiguid.php');
  25. include('mapi/class.meetingrequest.php');
  26. include('mapi/class.recurrence.php');
  27. include('mapi/class.freebusypublish.php');
  28. define('POLICY_PROCESS_MEETING_REQUESTS', 0x0001);
  29. define('POLICY_DECLINE_RECURRING_MEETING_REQUESTS', 0x0002);
  30. define('POLICY_DECLINE_CONFLICTING_MEETING_REQUESTS', 0x0004);
  31. define('RECURRENCE_AVAILABILITY_RANGE', 60 * 60 * 24 * 180); // 180 days
  32. $DEBUG = 1;
  33. function parseConfig($configfile)
  34. {
  35. $fp = fopen($configfile, "rt");
  36. if(!$fp)
  37. return false;
  38. $settings = array();
  39. while($line = fgets($fp)) {
  40. if($line[0] == '#')
  41. continue;
  42. $pos = strpos($line, "=");
  43. if($pos) {
  44. $key = trim(substr($line, 0, $pos));
  45. $value = trim(substr($line, $pos+1));
  46. $settings[$key] = $value;
  47. }
  48. }
  49. return $settings;
  50. }
  51. function u2w($s)
  52. {
  53. return $s;
  54. }
  55. if(!function_exists('hex2bin')){
  56. function hex2bin($data)
  57. {
  58. return pack("H*", $data);
  59. }
  60. }
  61. /**
  62. * Sorts by timestamp, if equal, then end before start. Used by getOverlapDepth()
  63. */
  64. function cmp($a, $b)
  65. {
  66. if ($a["time"] == $b["time"]) {
  67. if($a["type"] < $b["type"])
  68. return 1;
  69. if($a["type"] > $b["type"])
  70. return -1;
  71. return 0;
  72. }
  73. return ($a["time"] > $b["time"] ? 1 : -1);
  74. }
  75. /**
  76. * Get the overlap depth of the passed items.
  77. *
  78. * This function calculates the maximum number of overlapping appointments at any one time
  79. * for all the passed appointments, disregarding 'free' appointments.
  80. */
  81. function getOverlapDepth($items, $proptags, $goid2)
  82. {
  83. $timestamps = Array();
  84. $cbusy = Array();
  85. $level = 0;
  86. $maxlevel = 0;
  87. foreach($items as $item)
  88. {
  89. // Disregard 'free' items and the item that we are updating
  90. if($item[$proptags['busystatus']] > 0 && $item[$proptags['goid2']] != $goid2) {
  91. $ts["type"] = 0;
  92. $ts["time"] = $item[$proptags["startdate"]];
  93. $timestamps[] = $ts;
  94. $ts["type"] = 1;
  95. $ts["time"] = $item[$proptags["duedate"]];
  96. $timestamps[] = $ts;
  97. }
  98. }
  99. usort($timestamps, "cmp");
  100. foreach($timestamps as $ts)
  101. {
  102. switch ($ts["type"])
  103. {
  104. case 0: // Start
  105. $level++;
  106. $maxlevel = max($level, $maxlevel);
  107. break;
  108. case 1: // End
  109. $level--;
  110. break;
  111. }
  112. }
  113. return $maxlevel;
  114. }
  115. /**
  116. * Get unresponded items from the specified folder
  117. *
  118. * Looks for messages which have PR_RESPONSE_REQUESTED = TRUE, but no PR_PROCESSED = TRUE and have
  119. * class IPM.Meeting.Req.*
  120. */
  121. function getUnresponded($folder)
  122. {
  123. $contents = mapi_folder_getcontentstable($folder);
  124. $restriction = Array(RES_OR,
  125. Array(
  126. Array(RES_AND,
  127. Array(
  128. Array(RES_PROPERTY, Array(RELOP => RELOP_EQ, ULPROPTAG => PR_MESSAGE_CLASS, VALUE => 'IPM.Schedule.Meeting.Request') ),
  129. Array(RES_PROPERTY, Array(RELOP => RELOP_EQ, ULPROPTAG => PR_RESPONSE_REQUESTED, VALUE => true ) ),
  130. Array(RES_PROPERTY, Array(RELOP => RELOP_NE, ULPROPTAG => PR_PROCESSED, VALUE => true ) )
  131. )
  132. ),
  133. Array(RES_AND,
  134. Array(
  135. Array(RES_PROPERTY, Array(RELOP => RELOP_EQ, ULPROPTAG => PR_MESSAGE_CLASS, VALUE => 'IPM.Schedule.Meeting.Canceled') ),
  136. Array(RES_PROPERTY, Array(RELOP => RELOP_NE, ULPROPTAG => PR_PROCESSED, VALUE => true ) )
  137. )
  138. )
  139. )
  140. );
  141. $rows = mapi_table_queryallrows($contents, Array(PR_ENTRYID), $restriction);
  142. $entryids = array();
  143. foreach ($rows as $row) {
  144. $entryids[] = $row[PR_ENTRYID];
  145. }
  146. return $entryids;
  147. }
  148. /**
  149. * Get the capacity of the resource store
  150. *
  151. * For rooms, the capacity is always 1. For equipment the capacity is 1 if
  152. * PR_EMS_AB_ROOM_CAPACITY does not exist, otherwise it is equal to
  153. * PR_EMS_AB_ROOM_CAPACITY. Other objects always have a capacity of 1.
  154. */
  155. function getCapacity($session, $store)
  156. {
  157. $storeprops = mapi_getprops($store, array(PR_MAILBOX_OWNER_ENTRYID));
  158. $ab = mapi_openaddressbook($session);
  159. $mailuser = mapi_ab_openentry($ab, $storeprops[PR_MAILBOX_OWNER_ENTRYID]);
  160. $props = mapi_getprops($mailuser, array(PR_EMS_AB_ROOM_CAPACITY, PR_DISPLAY_TYPE_EX));
  161. if(!isset($props[PR_EMS_AB_ROOM_CAPACITY]) || $props[PR_EMS_AB_ROOM_CAPACITY] <= 0 || !isset($props[PR_DISPLAY_TYPE_EX]) || $props[PR_DISPLAY_TYPE_EX] != DT_EQUIPMENT) {
  162. $capacity = 1;
  163. } else {
  164. $capacity = $props[PR_EMS_AB_ROOM_CAPACITY];
  165. }
  166. return $capacity;
  167. }
  168. /**
  169. * Get local freebusy message for this store
  170. */
  171. function getLocalFBMessage($store)
  172. {
  173. $root = mapi_msgstore_openentry($store);
  174. $rootprops = mapi_getprops($root, array(PR_FREEBUSY_ENTRYIDS));
  175. if(!isset($rootprops[PR_FREEBUSY_ENTRYIDS]) || count($rootprops[PR_FREEBUSY_ENTRYIDS]) < 2)
  176. return false;
  177. $message = mapi_msgstore_openentry($store, $rootprops[PR_FREEBUSY_ENTRYIDS][1]);
  178. return $message;
  179. }
  180. /**
  181. * Get auto-respond policy for a store
  182. *
  183. * Can return POLICY_PROCESS_MEETING_REQUESTS, POLICY_DECLINE_RECURRING_MEETING_REQUESTS and POLICY_DECLINE_CONFLICTING_MEETING_REQUESTS
  184. * in any combination
  185. *
  186. */
  187. function getPolicy($store)
  188. {
  189. $localfbmessage = getLocalFBMessage($store);
  190. if(!$localfbmessage) {
  191. return 0;
  192. }
  193. $props = mapi_getprops($localfbmessage, array(PR_PROCESS_MEETING_REQUESTS, PR_DECLINE_CONFLICTING_MEETING_REQUESTS, PR_DECLINE_RECURRING_MEETING_REQUESTS));
  194. $flags = 0;
  195. if(isset($props[PR_PROCESS_MEETING_REQUESTS]) && $props[PR_PROCESS_MEETING_REQUESTS])
  196. $flags |= POLICY_PROCESS_MEETING_REQUESTS;
  197. if(isset($props[PR_DECLINE_CONFLICTING_MEETING_REQUESTS]) && $props[PR_DECLINE_CONFLICTING_MEETING_REQUESTS])
  198. $flags |= POLICY_DECLINE_CONFLICTING_MEETING_REQUESTS;
  199. if(isset($props[PR_DECLINE_RECURRING_MEETING_REQUESTS]) && $props[PR_DECLINE_RECURRING_MEETING_REQUESTS])
  200. $flags |= POLICY_DECLINE_RECURRING_MEETING_REQUESTS;
  201. return $flags;
  202. }
  203. function debugLog($message)
  204. {
  205. global $DEBUG;
  206. if($DEBUG) {
  207. print($message);
  208. }
  209. }
  210. /**
  211. * Return TRUE if two appointments overlap
  212. *
  213. */
  214. function apptOverlap($appt1, $appt2, $proptags)
  215. {
  216. // If appt1 starts after appt2 has ended, no overlap
  217. if($appt1[$proptags['startdate']] >= $appt2[$proptags['duedate']]) {
  218. return false;
  219. }
  220. // If appt2 starts after appt1 has ended, no overlap
  221. if($appt2[$proptags['startdate']] >= $appt1[$proptags['duedate']]) {
  222. return false;
  223. }
  224. return true;
  225. }
  226. /**
  227. * Return the intersection of a list of appointments with one appointment
  228. *
  229. * This means that only the appointments in $list that overlap with $appointment
  230. * are returned in a list
  231. */
  232. function intersectAppointmentWithList($appointment, $list, $proptags)
  233. {
  234. $intersect = array();
  235. foreach($list as $item) {
  236. if(apptOverlap($item, $appointment, $proptags))
  237. $intersect[] = $item;
  238. }
  239. return $intersect;
  240. }
  241. /**
  242. * Auto-respond to a meeting request
  243. *
  244. * Looks at the incoming meeting request, checks availability for the resource, and responds accordingly
  245. */
  246. function autoRespond($session, $store, $entryid, $capacity, $policy)
  247. {
  248. debugLog("Processing item with entryid " . bin2hex($entryid) . "\n");
  249. $calendar = getCalendar($store);
  250. if(!$calendar) {
  251. debugLog("Unable to open calendar.\n");
  252. return false;
  253. }
  254. $proptags = getPropIdsFromStrings($store, array(
  255. 'startdate' => "PT_SYSTIME:PSETID_Appointment:0x820d",
  256. 'duedate' => "PT_SYSTIME:PSETID_Appointment:0x820e",
  257. 'busystatus' => "PT_LONG:PSETID_Appointment:0x8205",
  258. 'recurring' => "PT_BOOLEAN:PSETID_Appointment:0x8223",
  259. 'goid2' => "PT_BINARY:PSETID_Meeting:0x23",
  260. 'subject' => PR_SUBJECT
  261. ));
  262. $request = mapi_msgstore_openentry($store, $entryid);
  263. if(!$request) {
  264. debugLog("Unable to open item with entryid " . bin2hex($entryid) . "\n");
  265. return false;
  266. }
  267. $mr = new Meetingrequest($store, $request, $session);
  268. if($mr->isMeetingRequest()) {
  269. $props = mapi_getprops($request, $proptags);
  270. // Check general policy settings
  271. if(isset($props[$proptags['recurring']]) && $props[$proptags['recurring']] && ($policy & POLICY_DECLINE_RECURRING_MEETING_REQUESTS)) {
  272. $mr->doDecline(true, false, false, _("Recurring meetings are not allowed"));
  273. debugLog("Declined due to recurrence against non-recurring policy.\n");
  274. return true;
  275. }
  276. if($policy & POLICY_DECLINE_CONFLICTING_MEETING_REQUESTS) {
  277. if(isset($props[$proptags['recurring']]) && $props[$proptags['recurring']]) {
  278. $rec = new Recurrence($store, $request);
  279. // Only check for conflicts in the first X months, otherwise processing would become too
  280. // complicated.
  281. $reqitems = $rec->GetItems($props[$proptags['startdate']], $props[$proptags['startdate']] + RECURRENCE_AVAILABILITY_RANGE);
  282. // Get all the possible conflicts in the coming X months
  283. debugLog('Getting conflicts from ' . strftime('%x %X', $props[$proptags['startdate']]) . '\n');
  284. debugLog('Getting conflicts to ' . strftime('%x %X', $props[$proptags['startdate']] + RECURRENCE_AVAILABILITY_RANGE) . '\n');
  285. $possibleconflicts = getCalendarItems($store, $calendar, $props[$proptags['startdate']], $props[$proptags['startdate']] + RECURRENCE_AVAILABILITY_RANGE, $proptags);
  286. } else {
  287. $reqitems = array($props);
  288. // Only look at possible conflicts during the duration of the item
  289. $possibleconflicts = getCalendarItems($store, $calendar, $props[$proptags['startdate']], $props[$proptags['duedate']], $proptags);
  290. }
  291. $conflicts = array();
  292. foreach($reqitems as $reqitem) {
  293. // Check for conflicting appointments
  294. $start = $reqitem[$proptags['startdate']];
  295. $end = $reqitem[$proptags['duedate']];
  296. debugLog("Checking availability from " . strftime("%x %X", $start) . " to " . strftime("%x %X", $end) . "\n");
  297. $items = intersectAppointmentWithList($reqitem, $possibleconflicts, $proptags);
  298. debugLog("Found " . count($items) . " overlapping records\n");
  299. $currentdepth = getOverlapDepth($items, $proptags, $props[$proptags['goid2']]);
  300. debugLog("Overlap depth is " . $currentdepth . "\n");
  301. if($currentdepth >= $capacity) {
  302. $conflicts[] = $reqitem;
  303. }
  304. }
  305. if(count($conflicts) > 0) {
  306. // At least one conflict
  307. if(count($conflicts) == count($reqitems)) {
  308. $body = _("The requested time slot is unavailable");
  309. } else {
  310. $body = _("The requested time slots are unavailble on the following dates:") . "\n\n";
  311. foreach($conflicts as $conflict) {
  312. $body .= strftime(_("%x %X"), $conflict[$proptags["startdate"]]) . " - " . strftime(_("%x %X"), $conflict[$proptags["duedate"]]) . "\n";
  313. }
  314. }
  315. $mr->doDecline(true, false, false, $body);
  316. debugLog("Declined due to capacity reached.\n");
  317. return true;
  318. }
  319. }
  320. // Checks passed, book the meeting
  321. $ceid = $mr->doAccept(false, true, true);
  322. if ($ceid === false) {
  323. debugLog("Failed to accept: " . sprintf("0x%X", mapi_last_hresult()) . "\n");
  324. return false;
  325. }
  326. debugLog("Accepted.\n");
  327. // reopen entry to add self as BCC recipient for ZCP-9901
  328. $calitem = mapi_msgstore_openentry($store, $ceid);
  329. if ($calitem) {
  330. $storeprops = mapi_getprops($store, array(PR_MAILBOX_OWNER_ENTRYID));
  331. $ab = mapi_openaddressbook($session);
  332. $mailuser = mapi_ab_openentry($ab, $storeprops[PR_MAILBOX_OWNER_ENTRYID]);
  333. $recip = mapi_getprops($mailuser, array(PR_ACCOUNT, PR_ADDRTYPE, PR_DISPLAY_NAME, PR_DISPLAY_TYPE, PR_DISPLAY_TYPE_EX,
  334. PR_EMAIL_ADDRESS, PR_ENTRYID, PR_OBJECT_TYPE, PR_SEARCH_KEY, PR_SMTP_ADDRESS));
  335. $recip[PR_RECIPIENT_ENTRYID] = $recip[PR_ENTRYID];
  336. $recip[PR_RECIPIENT_FLAGS] = 256 | 1;
  337. $recip[PR_RECIPIENT_TRACKSTATUS] = 0;
  338. $recip[PR_RECIPIENT_TYPE] = MAPI_BCC;
  339. // not setting PidLidAllAttendees, not important
  340. mapi_message_modifyrecipients($calitem, MODRECIP_ADD, array($recip));
  341. mapi_message_savechanges($calitem);
  342. debugLog("Accept updated.\n");
  343. } else {
  344. debugLog("Unable to update accepted item.\n");
  345. }
  346. return true;
  347. } else if($mr->isMeetingCancellation()) {
  348. $mr->processMeetingCancellation();
  349. $mr->doRemoveFromCalendar();
  350. debugLog("Removed canceled meeting\n");
  351. return true;
  352. }
  353. }
  354. // Since the username we are getting from the commandline is always in utf8, we have
  355. // to force LC_CTYPE to an UTF-8 language. This makes sure that opening the user's store
  356. // will always open the correct user's store.
  357. forceUTF8(LC_CTYPE);
  358. forceUTF8(LC_MESSAGES);
  359. forceUTF8(LC_TIME);
  360. textdomain("kopano");
  361. if(count($argv) != 3 && count($argv) != 4) {
  362. print "Usage: " . $argv[0] . " <username> <path/to/dagent.cfg> [<entryid>]\n";
  363. print
  364. print "If <entryid> is not specified, all unresponded MR's in the inbox are processed\n";
  365. exit(1);
  366. }
  367. $username = $argv[1];
  368. $config = $argv[2];
  369. if(isset($argv[3]))
  370. $entryid = $argv[3];
  371. $settings = parseConfig($config);
  372. if(!$settings || !isset($settings["server_socket"])) {
  373. $settings["server_socket"] = "default:";
  374. }
  375. if(isset($settings["sslkey_file"]) && isset($settings["sslkey_pass"]))
  376. $session = mapi_logon_zarafa($username, "", $settings["server_socket"], $settings["sslkey_file"], $settings["sslkey_pass"]);
  377. else
  378. $session = mapi_logon_zarafa($username, "", $settings["server_socket"]);
  379. $store = GetDefaultStore($session);
  380. $capacity = getCapacity($session, $store);
  381. $policy = getPolicy($store);
  382. debugLog("Policy is " . $policy . "\n");
  383. if(($policy & POLICY_PROCESS_MEETING_REQUESTS) == 0) {
  384. debugLog("Policy auto-respond not set.\n");
  385. }
  386. debugLog("Resource capacity is $capacity\n");
  387. $inbox = mapi_msgstore_getreceivefolder($store);
  388. if(isset($entryid)) {
  389. $items = array (hex2bin($entryid));
  390. } else {
  391. $items = getUnresponded($inbox);
  392. }
  393. debugLog("Found " . count($items) . " items to process\n");
  394. foreach ($items as $item) {
  395. autoRespond($session, $store, $item, $capacity, $policy);
  396. }
  397. $storeprops = mapi_getprops($store, array(PR_MAILBOX_OWNER_ENTRYID));
  398. $fb = new FreeBusyPublish($session, $store, getCalendar($store), $storeprops[PR_MAILBOX_OWNER_ENTRYID]);
  399. $fb->PublishFB(time() - (7 * 24 * 60 * 60), 6 * 30 * 24 * 60 * 60); // publish from one week ago, 6 months ahead
  400. exit(0);
  401. ?>