1: <?php
2: /**
3: * Wei Framework
4: *
5: * @copyright Copyright (c) 2008-2015 Twin Huang
6: * @license http://opensource.org/licenses/mit-license.php MIT License
7: */
8:
9: namespace Wei;
10:
11: use Closure;
12: use SimpleXMLElement;
13:
14: /**
15: * A service handles WeChat(WeiXin) callback message
16: *
17: * @author Twin Huang <twinhuang@qq.com>
18: * @link http://mp.weixin.qq.com/wiki/index.php?title=%E6%B6%88%E6%81%AF%E6%8E%A5%E5%8F%A3%E6%8C%87%E5%8D%97
19: */
20: class WeChatApp extends Base
21: {
22: /**
23: * The WeChat token to generate signature
24: *
25: * @var string
26: */
27: protected $token = 'wei';
28:
29: /**
30: * The HTTP raw post data, equals to $GLOBALS['HTTP_RAW_POST_DATA'] on default
31: *
32: * @var string
33: */
34: protected $postData;
35:
36: /**
37: * The URL query parameters, equals to $_GET on default
38: *
39: * @var array
40: */
41: protected $query;
42:
43: /**
44: * The rules to generate output message
45: *
46: * @var array
47: */
48: protected $rules = array(
49: 'text' => array(),
50: 'event' => array(),
51: 'image' => null,
52: 'location' => null,
53: 'voice' => null,
54: 'video' => null,
55: 'link' => null
56: );
57:
58: /**
59: * A handler executes when none of rules handled the input
60: *
61: * @var callable
62: */
63: protected $defaults;
64:
65: /**
66: * Whether the signature is valid
67: *
68: * @var bool
69: */
70: protected $valid = false;
71:
72: /**
73: * Are there any callbacks handled the message ?
74: *
75: * @var bool
76: */
77: protected $handled = false;
78:
79: /**
80: * The callback executes before send the XML data
81: *
82: * @var callable
83: */
84: protected $beforeSend;
85:
86: /**
87: * The element values of post XML data
88: *
89: * Most of the available element names in post XML data
90: * common : MsgType, FromUserName, ToUserName, MsgId, CreateTime, Ticket
91: * text : Content
92: * image : PicUrl
93: * location: Location_X, Location_Y, Scale, Label
94: * voice : MediaId, Format
95: * event : Event, EventKey
96: * video : MediaId, ThumbMediaId
97: * link : Title, Description
98: *
99: * @var array
100: */
101: protected $attrs = array();
102:
103: /**
104: * Constructor
105: *
106: * @param array $options
107: * @global string $GLOBALS['HTTP_RAW_POST_DATA']
108: */
109: public function __construct($options = array())
110: {
111: parent::__construct($options);
112:
113: if (!$this->query) {
114: $this->query = &$_GET;
115: }
116:
117: if (is_null($this->postData) && isset($GLOBALS['HTTP_RAW_POST_DATA'])) {
118: $this->postData = $GLOBALS['HTTP_RAW_POST_DATA'];
119: }
120:
121: $this->parsePostData();
122: }
123:
124: /**
125: * Start up WeChat application and output the matched rule message
126: *
127: * @return $this
128: */
129: public function __invoke()
130: {
131: echo $this->run();
132: return $this;
133: }
134:
135: /**
136: * Execute the matched rule and returns the rule result
137: *
138: * Returns false when the token is invalid or no rules matched
139: *
140: * @return string|false
141: */
142: public function run()
143: {
144: // The token is invalid
145: if (!$this->valid) {
146: return false;
147: }
148:
149: // Output 'echostr' for fist time authentication
150: if (isset($this->query['echostr'])) {
151: return htmlspecialchars($this->query['echostr'], ENT_QUOTES, 'UTF-8');
152: }
153:
154: switch ($this->getMsgType()) {
155: case 'text':
156: if ($result = $this->handleText()) {
157: return $result;
158: }
159: break;
160:
161: case 'event':
162: $event = strtolower($this->getEvent());
163: switch ($event) {
164: case 'subscribe':
165: $result = $this->handleEvent('subscribe');
166: if ($this->getTicket()) {
167: $result = $this->handleEvent('scan');
168: }
169: if ($result) {
170: return $result;
171: }
172: break;
173:
174: case 'scan':
175: if ($result = $this->handleEvent('scan')) {
176: return $result;
177: }
178: break;
179:
180: default:
181: if ($result = $this->handleEvent($event, $this->getEventKey())) {
182: return $result;
183: }
184: break;
185: }
186: break;
187:
188: // Including location, image, voice, video and link
189: default:
190: if (isset($this->rules[$this->getMsgType()])) {
191: return $this->handle($this->rules[$this->getMsgType()]);
192: }
193: }
194:
195: // Fallback to the default rule
196: if (!$this->handled && $this->defaults) {
197: return $this->handle($this->defaults);
198: }
199:
200: return false;
201: }
202:
203: /**
204: * Check if the request is verify the token string
205: *
206: * @return bool
207: */
208: public function isVerifyToken()
209: {
210: return isset($this->query['echostr']);
211: }
212:
213: /**
214: * Attach a callback which triggered when user subscribed you
215: *
216: * @param Closure $fn
217: * @return $this
218: */
219: public function subscribe(Closure $fn)
220: {
221: return $this->addEventRule('subscribe', null, $fn);
222: }
223:
224: /**
225: * Attach a callback which triggered when user unsubscribed you
226: *
227: * @param Closure $fn
228: * @return $this
229: */
230: public function unsubscribe(Closure $fn)
231: {
232: return $this->addEventRule('unsubscribe', null, $fn);
233: }
234:
235: /**
236: * Attach a callback which triggered when user click the custom menu
237: *
238: * @param string $key The key of event
239: * @param Closure $fn
240: * @return $this
241: */
242: public function click($key, Closure $fn)
243: {
244: return $this->addEventRule('click', $key, $fn);
245: }
246:
247: /**
248: * Attach a callback which triggered when user scan the QR Code
249: *
250: * @param Closure $fn
251: * @return $this
252: */
253: public function scan(Closure $fn)
254: {
255: return $this->addEventRule('scan', null, $fn);
256: }
257:
258: /**
259: * Attach a callback which triggered when user input equals to the keyword
260: *
261: * @param string $keyword The keyword to compare with user input
262: * @param Closure $fn
263: * @return $this
264: */
265: public function is($keyword, Closure $fn)
266: {
267: return $this->addTextRule('is', $keyword, $fn);
268: }
269:
270: /**
271: * Attach a callback with a keyword, which triggered when user input contains the keyword
272: *
273: * @param string $keyword The keyword to search in user input
274: * @param Closure $fn
275: * @return $this
276: */
277: public function has($keyword, Closure $fn)
278: {
279: return $this->addTextRule('has', $keyword, $fn);
280: }
281:
282: /**
283: * Attach a callback with a keyword, which triggered when user input starts with the keyword (case insensitive)
284: *
285: * @param string $keyword The keyword to search in user input
286: * @param Closure $fn
287: * @return $this
288: */
289: public function startsWith($keyword, Closure $fn)
290: {
291: return $this->addTextRule('startsWith', $keyword, $fn);
292: }
293:
294: /**
295: * Attach a callback with a regex pattern which triggered when user input match the pattern
296: *
297: * @param string $pattern The pattern to match
298: * @param Closure $fn
299: * @return $this
300: */
301: public function match($pattern, Closure $fn)
302: {
303: return $this->addTextRule('match', $pattern, $fn);
304: }
305:
306: /**
307: * Attach a callback to handle image message
308: *
309: * @param Closure $fn
310: * @return $this
311: */
312: public function receiveImage(Closure $fn)
313: {
314: $this->rules['image'] = $fn;
315: return $this;
316: }
317:
318: /**
319: * Attach a callback to handle location message
320: *
321: * @param Closure $fn
322: * @return $this
323: */
324: public function receiveLocation(Closure $fn)
325: {
326: $this->rules['location'] = $fn;
327: return $this;
328: }
329:
330: /**
331: * Attach a callback to handle voice message
332: *
333: * @param Closure $fn
334: * @return $this
335: */
336: public function receiveVoice(Closure $fn)
337: {
338: $this->rules['voice'] = $fn;
339: return $this;
340: }
341:
342: /**
343: * Attach a callback to handle video message
344: *
345: * @param Closure $fn
346: * @return $this
347: */
348: public function receiveVideo(Closure $fn)
349: {
350: $this->rules['video'] = $fn;
351: return $this;
352: }
353:
354: /**
355: * Attach a callback to handle link message
356: *
357: * @param Closure $fn
358: * @return $this
359: */
360: public function receiveLink(Closure $fn)
361: {
362: $this->rules['link'] = $fn;
363: return $this;
364: }
365:
366: /**
367: * Attach a handler which executes when none of the rule handled the input
368: *
369: * @param Closure $fn
370: * @return boolean
371: */
372: public function defaults(Closure $fn)
373: {
374: $this->defaults = $fn;
375: return $this;
376: }
377:
378: /**
379: * Generate text message for output
380: *
381: * @param string $content
382: * @return array
383: */
384: public function sendText($content)
385: {
386: return $this->send('text', array(
387: 'Content' => $content
388: ));
389: }
390:
391: /**
392: * Generate music message for output
393: *
394: * @param string $title The title of music
395: * @param string $description The description display blow the title
396: * @param string $url The music URL for player
397: * @param string $hqUrl The HQ music URL for player when user in WIFI
398: * @return array
399: */
400: public function sendMusic($title, $description, $url, $hqUrl = null)
401: {
402: return $this->send('music', array(
403: 'Music' => array(
404: 'Title' => $title,
405: 'Description' => $description,
406: 'MusicUrl' => $url,
407: 'HQMusicUrl' => $hqUrl
408: )
409: ));
410: }
411:
412: /**
413: * Generate article message for output
414: *
415: * ```
416: * // Sends one article
417: * $app->sendArticle(array(
418: * 'title' => 'The title of article',
419: * 'description' => 'The description of article',
420: * 'picUrl' => 'The picture URL of article',
421: * 'url' => 'The URL link to of article'
422: * ));
423: *
424: * // Sends two or more articles
425: * $app->sendArticle(array(
426: * array(
427: * 'title' => 'The title of article',
428: * 'description' => 'The description of article',
429: * 'picUrl' => 'The picture URL of article',
430: * 'url' => 'The URL link to of article'
431: * ),
432: * array(
433: * 'title' => 'The title of article',
434: * 'description' => 'The description of article',
435: * 'picUrl' => 'Te picture URL of article',
436: * 'url' => 'The URL link to of article'
437: * ),
438: * // more...
439: * ));
440: * ```
441: *
442: * @param array $articles The article array
443: * @return array
444: */
445: public function sendArticle(array $articles)
446: {
447: // Convert single article array
448: if (!is_int(key($articles))) {
449: $articles = array($articles);
450: }
451:
452: $response = array(
453: 'ArticleCount' => count($articles),
454: 'Articles' => array(
455: 'item' => array()
456: )
457: );
458:
459: foreach ($articles as $article) {
460: $article += array(
461: 'title' => null,
462: 'description' => null,
463: 'picUrl' => null,
464: 'url' => null
465: );
466: $response['Articles']['item'][] = array(
467: 'Title' => $article['title'],
468: 'Description' => $article['description'],
469: 'PicUrl' => $article['picUrl'],
470: 'Url' => $article['url']
471: );
472: }
473:
474: return $this->send('news', $response);
475: }
476:
477: /**
478: * Returns if the token is valid
479: *
480: * @return bool
481: */
482: public function isValid()
483: {
484: return $this->valid;
485: }
486:
487: /**
488: * Returns the XML element value
489: *
490: * @param string $name
491: * @return mixed
492: */
493: public function getAttr($name)
494: {
495: return isset($this->attrs[$name]) ? $this->attrs[$name] : null;
496: }
497:
498: /**
499: * Returns all of XML element values
500: *
501: * @return array
502: */
503: public function getAttrs()
504: {
505: return $this->attrs;
506: }
507:
508: /**
509: * Returns the HTTP raw post data
510: *
511: * @return string
512: */
513: public function getPostData()
514: {
515: return $this->postData;
516: }
517:
518: /**
519: * Returns your user id
520: *
521: * @return string
522: */
523: public function getToUserName()
524: {
525: return $this->getAttr('ToUserName');
526: }
527:
528: /**
529: * Returns the user openID who sent message to you
530: *
531: * @return string
532: */
533: public function getFromUserName()
534: {
535: return $this->getAttr('FromUserName');
536: }
537:
538: /**
539: * Returns the timestamp when message created
540: *
541: * @return string
542: */
543: public function getCreateTime()
544: {
545: return $this->getAttr('CreateTime');
546: }
547:
548: /**
549: * Returns the user input string, available when the message type is text
550: *
551: * @return string
552: */
553: public function getContent()
554: {
555: return $this->getAttr('Content');
556: }
557:
558: /**
559: * Returns the message id
560: *
561: * @return string
562: */
563: public function getMsgId()
564: {
565: return $this->getAttr('MsgId');
566: }
567:
568: /**
569: * Returns the message type
570: *
571: * Currently could be text, image, location, link, event, voice, video
572: *
573: * @return string
574: */
575: public function getMsgType()
576: {
577: return $this->getAttr('MsgType');
578: }
579:
580: /**
581: * Returns the picture URL, available when the message type is image
582: *
583: * @return string
584: */
585: public function getPicUrl()
586: {
587: return $this->getAttr('PicUrl');
588: }
589:
590: /**
591: * Returns the latitude of location, available when the message type is location
592: *
593: * @return string
594: */
595: public function getLocationX()
596: {
597: return $this->getAttr('Location_X');
598: }
599:
600: /**
601: * Returns the longitude of location, available when the message type is location
602: *
603: * @return string
604: */
605: public function getLocationY()
606: {
607: return $this->getAttr('Location_Y');
608: }
609:
610: /**
611: * Returns the detail address of location, available when the message type is location
612: *
613: * @return string
614: */
615: public function getLabel()
616: {
617: return $this->getAttr('Label');
618: }
619:
620: /**
621: * Returns the scale of map, available when the message type is location
622: *
623: * @return string
624: */
625: public function getScale()
626: {
627: return $this->getAttr('Scale');
628: }
629:
630: /**
631: * Returns the media id, available when the message type is voice or video
632: *
633: * @return string
634: */
635: public function getMediaId()
636: {
637: return $this->getAttr('MediaId');
638: }
639:
640: /**
641: * Returns the media format, available when the message type is voice
642: *
643: * @return string
644: */
645: public function getFormat()
646: {
647: return $this->getAttr('Format');
648: }
649:
650: /**
651: * Returns the type of event, could be subscribe, unsubscribe or CLICK, available when the message type is event
652: *
653: * @return string
654: */
655: public function getEvent()
656: {
657: return $this->getAttr('Event');
658: }
659:
660: /**
661: * Returns the key value of custom menu, available when the message type is event
662: *
663: * @return string
664: */
665: public function getEventKey()
666: {
667: return $this->getAttr('EventKey');
668: }
669:
670: /**
671: * Returns the scene id from the scan result, available when the message event is subscribe or scan
672: *
673: * @return string
674: */
675: public function getScanSceneId()
676: {
677: $eventKey = $this->getEventKey();
678: if (strpos($eventKey, 'qrscene_') === 0) {
679: $eventKey = substr($eventKey, 8);
680: }
681: return $eventKey;
682: }
683:
684: /**
685: * Returns the thumbnail id of video, available when the message type is video
686: *
687: * @return string
688: */
689: public function getThumbMediaId()
690: {
691: return $this->getAttr('ThumbMediaId');
692: }
693:
694: /**
695: * Returns the title of URL, available when the message type is link
696: *
697: * @return string
698: */
699: public function getTitle()
700: {
701: return $this->getAttr('Title');
702: }
703:
704: /**
705: * Returns the description of URL, available when the message type is link
706: *
707: * @return string
708: */
709: public function getDescription()
710: {
711: return $this->getAttr('Description');
712: }
713:
714: /**
715: * Returns the URL link, available when the message type is link
716: *
717: * @return string
718: */
719: public function getUrl()
720: {
721: return $this->getAttr('Url');
722: }
723:
724: /**
725: * Returns the ticket string, available when user scan from the QR Code
726: *
727: * @return string
728: */
729: public function getTicket()
730: {
731: return $this->getAttr('Ticket');
732: }
733:
734: /**
735: * Returns the user inputted content or clicked button value
736: *
737: * @return bool|string
738: */
739: public function getKeyword()
740: {
741: if ($this->getMsgType() == 'text') {
742: return strtolower($this->getContent());
743: } elseif ($this->getMsgType() == 'event' && strtolower($this->getEvent()) == 'click') {
744: return strtolower($this->getEventKey());
745: }
746: return false;
747: }
748:
749: /**
750: * Generate message for output
751: *
752: * @param string $type The type of message
753: * @param array $response The response content
754: * @return array
755: */
756: protected function send($type, array $response)
757: {
758: return $response + array(
759: 'ToUserName' => $this->getFromUserName(),
760: 'FromUserName' => $this->getToUserName(),
761: 'MsgType' => $type,
762: 'CreateTime' => time()
763: );
764: }
765:
766: /**
767: * Adds a rule to handle user text input
768: *
769: * @param string $type
770: * @param string $keyword
771: * @param Closure $fn
772: * @return $this
773: */
774: protected function addTextRule($type, $keyword, Closure $fn)
775: {
776: $this->rules['text'][] = array(
777: 'type' => $type,
778: 'keyword' => $keyword,
779: 'fn' => $fn
780: );
781: return $this;
782: }
783:
784: /**
785: * Adds a rule to handle user event, such as click, subscribe
786: *
787: * @param string $name
788: * @param string $key
789: * @param Closure $fn
790: * @return $this
791: */
792: protected function addEventRule($name, $key, Closure $fn)
793: {
794: $this->rules['event'][$name][$key] = $fn;
795: return $this;
796: }
797:
798: /**
799: * Parse post data to receive user OpenID and input content and message attr
800: */
801: protected function parsePostData()
802: {
803: // Check if the WeChat server signature is valid
804: $query = $this->query;
805: $tmpArr = array(
806: $this->token,
807: isset($query['timestamp']) ? $query['timestamp'] : '',
808: isset($query['nonce']) ? $query['nonce'] : ''
809: );
810: sort($tmpArr, SORT_STRING);
811: $tmpStr = sha1(implode($tmpArr));
812: $this->valid = (isset($query['signature']) && $tmpStr === $query['signature']);
813:
814: // Parse the message data
815: if ($this->valid && $this->postData) {
816: // Do not output libxml error messages to screen
817: $useErrors = libxml_use_internal_errors(true);
818: $attrs = simplexml_load_string($this->postData, 'SimpleXMLElement', LIBXML_NOCDATA);
819: libxml_use_internal_errors($useErrors);
820:
821: // Fix the issue that XML parse empty data to new SimpleXMLElement object
822: $this->attrs = array_map('strval', (array)$attrs);
823: }
824: }
825:
826: /**
827: * Handle text rule
828: *
829: * @return string|false
830: */
831: protected function handleText()
832: {
833: $content = $this->getContent();
834: foreach ($this->rules['text'] as $rule) {
835: if ($rule['type'] == 'is' && 0 === strcasecmp($content, $rule['keyword'])) {
836: return $this->handle($rule['fn']);
837: }
838:
839: if ($rule['type'] == 'has' && false !== mb_stripos($content, $rule['keyword'])) {
840: return $this->handle($rule['fn']);
841: }
842:
843: if ($rule['type'] == 'startsWith' && 0 === mb_stripos($content, $rule['keyword'])) {
844: return $this->handle($rule['fn']);
845: }
846:
847: if ($rule['type'] == 'match' && preg_match($rule['keyword'], $content)) {
848: return $this->handle($rule['fn']);
849: }
850: }
851: return false;
852: }
853:
854: protected function handleEvent($event, $eventKey = false)
855: {
856: if ($eventKey !== false) {
857: if (isset($this->rules['event'][$event][$eventKey])) {
858: return $this->handle($this->rules['event'][$event][$eventKey]);
859: }
860: } else {
861: if (isset($this->rules['event'][$event])) {
862: return $this->handle(end($this->rules['event'][$event]));
863: }
864: }
865: return false;
866: }
867:
868: /**
869: * Executes callback handler
870: *
871: * @param Closure $fn
872: * @return string
873: */
874: protected function handle($fn)
875: {
876: $this->handled = true;
877:
878: // Converts string to array
879: $content = $fn($this, $this->wei) ?: array();
880: if ($content && !is_array($content)) {
881: $content = $this->sendText($content);
882: }
883:
884: $this->beforeSend && call_user_func_array($this->beforeSend, array($this, &$content, $this->wei));
885:
886: // Returns empty string if no response
887: // http://mp.weixin.qq.com/wiki/index.php?title=%E5%8F%91%E9%80%81%E8%A2%AB%E5%8A%A8%E5%93%8D%E5%BA%94%E6%B6%88%E6%81%AF
888: return $content ? $this->arrayToXml($content)->asXML() : '';
889: }
890:
891: /**
892: * Convert to XML element
893: *
894: * @param array $array
895: * @param SimpleXMLElement $xml
896: * @return SimpleXMLElement
897: */
898: protected function arrayToXml(array $array, SimpleXMLElement $xml = null)
899: {
900: if ($xml === null) {
901: $xml = new SimpleXMLElement('<xml/>');
902: }
903: foreach($array as $key => $value) {
904: if(is_array($value)) {
905: if (isset($value[0])) {
906: foreach ($value as $subValue) {
907: $subNode = $xml->addChild($key);
908: $this->arrayToXml($subValue, $subNode);
909: }
910: } else {
911: $subNode = $xml->addChild($key);
912: $this->arrayToXml($value, $subNode);
913: }
914: } else {
915: // Wrap cdata for non-numeric string
916: if (is_numeric($value)) {
917: $xml->addChild($key, $value);
918: } else {
919: $child = $xml->addChild($key);
920: $node = dom_import_simplexml($child);
921: $node->appendChild($node->ownerDocument->createCDATASection($value));
922: }
923: }
924: }
925: return $xml;
926: }
927: }
928: