Package s3 :: Module s3msg
[frames] | no frames]

Source Code for Module s3.s3msg

   1  # -*- coding: utf-8 -*- 
   2   
   3  """ Messaging API 
   4   
   5      API to send & receive messages: 
   6      - currently SMS, Email, RSS & Twitter 
   7   
   8      Messages get sent to the Outbox (& Log) 
   9      From there, the Scheduler tasks collect them & send them 
  10   
  11      @copyright: 2009-2019 (c) Sahana Software Foundation 
  12      @license: MIT 
  13   
  14      Permission is hereby granted, free of charge, to any person 
  15      obtaining a copy of this software and associated documentation 
  16      files (the "Software"), to deal in the Software without 
  17      restriction, including without limitation the rights to use, 
  18      copy, modify, merge, publish, distribute, sublicense, and/or sell 
  19      copies of the Software, and to permit persons to whom the 
  20      Software is furnished to do so, subject to the following 
  21      conditions: 
  22   
  23      The above copyright notice and this permission notice shall be 
  24      included in all copies or substantial portions of the Software. 
  25   
  26      THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 
  27      EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 
  28      OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 
  29      NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 
  30      HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
  31      WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 
  32      FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
  33      OTHER DEALINGS IN THE SOFTWARE. 
  34   
  35  """ 
  36   
  37  __all__ = ("S3Msg", 
  38             "S3Compose", 
  39             ) 
  40   
  41  import base64 
  42  import datetime 
  43  import json 
  44  import os 
  45  import re 
  46  import string 
  47  import urllib 
  48  import urllib2 
  49   
  50  try: 
  51      from cStringIO import StringIO    # Faster, where available 
  52  except: 
  53      from StringIO import StringIO 
  54   
  55  try: 
  56      from lxml import etree 
  57  except ImportError: 
  58      import sys 
  59      sys.stderr.write("ERROR: lxml module needed for XML handling\n") 
  60      raise 
  61   
  62  from gluon import current, redirect 
  63  from gluon.html import * 
  64   
  65  #from s3codec import S3Codec 
  66  from s3crud import S3CRUD 
  67  from s3datetime import s3_decode_iso_datetime 
  68  from s3forms import S3SQLDefaultForm 
  69  from s3utils import s3_unicode 
  70  from s3validators import IS_IN_SET, IS_ONE_OF 
  71  from s3widgets import S3PentityAutocompleteWidget 
  72   
  73  IDENTITYTRANS = ALLCHARS = string.maketrans("", "") 
  74  NOTPHONECHARS = ALLCHARS.translate(IDENTITYTRANS, string.digits) 
  75  NOTTWITTERCHARS = ALLCHARS.translate(IDENTITYTRANS, 
  76                                       "%s%s_" % (string.digits, string.letters)) 
  77   
  78  TWITTER_MAX_CHARS = 140 
  79  TWITTER_HAS_NEXT_SUFFIX = u' \u2026' 
  80  TWITTER_HAS_PREV_PREFIX = u'\u2026 ' 
  81   
  82  SENDER = re.compile(r"(.*)\s*\<(.+@.+)\>\s*") 
83 84 # ============================================================================= 85 -class S3Msg(object):
86 """ Messaging framework """ 87
88 - def __init__(self, 89 modem=None):
90 91 T = current.T 92 self.modem = modem 93 94 # http://docs.oasis-open.org/emergency/edxl-have/cs01/xPIL-types.xsd 95 # <xs:simpleType name="CommunicationMediaTypeList"> 96 # <xs:enumeration value="Cellphone"/> 97 # <xs:enumeration value="Fax"/> 98 # <xs:enumeration value="Pager"/> 99 # <xs:enumeration value="Telephone"/> 100 # <xs:enumeration value="VOIP"/> 101 # <xs:simpleType name="ElectronicAddressIdentifierTypeList"> 102 # <xs:enumeration value="AIM"/> 103 # <xs:enumeration value="EMAIL"/> 104 # <xs:enumeration value="GOOGLE"/> 105 # <xs:enumeration value="GIZMO"/> 106 # <xs:enumeration value="ICQ"/> 107 # <xs:enumeration value="JABBER"/> 108 # <xs:enumeration value="MSN"/> 109 # <xs:enumeration value="SIP"/> 110 # <xs:enumeration value="SKYPE"/> 111 # <xs:enumeration value="URL"/> 112 # <xs:enumeration value="XRI"/> 113 # <xs:enumeration value="YAHOO"/> 114 115 # @ToDo: Remove the T from the init() & T upon usage instead 116 117 MOBILE = current.deployment_settings.get_ui_label_mobile_phone() 118 # Full range of contact options 119 self.CONTACT_OPTS = {"EMAIL": T("Email"), 120 "FACEBOOK": T("Facebook"), 121 "FAX": T("Fax"), 122 "HOME_PHONE": T("Home Phone"), 123 "RADIO": T("Radio Callsign"), 124 "RSS": T("RSS Feed"), 125 "SKYPE": T("Skype"), 126 "SMS": MOBILE, 127 "TWITTER": T("Twitter"), 128 "WHATSAPP": T("WhatsApp"), 129 #"XMPP": "XMPP", 130 #"WEB": T("Website"), 131 "WORK_PHONE": T("Work phone"), 132 "IRC": T("IRC handle"), 133 "GITHUB": T("Github Repo"), 134 "GCM": T("Google Cloud Messaging"), 135 "LINKEDIN": T("LinkedIn Profile"), 136 "BLOG": T("Blog"), 137 "OTHER": T("Other") 138 } 139 140 # Those contact options to which we can send notifications 141 # NB Coded into hrm_map_popup & s3.msg.js 142 self.MSG_CONTACT_OPTS = {"EMAIL": T("Email"), 143 "SMS": MOBILE, 144 "TWITTER": T("Twitter"), 145 "FACEBOOK": T("Facebook"), 146 #"XMPP": "XMPP", 147 } 148 149 # SMS Gateways 150 self.GATEWAY_OPTS = {"MODEM": T("Modem"), 151 "SMTP": T("SMTP"), 152 "TROPO": T("Tropo"), 153 # Currently only available for Inbound 154 #"TWILIO": T("Twilio"), 155 "WEB_API": T("Web API"), 156 }
157 158 # ------------------------------------------------------------------------- 159 @staticmethod
160 - def sanitise_phone(phone, channel_id=None):
161 """ 162 Strip out unnecessary characters from the string: 163 +()- & space 164 """ 165 166 settings = current.deployment_settings 167 table = current.s3db.msg_sms_outbound_gateway 168 169 if channel_id: 170 row = current.db(table.channel_id == channel_id) \ 171 .select(limitby=(0, 1)).first() 172 default_country_code = row["msg_sms_outbound_gateway.default_country_code"] 173 else: 174 default_country_code = settings.get_L10n_default_country_code() 175 176 clean = phone.translate(IDENTITYTRANS, NOTPHONECHARS) 177 178 # If number starts with a 0 then need to remove this & add the country code in 179 if clean[0] == "0": 180 # Add default country code 181 if default_country_code == 39: 182 # Italy keeps 0 after country code 183 clean = "%s%s" % (default_country_code, clean) 184 else: 185 clean = "%s%s" % (default_country_code, 186 clean.lstrip("0")) 187 188 return clean
189 190 # ------------------------------------------------------------------------- 191 @staticmethod
192 - def decode_email(header):
193 """ 194 Decode an RFC2047-encoded email header (e.g. 195 "Dominic =?ISO-8859-1?Q?K=F6nig?=") and return it as unicode. 196 197 @param header: the header 198 """ 199 200 # Deal with missing word separation (thanks Ingmar Hupp) 201 header = re.sub(r"(=\?.*\?=)(?!$)", r"\1 ", header) 202 203 # Decode header 204 from email.header import decode_header 205 decoded = decode_header(header) 206 207 # Build string 208 return " ".join([s3_unicode(part[0], part[1] or "ASCII") 209 for part in decoded])
210 211 # ========================================================================= 212 # Inbound Messages 213 # ========================================================================= 214 @staticmethod
215 - def sort_by_sender(row):
216 """ 217 Helper method to sort messages according to sender priority. 218 """ 219 220 s3db = current.s3db 221 db = current.db 222 ptable = s3db.msg_parsing_status 223 mtable = s3db.msg_message 224 stable = s3db.msg_sender 225 226 try: 227 # @ToDo: Look at doing a Join? 228 pmessage = db(ptable.id == row.id).select(ptable.message_id) 229 m_id = pmessage.message_id 230 231 message = db(mtable.id == m_id).select(mtable.from_address, 232 limitby=(0, 1)).first() 233 sender = message.from_address 234 235 srecord = db(stable.sender == sender).select(stable.priority, 236 limitby=(0, 1)).first() 237 238 return srecord.priority 239 except: 240 import sys 241 # Return max value i.e. assign lowest priority 242 return sys.maxint
243 244 # ------------------------------------------------------------------------- 245 @staticmethod
246 - def parse(channel_id, function_name):
247 """ 248 Parse unparsed Messages from Channel with Parser 249 - called from Scheduler 250 251 @param channel_id: Channel 252 @param function_name: Parser 253 """ 254 255 from s3parser import S3Parsing 256 257 parser = S3Parsing.parser 258 stable = current.s3db.msg_parsing_status 259 query = (stable.channel_id == channel_id) & \ 260 (stable.is_parsed == False) 261 messages = current.db(query).select(stable.id, 262 stable.message_id) 263 for message in messages: 264 # Parse the Message 265 reply_id = parser(function_name, message.message_id) 266 # Update to show that we've parsed the message & provide a link to the reply 267 message.update_record(is_parsed=True, 268 reply_id=reply_id) 269 return
270 271 # ========================================================================= 272 # Outbound Messages 273 # =========================================================================
274 - def compose(self, 275 type = "SMS", 276 recipient_type = None, 277 recipient = None, 278 #@ToDo: 279 #sender = None, 280 #hide = True, 281 subject = "", 282 message = "", 283 url = None, 284 # @ToDo: re-implement 285 #formid = None, 286 ):
287 """ 288 Form to Compose a Message 289 290 @param type: The default message type: None, EMAIL, SMS or TWITTER 291 @param recipient_type: Send to Persons or Groups? (pr_person or pr_group) 292 @param recipient: The pe_id of the person/group to send the message to 293 - this can also be set by setting one of 294 (in priority order, if multiple found): 295 request.vars.pe_id 296 request.vars.person_id @ToDo 297 request.vars.group_id @ToDo 298 request.vars.hrm_id @ToDo 299 @param subject: The default subject text (for Emails) 300 @param message: The default message text 301 @param url: Redirect to the specified URL() after message sent 302 @param formid: If set, allows multiple forms open in different tabs 303 """ 304 305 if not url: 306 url = URL(c="msg", f="compose") 307 308 # Unauthenticated users aren't allowed to Compose Messages 309 auth = current.auth 310 if auth.is_logged_in() or auth.basic(): 311 pass 312 else: 313 redirect(URL(c="default", f="user", args="login", 314 vars={"_next" : url})) 315 316 # Authenticated users need to have update rights on the msg controller 317 if not auth.permission.has_permission("update", c="msg"): 318 current.session.error = current.T("You do not have permission to send messages") 319 redirect(URL(f="index")) 320 321 # Configure an instance of S3Compose 322 instance = S3Compose() 323 instance.contact_method = type 324 instance.recipient = recipient 325 instance.recipients = None 326 instance.recipient_type = recipient_type 327 instance.subject = subject 328 instance.message = message 329 #instance.formid = formid 330 instance.resource = None 331 instance.url = url 332 333 # Generate the form 334 form = instance._compose_form() 335 336 # Default title 337 # - can be overridden by the calling function 338 title = current.T("Send Message") 339 340 return dict(form = form, 341 title = title)
342 343 # ------------------------------------------------------------------------- 344 @staticmethod
345 - def send(recipient, message, subject=None):
346 """ 347 Send a single message to an Address 348 349 @param recipient: "email@address", "+4412345678", "@nick" 350 @param message: message body 351 @param subject: message subject (Email only) 352 """ 353 354 # Determine channel to send on based on format of recipient 355 if recipient.startswith("@"): 356 # Twitter 357 tablename = "msg_twitter" 358 elif "@" in recipient: 359 # Email 360 tablename = "msg_email" 361 else: 362 # SMS 363 tablename = "msg_sms"
364 365 # @ToDo: Complete this 366 367 # ------------------------------------------------------------------------- 368 @staticmethod
369 - def send_by_pe_id(pe_id, 370 subject = "", 371 message = "", 372 contact_method = "EMAIL", 373 document_ids = None, 374 from_address = None, 375 system_generated = False, 376 **data):
377 """ 378 Send a single message to a Person Entity (or list thereof) 379 380 @ToDo: contact_method = ALL 381 - look up the pr_contact options available for the pe & send via all 382 383 @ToDo: This is not transaction safe 384 - power failure in the middle will cause no message in the outbox 385 """ 386 387 s3db = current.s3db 388 389 # Place the Message in the appropriate Log 390 if contact_method == "EMAIL": 391 if not from_address: 392 # Fallback to system default 393 # @ToDo: Allow a multi-context lookup here for multi-tenancy? 394 from_address = current.deployment_settings.get_mail_sender() 395 396 table = s3db.msg_email 397 _id = table.insert(body=message, 398 subject=subject, 399 from_address=from_address, 400 #to_address=pe_id, 401 inbound=False, 402 ) 403 record = dict(id=_id) 404 s3db.update_super(table, record) 405 message_id = record["message_id"] 406 if document_ids: 407 ainsert = s3db.msg_attachment.insert 408 if not isinstance(document_ids, list): 409 document_ids = [document_ids] 410 for document_id in document_ids: 411 ainsert(message_id=message_id, 412 document_id=document_id, 413 ) 414 415 elif contact_method == "SMS": 416 table = s3db.msg_sms 417 _id = table.insert(body=message, 418 from_address=from_address, 419 inbound=False, 420 ) 421 record = dict(id=_id) 422 s3db.update_super(table, record) 423 message_id = record["message_id"] 424 elif contact_method == "TWITTER": 425 table = s3db.msg_twitter 426 _id = table.insert(body=message, 427 from_address=from_address, 428 inbound=False, 429 ) 430 record = dict(id=_id) 431 s3db.update_super(table, record) 432 message_id = record["message_id"] 433 else: 434 # @ToDo 435 raise NotImplementedError 436 437 # Place the Message in the main OutBox 438 table = s3db.msg_outbox 439 if isinstance(pe_id, list): 440 # Add an entry per recipient 441 listindex = 0 442 insert = table.insert 443 for _id in pe_id: 444 try: 445 insert(message_id = message_id, 446 pe_id = _id, 447 contact_method = contact_method, 448 system_generated = system_generated) 449 listindex = listindex + 1 450 except: 451 return listindex 452 else: 453 try: 454 table.insert(message_id = message_id, 455 pe_id = pe_id, 456 contact_method = contact_method, 457 system_generated = system_generated) 458 except: 459 return False 460 461 # Process OutBox async 462 current.s3task.async("msg_process_outbox", 463 args = [contact_method]) 464 465 # Perform post process after message sending 466 postp = current.deployment_settings.get_msg_send_postprocess() 467 if postp: 468 postp(message_id, **data) 469 470 return message_id
471 472 # -------------------------------------------------------------------------
473 - def process_outbox(self, contact_method="EMAIL"):
474 """ 475 Send pending messages from outbox (usually called from scheduler) 476 477 @param contact_method: the output channel (see pr_contact.method) 478 479 @todo: contact_method = "ALL" 480 """ 481 482 db = current.db 483 s3db = current.s3db 484 485 lookup_org = False 486 channels = {} 487 outgoing_sms_handler = None 488 channel_id = None 489 490 if contact_method == "SMS": 491 # Read all enabled Gateways 492 # - we assume there are relatively few & we may need to decide which to use based on the message's organisation 493 table = s3db.msg_sms_outbound_gateway 494 etable = db.msg_channel 495 query = (table.deleted == False) & \ 496 (table.channel_id == etable.channel_id) 497 rows = db(query).select(table.channel_id, 498 table.organisation_id, 499 etable.instance_type, 500 ) 501 if not rows: 502 # Raise exception here to make the scheduler 503 # task fail permanently until manually reset 504 raise ValueError("No SMS handler defined!") 505 506 if len(rows) == 1: 507 lookup_org = False 508 row = rows.first() 509 outgoing_sms_handler = row["msg_channel.instance_type"] 510 channel_id = row["msg_sms_outbound_gateway.channel_id"] 511 else: 512 lookup_org = True 513 org_branches = current.deployment_settings.get_org_branches() 514 if org_branches: 515 org_parents = s3db.org_parents 516 for row in rows: 517 channels[row["msg_sms_outbound_gateway.organisation_id"]] = \ 518 dict(outgoing_sms_handler = row["msg_channel.instance_type"], 519 channel_id = row["msg_sms_outbound_gateway.channel_id"]) 520 521 elif contact_method == "TWITTER": 522 twitter_settings = self.get_twitter_api() 523 if not twitter_settings: 524 # Raise exception here to make the scheduler 525 # task fail permanently 526 raise ValueError("No Twitter API available!") 527 528 def dispatch_to_pe_id(pe_id, 529 subject, 530 message, 531 outbox_id, 532 message_id, 533 attachments = [], 534 organisation_id = None, 535 contact_method = contact_method, 536 channel_id = channel_id, 537 from_address = None, 538 outgoing_sms_handler = outgoing_sms_handler, 539 lookup_org = lookup_org, 540 channels = channels): 541 """ 542 Helper method to send messages by pe_id 543 544 @param pe_id: the pe_id 545 @param subject: the message subject 546 @param message: the message body 547 @param outbox_id: the outbox record ID 548 @param message_id: the message_id 549 @param organisation_id: the organisation_id (for SMS) 550 @param contact_method: the contact method 551 """ 552 553 # Get the recipient's contact info 554 table = s3db.pr_contact 555 query = (table.pe_id == pe_id) & \ 556 (table.contact_method == contact_method) & \ 557 (table.deleted == False) 558 contact_info = db(query).select(table.value, 559 orderby=table.priority, 560 limitby=(0, 1)).first() 561 # Send the message 562 if contact_info: 563 address = contact_info.value 564 if contact_method == "EMAIL": 565 return self.send_email(address, 566 subject, 567 message, 568 sender = from_address, 569 attachments = attachments, 570 ) 571 elif contact_method == "SMS": 572 if lookup_org: 573 channel = channels.get(organisation_id) 574 if not channel and \ 575 org_branches: 576 orgs = org_parents(organisation_id) 577 for org in orgs: 578 channel = channels.get(org) 579 if channel: 580 break 581 if not channel: 582 # Look for an unrestricted channel 583 channel = channels.get(None) 584 if not channel: 585 # We can't send this message as there is no unrestricted channel & none which matches this Org 586 return False 587 outgoing_sms_handler = channel["outgoing_sms_handler"] 588 channel_id = channel["channel_id"] 589 if outgoing_sms_handler == "msg_sms_webapi_channel": 590 return self.send_sms_via_api(address, 591 message, 592 message_id, 593 channel_id) 594 elif outgoing_sms_handler == "msg_sms_smtp_channel": 595 return self.send_sms_via_smtp(address, 596 message, 597 channel_id) 598 elif outgoing_sms_handler == "msg_sms_modem_channel": 599 return self.send_sms_via_modem(address, 600 message, 601 channel_id) 602 elif outgoing_sms_handler == "msg_sms_tropo_channel": 603 # NB This does not mean the message is sent 604 return self.send_sms_via_tropo(outbox_id, 605 message_id, 606 address, 607 message, 608 channel_id) 609 elif contact_method == "TWITTER": 610 return self.send_tweet(message, address) 611 612 return False
613 614 outbox = s3db.msg_outbox 615 616 query = (outbox.contact_method == contact_method) & \ 617 (outbox.status == 1) & \ 618 (outbox.deleted == False) 619 620 petable = s3db.pr_pentity 621 left = [petable.on(petable.pe_id == outbox.pe_id)] 622 623 fields = [outbox.id, 624 outbox.message_id, 625 outbox.pe_id, 626 outbox.retries, 627 petable.instance_type, 628 ] 629 630 if contact_method == "EMAIL": 631 mailbox = s3db.msg_email 632 fields.extend([mailbox.subject, mailbox.body, mailbox.from_address]) 633 left.append(mailbox.on(mailbox.message_id == outbox.message_id)) 634 elif contact_method == "SMS": 635 mailbox = s3db.msg_sms 636 fields.append(mailbox.body) 637 if lookup_org: 638 fields.append(mailbox.organisation_id) 639 left.append(mailbox.on(mailbox.message_id == outbox.message_id)) 640 elif contact_method == "TWITTER": 641 mailbox = s3db.msg_twitter 642 fields.append(mailbox.body) 643 left.append(mailbox.on(mailbox.message_id == outbox.message_id)) 644 else: 645 # @ToDo 646 raise NotImplementedError 647 648 rows = db(query).select(*fields, 649 left=left, 650 orderby=~outbox.retries) 651 if not rows: 652 return 653 654 htable = s3db.table("hrm_human_resource") 655 otable = s3db.org_organisation 656 ptable = s3db.pr_person 657 gtable = s3db.pr_group 658 mtable = db.pr_group_membership 659 ftable = s3db.pr_forum 660 fmtable = db.pr_forum_membership 661 662 # Left joins for multi-recipient lookups 663 gleft = [mtable.on((mtable.group_id == gtable.id) & \ 664 (mtable.person_id != None) & \ 665 (mtable.deleted != True)), 666 ptable.on((ptable.id == mtable.person_id) & \ 667 (ptable.deleted != True)) 668 ] 669 fleft = [fmtable.on((fmtable.forum_id == ftable.id) & \ 670 (fmtable.person_id != None) & \ 671 (fmtable.deleted != True)), 672 ptable.on((ptable.id == fmtable.person_id) & \ 673 (ptable.deleted != True)) 674 ] 675 676 if htable: 677 oleft = [htable.on((htable.organisation_id == otable.id) & \ 678 (htable.person_id != None) & \ 679 (htable.deleted != True)), 680 ptable.on((ptable.id == htable.person_id) & \ 681 (ptable.deleted != True)), 682 ] 683 684 etable = s3db.hrm_training_event 685 ttable = s3db.hrm_training 686 tleft = [ttable.on((ttable.training_event_id == etable.id) & \ 687 (ttable.person_id != None) & \ 688 (ttable.deleted != True)), 689 ptable.on((ptable.id == ttable.person_id) & \ 690 (ptable.deleted != True)), 691 ] 692 693 atable = s3db.table("deploy_alert") 694 if atable: 695 ltable = db.deploy_alert_recipient 696 aleft = [ltable.on(ltable.alert_id == atable.id), 697 htable.on((htable.id == ltable.human_resource_id) & \ 698 (htable.person_id != None) & \ 699 (htable.deleted != True)), 700 ptable.on((ptable.id == htable.person_id) & \ 701 (ptable.deleted != True)) 702 ] 703 704 # chainrun: used to fire process_outbox again, 705 # when messages are sent to groups or organisations 706 chainrun = False 707 708 # Set a default for non-SMS 709 organisation_id = None 710 attachment_table = s3db.msg_attachment 711 document_table = s3db.doc_document 712 file_field = document_table.file 713 if file_field.custom_retrieve_file_properties: 714 retrieve_file_properties = file_field.custom_retrieve_file_properties 715 else: 716 retrieve_file_properties = file_field.retrieve_file_properties 717 mail_attachment = current.mail.Attachment 718 719 for row in rows: 720 attachments = [] 721 status = True 722 message_id = row.msg_outbox.message_id 723 724 if contact_method == "EMAIL": 725 subject = row["msg_email.subject"] or "" 726 message = row["msg_email.body"] or "" 727 from_address = row["msg_email.from_address"] or "" 728 query = (attachment_table.message_id == message_id) & \ 729 (attachment_table.deleted != True) & \ 730 (attachment_table.document_id == document_table.id) & \ 731 (document_table.deleted != True) 732 arows = db(query).select(file_field) 733 for arow in arows: 734 file = arow.file 735 prop = retrieve_file_properties(file) 736 _file_path = os.path.join(prop["path"], file) 737 attachments.append(mail_attachment(_file_path)) 738 elif contact_method == "SMS": 739 subject = None 740 message = row["msg_sms.body"] or "" 741 from_address = None 742 if lookup_org: 743 organisation_id = row["msg_sms.organisation_id"] 744 elif contact_method == "TWITTER": 745 subject = None 746 message = row["msg_twitter.body"] or "" 747 from_address = None 748 else: 749 # @ToDo 750 continue 751 752 entity_type = row["pr_pentity"].instance_type 753 if not entity_type: 754 current.log.warning("s3msg", "Entity type unknown") 755 continue 756 757 row = row["msg_outbox"] 758 pe_id = row.pe_id 759 message_id = row.message_id 760 761 if entity_type == "pr_person": 762 # Send the message to this person 763 try: 764 status = dispatch_to_pe_id( 765 pe_id, 766 subject, 767 message, 768 row.id, 769 message_id, 770 organisation_id = organisation_id, 771 from_address = from_address, 772 attachments = attachments, 773 ) 774 except: 775 status = False 776 777 elif entity_type == "pr_group": 778 # Re-queue the message for each member in the group 779 gquery = (gtable.pe_id == pe_id) 780 recipients = db(gquery).select(ptable.pe_id, left=gleft) 781 pe_ids = set(r.pe_id for r in recipients) 782 pe_ids.discard(None) 783 if pe_ids: 784 for pe_id in pe_ids: 785 outbox.insert(message_id=message_id, 786 pe_id=pe_id, 787 contact_method=contact_method, 788 system_generated=True) 789 chainrun = True 790 status = True 791 792 elif entity_type == "pr_forum": 793 # Re-queue the message for each member in the group 794 fquery = (ftable.pe_id == pe_id) 795 recipients = db(fquery).select(ptable.pe_id, left=fleft) 796 pe_ids = set(r.pe_id for r in recipients) 797 pe_ids.discard(None) 798 if pe_ids: 799 for pe_id in pe_ids: 800 outbox.insert(message_id=message_id, 801 pe_id=pe_id, 802 contact_method=contact_method, 803 system_generated=True) 804 chainrun = True 805 status = True 806 807 elif htable and entity_type == "org_organisation": 808 # Re-queue the message for each HR in the organisation 809 oquery = (otable.pe_id == pe_id) 810 recipients = db(oquery).select(ptable.pe_id, left=oleft) 811 pe_ids = set(r.pe_id for r in recipients) 812 pe_ids.discard(None) 813 if pe_ids: 814 for pe_id in pe_ids: 815 outbox.insert(message_id=message_id, 816 pe_id=pe_id, 817 contact_method=contact_method, 818 system_generated=True) 819 chainrun = True 820 status = True 821 822 elif entity_type == "hrm_training_event": 823 # Re-queue the message for each participant 824 equery = (etable.pe_id == pe_id) 825 recipients = db(equery).select(ptable.pe_id, left=tleft) 826 pe_ids = set(r.pe_id for r in recipients) 827 pe_ids.discard(None) 828 if pe_ids: 829 for pe_id in pe_ids: 830 outbox.insert(message_id=message_id, 831 pe_id=pe_id, 832 contact_method=contact_method, 833 system_generated=True) 834 chainrun = True 835 status = True 836 837 elif atable and entity_type == "deploy_alert": 838 # Re-queue the message for each HR in the group 839 aquery = (atable.pe_id == pe_id) 840 recipients = db(aquery).select(ptable.pe_id, left=aleft) 841 pe_ids = set(r.pe_id for r in recipients) 842 pe_ids.discard(None) 843 if pe_ids: 844 for pe_id in pe_ids: 845 outbox.insert(message_id=message_id, 846 pe_id=pe_id, 847 contact_method=contact_method, 848 system_generated=True) 849 chainrun = True 850 status = True 851 852 else: 853 # Unsupported entity type 854 row.update_record(status = 4) # Invalid 855 db.commit() 856 continue 857 858 if status: 859 row.update_record(status = 2) # Sent 860 db.commit() 861 else: 862 if row.retries > 0: 863 row.update_record(retries = row.retries - 1) 864 db.commit() 865 elif row.retries is not None: 866 row.update_record(status = 5) # Failed 867 868 if chainrun: 869 self.process_outbox(contact_method)
870 871 # ------------------------------------------------------------------------- 872 # Google Cloud Messaging Push 873 # -------------------------------------------------------------------------
874 - def gcm_push(self, title=None, uri=None, message=None, registration_ids=None, channel_id=None):
875 """ 876 Push the message relating to google cloud messaging server 877 878 @param title: The title for notification 879 @param message: The message to be sent to GCM server 880 @param api_key: The API key for GCM server 881 @param registration_ids: The list of id that will be notified 882 @param channel_id: The specific channel_id to use for GCM push 883 """ 884 885 if not title or not uri or not message or not len(registration_ids): 886 return 887 888 from gcm import GCM 889 gcmtable = current.s3db.msg_gcm_channel 890 if channel_id: 891 query = (gcmtable.channel_id == channel_id) 892 else: 893 query = (gcmtable.enabled == True) & (gcmtable.deleted != True) 894 895 row = current.db(query).select(gcmtable.api_key, 896 limitby=(0, 1)).first() 897 try: 898 gcm = GCM(row.api_key) 899 except Exception as e: 900 current.log.error("No API Key configured for GCM: ", e) 901 else: 902 try: 903 # @ToDo: Store notification in outbox if-required 904 # @ToDo: Implement other methods for GCM as required 905 # See: https://github.com/geeknam/python-gcm 906 notification = {"title": title, 907 "message": message, 908 "uri": uri, 909 } 910 response = gcm.json_request(registration_ids=registration_ids, 911 data=notification) 912 except Exception as e: 913 current.log.error("Google Cloud Messaging Error", e) 914 else: 915 if "errors" in response: 916 for error, reg_ids in response["errors"].items(): 917 for reg_id in reg_ids: 918 current.log.error("Google Cloud Messaging Error: %s for Registration ID %s" % (error, reg_id)) 919 # @ToDo: Handle for canonical when required 920 #if "canonical" in response: 921 # for reg_id, canonical_id in response["canonical"].items(): 922 # @ToDo: Replace registration_id with canonical_id 923 return
924 925 # ------------------------------------------------------------------------- 926 # Send Email 927 # -------------------------------------------------------------------------
928 - def send_email(self, 929 to, 930 subject, 931 message, 932 attachments=None, 933 cc=None, 934 bcc=None, 935 reply_to=None, 936 sender=None, 937 encoding="utf-8", 938 #from_address=None, 939 ):
940 """ 941 Function to send Email 942 - simple Wrapper over Web2Py's Email API 943 """ 944 945 if not to: 946 return False 947 948 settings = current.deployment_settings 949 950 default_sender = settings.get_mail_sender() 951 if not default_sender: 952 current.log.warning("Email sending disabled until the Sender address has been set in models/000_config.py") 953 return False 954 955 if not sender: 956 sender = default_sender 957 sender = self.sanitize_sender(sender) 958 959 limit = settings.get_mail_limit() 960 if limit: 961 # Check whether we've reached our daily limit 962 day = datetime.timedelta(hours=24) 963 cutoff = current.request.utcnow - day 964 table = current.s3db.msg_channel_limit 965 # @ToDo: Include Channel Info 966 check = current.db(table.created_on > cutoff).count() 967 if check >= limit: 968 return False 969 # Log the sending 970 table.insert() 971 972 result = current.mail.send(to, 973 subject=subject, 974 message=message, 975 attachments=attachments, 976 cc=cc, 977 bcc=bcc, 978 reply_to=reply_to, 979 sender=sender, 980 encoding=encoding, 981 # e.g. Return-Receipt-To:<user@domain> 982 headers={}, 983 # Added to Web2Py 2014-03-04 984 # - defaults to sender 985 #from_address=from_address, 986 ) 987 if not result: 988 current.session.error = current.mail.error 989 else: 990 current.session.error = None 991 992 return result
993 994 # ------------------------------------------------------------------------- 995 @staticmethod
996 - def sanitize_sender(sender):
997 """ 998 Sanitize the email sender string to prevent MIME-encoding 999 of the from-address (RFC2047) 1000 1001 @param: the sender-string 1002 @returns: the sanitized sender-string 1003 """ 1004 1005 if not sender: 1006 return sender 1007 match = SENDER.match(sender) 1008 if match: 1009 sender_name, from_address = match.groups() 1010 if any(32 > ord(c) or ord(c) > 127 for c in sender_name): 1011 from email.header import Header 1012 sender_name = Header(sender_name.strip(), "utf-8") 1013 else: 1014 sender_name = sender_name.strip() 1015 sender = "%s <%s>" % (sender_name, from_address.strip()) 1016 return sender
1017 1018 # -------------------------------------------------------------------------
1019 - def send_email_by_pe_id(self, 1020 pe_id, 1021 subject="", 1022 message="", 1023 from_address=None, 1024 system_generated=False):
1025 """ 1026 API wrapper over send_by_pe_id 1027 """ 1028 1029 return self.send_by_pe_id(pe_id, 1030 subject, 1031 message, 1032 "EMAIL", 1033 from_address, 1034 system_generated)
1035 1036 # ========================================================================= 1037 # SMS 1038 # ========================================================================= 1039 1040 # ------------------------------------------------------------------------- 1041 # OpenGeoSMS 1042 # ------------------------------------------------------------------------- 1043 @staticmethod
1044 - def prepare_opengeosms(location_id, code="S", map="google", text=""):
1045 """ 1046 Function to create an OpenGeoSMS 1047 1048 @param: location_id - reference to record in gis_location table 1049 @param: code - the type of OpenGeoSMS: 1050 S = Sahana 1051 SI = Incident Report 1052 ST = Task Dispatch 1053 @param: map: "google" or "osm" 1054 @param: text - the rest of the message 1055 1056 Returns the formatted OpenGeoSMS or None if it can't find 1057 an appropriate location 1058 """ 1059 1060 if not location_id: 1061 return text 1062 1063 db = current.db 1064 s3db = current.s3db 1065 table = s3db.gis_location 1066 query = (table.id == location_id) 1067 location = db(query).select(table.lat, 1068 table.lon, 1069 #table.path, 1070 #table.parent, 1071 limitby=(0, 1)).first() 1072 if not location: 1073 return text 1074 lat = location.lat 1075 lon = location.lon 1076 if lat is None or lon is None: 1077 # @ToDo: Should we try parents? Or would that not be granular enough anyway? 1078 return text 1079 1080 code = "GeoSMS=%s" % code 1081 1082 if map == "google": 1083 url = "http://maps.google.com/?q=%f,%f" % (lat, lon) 1084 elif map == "osm": 1085 # NB Not sure how this will work in OpenGeoSMS client 1086 url = "http://openstreetmap.org?mlat=%f&mlon=%f&zoom=14" % (lat, lon) 1087 1088 opengeosms = "%s&%s\n%s" % (url, code, text) 1089 1090 return opengeosms
1091 1092 # ------------------------------------------------------------------------- 1093 @staticmethod
1094 - def parse_opengeosms(message):
1095 """ 1096 Function to parse an OpenGeoSMS 1097 @param: message - Inbound message to be parsed for OpenGeoSMS. 1098 Returns the lat, lon, code and text contained in the message. 1099 """ 1100 1101 lat = "" 1102 lon = "" 1103 code = "" 1104 text = "" 1105 1106 words = message.split(" ") 1107 if "http://maps.google.com/?q" in words[0]: 1108 # Parse OpenGeoSMS 1109 pwords = words[0].split("?q=")[1].split(",") 1110 lat = pwords[0] 1111 lon = pwords[1].split("&")[0] 1112 code = pwords[1].split("&")[1].split("=")[1] 1113 text = "" 1114 for a in range(1, len(words)): 1115 text = text + words[a] + " " 1116 1117 1118 return lat, lon, code, text
1119 1120 # ------------------------------------------------------------------------- 1121 # Send SMS 1122 # -------------------------------------------------------------------------
1123 - def send_sms_via_api(self, 1124 mobile, 1125 text = "", 1126 message_id = None, 1127 channel_id = None, 1128 ):
1129 """ 1130 Function to send SMS via Web API 1131 """ 1132 1133 db = current.db 1134 s3db = current.s3db 1135 table = s3db.msg_sms_webapi_channel 1136 1137 # Get Configuration 1138 if channel_id: 1139 sms_api = db(table.channel_id == channel_id).select(limitby=(0, 1) 1140 ).first() 1141 else: 1142 # @ToDo: Check for Organisation-specific Gateway 1143 sms_api = db(table.enabled == True).select(limitby=(0, 1)).first() 1144 if not sms_api: 1145 return False 1146 1147 post_data = {} 1148 1149 parts = sms_api.parameters.split("&") 1150 for p in parts: 1151 post_data[p.split("=")[0]] = p.split("=")[1] 1152 1153 mobile = self.sanitise_phone(mobile, channel_id) 1154 1155 # To send non-ASCII characters in UTF-8 encoding, we'd need 1156 # to hex-encode the text and activate unicode=1, but this 1157 # would limit messages to 70 characters, and many mobile 1158 # phones can't display unicode anyway. 1159 1160 # To be however able to send messages with at least special 1161 # European characters like á or ø, we convert the UTF-8 to 1162 # the default ISO-8859-1 (latin-1) here: 1163 text_latin1 = s3_unicode(text).encode("utf-8") \ 1164 .decode("utf-8") \ 1165 .encode("iso-8859-1") 1166 1167 post_data[sms_api.message_variable] = text_latin1 1168 post_data[sms_api.to_variable] = str(mobile) 1169 1170 url = sms_api.url 1171 clickatell = "clickatell" in url 1172 if clickatell: 1173 text_len = len(text) 1174 if text_len > 480: 1175 current.log.error("Clickatell messages cannot exceed 480 chars") 1176 return False 1177 elif text_len > 320: 1178 post_data["concat"] = 3 1179 elif text_len > 160: 1180 post_data["concat"] = 2 1181 1182 request = urllib2.Request(url) 1183 query = urllib.urlencode(post_data) 1184 if sms_api.username and sms_api.password: 1185 # e.g. Mobile Commons 1186 base64string = base64.encodestring("%s:%s" % (sms_api.username, sms_api.password)).replace("\n", "") 1187 request.add_header("Authorization", "Basic %s" % base64string) 1188 try: 1189 result = urllib2.urlopen(request, query) 1190 except urllib2.HTTPError, e: 1191 current.log.error("SMS message send failed: %s" % e) 1192 return False 1193 else: 1194 # Parse result 1195 output = result.read() 1196 if clickatell: 1197 if output.startswith("ERR"): 1198 current.log.error("Clickatell message send failed: %s" % output) 1199 return False 1200 elif message_id and output.startswith("ID"): 1201 # Store ID from Clickatell to be able to followup 1202 remote_id = output[4:] 1203 db(s3db.msg_sms.message_id == message_id).update(remote_id=remote_id) 1204 elif "mcommons" in url: 1205 # http://www.mobilecommons.com/mobile-commons-api/rest/#errors 1206 # Good = <response success="true"></response> 1207 # Bad = <response success="false"><errror id="id" message="message"></response> 1208 if "error" in output: 1209 current.log.error("Mobile Commons message send failed: %s" % output) 1210 return False 1211 1212 return True
1213 1214 # -------------------------------------------------------------------------
1215 - def send_sms_via_modem(self, mobile, text="", channel_id=None):
1216 """ 1217 Function to send SMS via locally-attached Modem 1218 - needs to have the cron/sms_handler_modem.py script running 1219 """ 1220 1221 mobile = self.sanitise_phone(mobile, channel_id) 1222 1223 # Add '+' before country code 1224 mobile = "+%s" % mobile 1225 1226 try: 1227 self.modem.send_sms(mobile, text) 1228 return True 1229 except KeyError: 1230 current.log.error("s3msg", "Modem not available: need to have the cron/sms_handler_modem.py script running") 1231 return False
1232 1233 # -------------------------------------------------------------------------
1234 - def send_sms_via_smtp(self, mobile, text="", channel_id=None):
1235 """ 1236 Function to send SMS via SMTP 1237 1238 NB Different Gateways have different requirements for presence/absence of International code 1239 1240 http://en.wikipedia.org/wiki/List_of_SMS_gateways 1241 http://www.obviously.com/tech_tips/SMS_Text_Email_Gateway.html 1242 """ 1243 1244 table = current.s3db.msg_sms_smtp_channel 1245 if channel_id: 1246 query = (table.channel_id == channel_id) 1247 else: 1248 query = (table.enabled == True) 1249 settings = current.db(query).select(limitby=(0, 1) 1250 ).first() 1251 if not settings: 1252 return False 1253 1254 mobile = self.sanitise_phone(mobile, channel_id) 1255 1256 to = "%s@%s" % (mobile, 1257 settings.address) 1258 1259 try: 1260 result = self.send_email(to=to, 1261 subject="", 1262 message= text) 1263 return result 1264 except: 1265 return False
1266 1267 #-------------------------------------------------------------------------------------------------
1268 - def send_sms_via_tropo(self, 1269 row_id, 1270 message_id, 1271 recipient, 1272 message, 1273 network = "SMS", 1274 channel_id = None, 1275 ):
1276 """ 1277 Send a URL request to Tropo to pick a message up 1278 """ 1279 1280 db = current.db 1281 s3db = current.s3db 1282 table = s3db.msg_tropo_channel 1283 1284 base_url = "http://api.tropo.com/1.0/sessions" 1285 action = "create" 1286 1287 if channel_id: 1288 query = (table.channel_id == channel_id) 1289 else: 1290 query = (table.enabled == True) 1291 tropo_settings = db(query).select(table.token_messaging, 1292 limitby=(0, 1)).first() 1293 if tropo_settings: 1294 tropo_token_messaging = tropo_settings.token_messaging 1295 #tropo_token_voice = tropo_settings.token_voice 1296 else: 1297 return 1298 1299 if network == "SMS": 1300 recipient = self.sanitise_phone(recipient, channel_id) 1301 1302 try: 1303 s3db.msg_tropo_scratch.insert(row_id = row_id, 1304 message_id = message_id, 1305 recipient = recipient, 1306 message = message, 1307 network = network) 1308 params = urllib.urlencode([("action", action), 1309 ("token", tropo_token_messaging), 1310 ("outgoing", "1"), 1311 ("row_id", row_id) 1312 ]) 1313 xml = urllib2.urlopen("%s?%s" % (base_url, params)).read() 1314 # Parse Response (actual message is sent as a response to the POST which will happen in parallel) 1315 #root = etree.fromstring(xml) 1316 #elements = root.getchildren() 1317 #if elements[0].text == "false": 1318 # session.error = T("Message sending failed! Reason:") + " " + elements[2].text 1319 # redirect(URL(f='index')) 1320 #else: 1321 # session.flash = T("Message Sent") 1322 # redirect(URL(f='index')) 1323 except: 1324 pass 1325 1326 # Return False because the API needs to ask us for the messsage again. 1327 return False
1328 1329 # -------------------------------------------------------------------------
1330 - def send_sms_by_pe_id(self, 1331 pe_id, 1332 message="", 1333 from_address=None, 1334 system_generated=False):
1335 """ 1336 API wrapper over send_by_pe_id 1337 """ 1338 1339 return self.send_by_pe_id(pe_id, 1340 subject = "", 1341 message = message, 1342 contact_method = "SMS", 1343 from_address = from_address, 1344 system_generated = system_generated, 1345 )
1346 1347 # ------------------------------------------------------------------------- 1348 # Twitter 1349 # ------------------------------------------------------------------------- 1350 @staticmethod
1351 - def _sanitise_twitter_account(account):
1352 """ 1353 Only keep characters that are legal for a twitter account: 1354 letters, digits, and _ 1355 """ 1356 1357 return account.translate(IDENTITYTRANS, NOTTWITTERCHARS)
1358 1359 # ------------------------------------------------------------------------- 1360 @staticmethod
1361 - def _break_to_chunks(text, 1362 chunk_size=TWITTER_MAX_CHARS, 1363 suffix = TWITTER_HAS_NEXT_SUFFIX, 1364 prefix = TWITTER_HAS_PREV_PREFIX):
1365 """ 1366 Breaks text to <=chunk_size long chunks. Tries to do this at a space. 1367 All chunks, except for last, end with suffix. 1368 All chunks, except for first, start with prefix. 1369 """ 1370 1371 from s3 import s3_str 1372 res = [] 1373 current_prefix = "" # first chunk has no prefix 1374 while text: 1375 if len(current_prefix + text) <= chunk_size: 1376 res.append(current_prefix + text) 1377 return res 1378 else: # break a chunk 1379 c = text[:chunk_size - len(current_prefix) - len(suffix)] 1380 i = c.rfind(" ") 1381 if i > 0: # got a blank 1382 c = c[:i] 1383 text = text[len(c):].lstrip() 1384 res.append(current_prefix + c.rstrip() + s3_str(suffix)) 1385 current_prefix = s3_str(prefix) # from now on, we want a prefix
1386 1387 # ------------------------------------------------------------------------- 1388 @staticmethod
1389 - def get_twitter_api(channel_id=None):
1390 """ 1391 Initialize Twitter API 1392 """ 1393 1394 try: 1395 import tweepy 1396 except ImportError: 1397 current.log.error("s3msg", "Tweepy not available, so non-Tropo Twitter support disabled") 1398 return None 1399 1400 table = current.s3db.msg_twitter_channel 1401 if not channel_id: 1402 # Try the 1st enabled one in the DB 1403 query = (table.enabled == True) 1404 limitby = None 1405 else: 1406 query = (table.channel_id == channel_id) 1407 limitby = (0, 1) 1408 1409 rows = current.db(query).select(table.login, 1410 table.twitter_account, 1411 table.consumer_key, 1412 table.consumer_secret, 1413 table.access_token, 1414 table.access_token_secret, 1415 limitby=limitby 1416 ) 1417 if len(rows) == 1: 1418 c = rows.first() 1419 elif not len(rows): 1420 current.log.error("s3msg", "No Twitter channels configured") 1421 return None 1422 else: 1423 # Filter to just the login channel 1424 rows.exclude(lambda row: row.login != True) 1425 if len(rows) == 1: 1426 c = rows.first() 1427 elif not len(rows): 1428 current.log.error("s3msg", "No Twitter channels configured for login") 1429 return None 1430 1431 if not c.consumer_key: 1432 current.log.error("s3msg", "Twitter channel has no consumer key") 1433 return None 1434 1435 try: 1436 oauth = tweepy.OAuthHandler(c.consumer_key, 1437 c.consumer_secret) 1438 oauth.set_access_token(c.access_token, 1439 c.access_token_secret) 1440 twitter_api = tweepy.API(oauth) 1441 return (twitter_api, c.twitter_account) 1442 except: 1443 return None
1444 1445 # -------------------------------------------------------------------------
1446 - def send_tweet(self, text="", recipient=None, **data):
1447 """ 1448 Function to tweet. 1449 If a recipient is specified then we send via direct message if the recipient follows us. 1450 - falls back to @mention (leaves less characters for the message). 1451 Breaks long text to chunks if needed. 1452 1453 @ToDo: Option to Send via Tropo 1454 """ 1455 1456 # Initialize Twitter API 1457 twitter_settings = self.get_twitter_api() 1458 if not twitter_settings: 1459 # Abort 1460 return False 1461 1462 import tweepy 1463 1464 twitter_api = twitter_settings[0] 1465 twitter_account = twitter_settings[1] 1466 1467 from_address = twitter_api.me().screen_name 1468 1469 db = current.db 1470 s3db = current.s3db 1471 table = s3db.msg_twitter 1472 otable = s3db.msg_outbox 1473 1474 message_id = None 1475 1476 def log_tweet(tweet, recipient, from_address): 1477 # Log in msg_twitter 1478 _id = table.insert(body=tweet, 1479 from_address=from_address, 1480 ) 1481 record = db(table.id == _id).select(table.id, 1482 limitby=(0, 1) 1483 ).first() 1484 s3db.update_super(table, record) 1485 message_id = record.message_id 1486 1487 # Log in msg_outbox 1488 otable.insert(message_id = message_id, 1489 address = recipient, 1490 status = 2, 1491 contact_method = "TWITTER", 1492 ) 1493 return message_id
1494 1495 if recipient: 1496 recipient = self._sanitise_twitter_account(recipient) 1497 try: 1498 can_dm = recipient == twitter_account or \ 1499 twitter_api.get_user(recipient).id in twitter_api.followers_ids(twitter_account) 1500 except tweepy.TweepError: 1501 # recipient not found 1502 return False 1503 if can_dm: 1504 chunks = self._break_to_chunks(text) 1505 for c in chunks: 1506 try: 1507 # Note: send_direct_message() requires explicit kwargs (at least in tweepy 1.5) 1508 # See http://groups.google.com/group/tweepy/msg/790fcab8bc6affb5 1509 if twitter_api.send_direct_message(screen_name=recipient, 1510 text=c): 1511 message_id = log_tweet(c, recipient, from_address) 1512 1513 except tweepy.TweepError: 1514 current.log.error("Unable to Tweet DM") 1515 else: 1516 prefix = "@%s " % recipient 1517 chunks = self._break_to_chunks(text, 1518 TWITTER_MAX_CHARS - len(prefix)) 1519 for c in chunks: 1520 try: 1521 twitter_api.update_status("%s %s" % prefix, c) 1522 except tweepy.TweepError: 1523 current.log.error("Unable to Tweet @mention") 1524 else: 1525 message_id = log_tweet(c, recipient, from_address) 1526 else: 1527 chunks = self._break_to_chunks(text) 1528 for c in chunks: 1529 try: 1530 twitter_api.update_status(c) 1531 except tweepy.TweepError: 1532 current.log.error("Unable to Tweet") 1533 else: 1534 message_id = log_tweet(c, recipient, from_address) 1535 1536 # Perform post process after message sending 1537 if message_id: 1538 postp = current.deployment_settings.get_msg_send_postprocess() 1539 if postp: 1540 postp(message_id, **data) 1541 1542 return True 1543 1544 #------------------------------------------------------------------------------
1545 - def post_to_facebook(self, text="", channel_id=None, recipient=None, **data):
1546 """ 1547 Posts a message on Facebook 1548 1549 https://developers.facebook.com/docs/graph-api 1550 """ 1551 1552 db = current.db 1553 s3db = current.s3db 1554 table = s3db.msg_facebook_channel 1555 if not channel_id: 1556 # Try the 1st enabled one in the DB 1557 query = (table.enabled == True) 1558 else: 1559 query = (table.channel_id == channel_id) 1560 1561 c = db(query).select(table.app_id, 1562 table.app_secret, 1563 table.page_id, 1564 table.page_access_token, 1565 limitby=(0, 1) 1566 ).first() 1567 1568 import facebook 1569 1570 try: 1571 app_access_token = facebook.get_app_access_token(c.app_id, 1572 c.app_secret) 1573 except: 1574 import sys 1575 message = sys.exc_info()[1] 1576 current.log.error("S3MSG: %s" % message) 1577 return 1578 1579 table = s3db.msg_facebook 1580 otable = s3db.msg_outbox 1581 1582 message_id = None 1583 1584 def log_facebook(post, recipient, from_address): 1585 # Log in msg_facebook 1586 _id = table.insert(body=post, 1587 from_address=from_address, 1588 ) 1589 record = db(table.id == _id).select(table.id, 1590 limitby=(0, 1) 1591 ).first() 1592 s3db.update_super(table, record) 1593 message_id = record.message_id 1594 1595 # Log in msg_outbox 1596 otable.insert(message_id = message_id, 1597 address = recipient, 1598 status = 2, 1599 contact_method = "FACEBOOK", 1600 ) 1601 return message_id
1602 1603 graph = facebook.GraphAPI(app_access_token) 1604 1605 page_id = c.page_id 1606 if page_id: 1607 graph = facebook.GraphAPI(c.page_access_token) 1608 graph.put_object(page_id, "feed", message=text) 1609 else: 1610 # FIXME user_id does not exist: 1611 #graph.put_object(user_id, "feed", message=text) 1612 raise NotImplementedError 1613 1614 message_id = log_facebook(text, recipient, channel_id) 1615 1616 # Perform post process after message sending 1617 if message_id: 1618 postp = current.deployment_settings.get_msg_send_postprocess() 1619 if postp: 1620 postp(message_id, **data) 1621 1622 # -------------------------------------------------------------------------
1623 - def poll(self, tablename, channel_id):
1624 """ 1625 Poll a Channel for New Messages 1626 """ 1627 1628 channel_type = tablename.split("_", 2)[1] 1629 # Launch the correct Poller 1630 function_name = "poll_%s" % channel_type 1631 try: 1632 fn = getattr(S3Msg, function_name) 1633 except: 1634 error = "Unsupported Channel: %s" % channel_type 1635 current.log.error(error) 1636 return error 1637 1638 result = fn(channel_id) 1639 return result
1640 1641 # ------------------------------------------------------------------------- 1642 @staticmethod
1643 - def poll_email(channel_id):
1644 """ 1645 This is a simple mailbox polling script for the Messaging Module. 1646 It is normally called from the scheduler. 1647 1648 @ToDo: If there is a need to collect from non-compliant mailers 1649 then suggest using the robust Fetchmail to collect & store 1650 in a more compliant mailer! 1651 @ToDo: If delete_from_server is false, we don't want to download the 1652 same messages repeatedly. Perhaps record time of fetch runs 1653 (or use info from the scheduler_run table), compare w/ message 1654 timestamp, as a filter. That may not be completely accurate, 1655 so could check msg_email for messages close to the last 1656 fetch time. Or just advise people to have a dedicated account 1657 to which email is sent, that does not also need to be read 1658 by humans. Or don't delete the fetched mail until the next run. 1659 """ 1660 1661 db = current.db 1662 s3db = current.s3db 1663 1664 table = s3db.msg_email_channel 1665 # Read-in configuration from Database 1666 query = (table.channel_id == channel_id) 1667 channel = db(query).select(table.username, 1668 table.password, 1669 table.server, 1670 table.protocol, 1671 table.use_ssl, 1672 table.port, 1673 table.delete_from_server, 1674 limitby=(0, 1)).first() 1675 if not channel: 1676 return "No Such Email Channel: %s" % channel_id 1677 1678 import email 1679 #import mimetypes 1680 import socket 1681 1682 from dateutil import parser 1683 date_parse = parser.parse 1684 1685 username = channel.username 1686 password = channel.password 1687 host = channel.server 1688 protocol = channel.protocol 1689 ssl = channel.use_ssl 1690 port = int(channel.port) 1691 delete = channel.delete_from_server 1692 1693 mtable = db.msg_email 1694 minsert = mtable.insert 1695 stable = db.msg_channel_status 1696 sinsert = stable.insert 1697 atable = s3db.msg_attachment 1698 ainsert = atable.insert 1699 dtable = db.doc_document 1700 dinsert = dtable.insert 1701 store = dtable.file.store 1702 update_super = s3db.update_super 1703 # Is this channel connected to a parser? 1704 parser = s3db.msg_parser_enabled(channel_id) 1705 if parser: 1706 ptable = db.msg_parsing_status 1707 pinsert = ptable.insert 1708 1709 # --------------------------------------------------------------------- 1710 def parse_email(message): 1711 """ 1712 Helper to parse the mail 1713 """ 1714 1715 # Create a Message object 1716 msg = email.message_from_string(message) 1717 # Parse the Headers 1718 sender = msg["from"] 1719 subject = msg.get("subject", "") 1720 date_sent = msg.get("date", None) 1721 # Store the whole raw message 1722 raw = msg.as_string() 1723 # Parse out the 'Body' 1724 # Look for Attachments 1725 attachments = [] 1726 # http://docs.python.org/2/library/email-examples.html 1727 body = "" 1728 for part in msg.walk(): 1729 if part.get_content_maintype() == "multipart": 1730 # multipart/* are just containers 1731 continue 1732 filename = part.get_filename() 1733 if not filename: 1734 # Assume this is the Message Body (plain text or HTML) 1735 if not body: 1736 # Plain text will come first 1737 body = part.get_payload(decode=True) 1738 continue 1739 attachments.append((filename, part.get_payload(decode=True))) 1740 1741 # Store in DB 1742 data = dict(channel_id=channel_id, 1743 from_address=sender, 1744 subject=subject[:78], 1745 body=body, 1746 raw=raw, 1747 inbound=True, 1748 ) 1749 if date_sent: 1750 data["date"] = date_parse(date_sent) 1751 _id = minsert(**data) 1752 record = dict(id=_id) 1753 update_super(mtable, record) 1754 message_id = record["message_id"] 1755 for a in attachments: 1756 # Linux ext2/3 max filename length = 255 1757 # b16encode doubles length & need to leave room for doc_document.file.16charsuuid. 1758 # store doesn't support unicode, so need an ascii string 1759 filename = s3_unicode(a[0][:92]).encode("ascii", "ignore") 1760 fp = StringIO() 1761 fp.write(a[1]) 1762 fp.seek(0) 1763 newfilename = store(fp, filename) 1764 fp.close() 1765 document_id = dinsert(name=filename, 1766 file=newfilename) 1767 update_super(dtable, dict(id=document_id)) 1768 ainsert(message_id=message_id, 1769 document_id=document_id) 1770 if parser: 1771 pinsert(message_id=message_id, 1772 channel_id=channel_id)
1773 1774 dellist = [] 1775 if protocol == "pop3": 1776 import poplib 1777 # http://docs.python.org/library/poplib.html 1778 try: 1779 if ssl: 1780 p = poplib.POP3_SSL(host, port) 1781 else: 1782 p = poplib.POP3(host, port) 1783 except socket.error, e: 1784 error = "Cannot connect: %s" % e 1785 current.log.error(error) 1786 # Store status in the DB 1787 sinsert(channel_id=channel_id, 1788 status=error) 1789 return error 1790 except poplib.error_proto, e: 1791 # Something else went wrong - probably transient (have seen '-ERR EOF' here) 1792 current.log.error("Email poll failed: %s" % e) 1793 return 1794 1795 try: 1796 # Attempting APOP authentication... 1797 p.apop(username, password) 1798 except poplib.error_proto: 1799 # Attempting standard authentication... 1800 try: 1801 p.user(username) 1802 p.pass_(password) 1803 except poplib.error_proto, e: 1804 error = "Login failed: %s" % e 1805 current.log.error(error) 1806 # Store status in the DB 1807 sinsert(channel_id=channel_id, 1808 status=error) 1809 return error 1810 1811 mblist = p.list()[1] 1812 for item in mblist: 1813 number, octets = item.split(" ") 1814 # Retrieve the message (storing it in a list of lines) 1815 lines = p.retr(number)[1] 1816 parse_email("\n".join(lines)) 1817 if delete: 1818 # Add it to the list of messages to delete later 1819 dellist.append(number) 1820 # Iterate over the list of messages to delete 1821 for number in dellist: 1822 p.dele(number) 1823 p.quit() 1824 1825 elif protocol == "imap": 1826 import imaplib 1827 # http://docs.python.org/library/imaplib.html 1828 try: 1829 if ssl: 1830 M = imaplib.IMAP4_SSL(host, port) 1831 else: 1832 M = imaplib.IMAP4(host, port) 1833 except socket.error, e: 1834 error = "Cannot connect: %s" % e 1835 current.log.error(error) 1836 # Store status in the DB 1837 sinsert(channel_id=channel_id, 1838 status=error) 1839 return error 1840 1841 try: 1842 M.login(username, password) 1843 except M.error, e: 1844 error = "Login failed: %s" % e 1845 current.log.error(error) 1846 # Store status in the DB 1847 sinsert(channel_id=channel_id, 1848 status=error) 1849 # Explicitly commit DB operations when running from Cron 1850 db.commit() 1851 return error 1852 1853 # Select inbox 1854 M.select() 1855 # Search for Messages to Download 1856 typ, data = M.search(None, "ALL") 1857 mblist = data[0].split() 1858 for number in mblist: 1859 typ, msg_data = M.fetch(number, "(RFC822)") 1860 for response_part in msg_data: 1861 if isinstance(response_part, tuple): 1862 parse_email(response_part[1]) 1863 if delete: 1864 # Add it to the list of messages to delete later 1865 dellist.append(number) 1866 # Iterate over the list of messages to delete 1867 for number in dellist: 1868 typ, response = M.store(number, "+FLAGS", r"(\Deleted)") 1869 M.close() 1870 M.logout() 1871 1872 # ------------------------------------------------------------------------- 1873 @staticmethod
1874 - def poll_mcommons(channel_id):
1875 """ 1876 Fetches the inbound SMS from Mobile Commons API 1877 http://www.mobilecommons.com/mobile-commons-api/rest/#ListIncomingMessages 1878 """ 1879 1880 db = current.db 1881 s3db = current.s3db 1882 table = s3db.msg_mcommons_channel 1883 query = (table.channel_id == channel_id) 1884 channel = db(query).select(table.url, 1885 table.campaign_id, 1886 table.username, 1887 table.password, 1888 table.query, 1889 table.timestmp, 1890 limitby=(0, 1)).first() 1891 if not channel: 1892 return "No Such MCommons Channel: %s" % channel_id 1893 1894 url = channel.url 1895 username = channel.username 1896 password = channel.password 1897 _query = channel.query 1898 timestamp = channel.timestmp 1899 1900 url = "%s?campaign_id=%s" % (url, channel.campaign_id) 1901 if timestamp: 1902 url = "%s&start_time=%s" % (url, timestamp) 1903 if _query: 1904 url = "%s&query=%s" % (url, _query) 1905 1906 # Create a password manager 1907 passman = urllib2.HTTPPasswordMgrWithDefaultRealm() 1908 passman.add_password(None, url, username, password) 1909 1910 # Create the AuthHandler 1911 authhandler = urllib2.HTTPBasicAuthHandler(passman) 1912 opener = urllib2.build_opener(authhandler) 1913 urllib2.install_opener(opener) 1914 1915 # Update the timestamp 1916 # NB Ensure MCommons account is in UTC 1917 db(query).update(timestmp = current.request.utcnow) 1918 1919 try: 1920 _response = urllib2.urlopen(url) 1921 except urllib2.HTTPError, e: 1922 return "Error: %s" % e.code 1923 else: 1924 sms_xml = _response.read() 1925 tree = etree.XML(sms_xml) 1926 messages = tree.findall(".//message") 1927 1928 mtable = s3db.msg_sms 1929 minsert = mtable.insert 1930 update_super = s3db.update_super 1931 decode = s3_decode_iso_datetime 1932 1933 # Is this channel connected to a parser? 1934 parser = s3db.msg_parser_enabled(channel_id) 1935 if parser: 1936 ptable = db.msg_parsing_status 1937 pinsert = ptable.insert 1938 1939 for message in messages: 1940 sender_phone = message.find("phone_number").text 1941 body = message.find("body").text 1942 received_on = decode(message.find("received_at").text) 1943 _id = minsert(channel_id = channel_id, 1944 sender_phone = sender_phone, 1945 body = body, 1946 received_on = received_on, 1947 ) 1948 record = dict(id=_id) 1949 update_super(mtable, record) 1950 if parser: 1951 pinsert(message_id = record["message_id"], 1952 channel_id = channel_id) 1953 1954 return "OK"
1955 1956 # ------------------------------------------------------------------------- 1957 @staticmethod
1958 - def poll_twilio(channel_id):
1959 """ 1960 Fetches the inbound SMS from Twilio API 1961 http://www.twilio.com/docs/api/rest 1962 """ 1963 1964 db = current.db 1965 s3db = current.s3db 1966 table = s3db.msg_twilio_channel 1967 query = (table.channel_id == channel_id) 1968 channel = db(query).select(table.account_sid, 1969 table.auth_token, 1970 table.url, 1971 limitby=(0, 1)).first() 1972 if not channel: 1973 return "No Such Twilio Channel: %s" % channel_id 1974 1975 # @ToDo: Do we really have to download *all* messages every time 1976 # & then only import the ones we don't yet have? 1977 account_sid = channel.account_sid 1978 url = "%s/%s/SMS/Messages.json" % (channel.url, account_sid) 1979 1980 # Create a password manager 1981 passman = urllib2.HTTPPasswordMgrWithDefaultRealm() 1982 passman.add_password(None, url, account_sid, channel.auth_token) 1983 1984 # Create the AuthHandler 1985 authhandler = urllib2.HTTPBasicAuthHandler(passman) 1986 opener = urllib2.build_opener(authhandler) 1987 urllib2.install_opener(opener) 1988 1989 try: 1990 smspage = urllib2.urlopen(url) 1991 except urllib2.HTTPError, e: 1992 error = "Error: %s" % e.code 1993 current.log.error(error) 1994 # Store status in the DB 1995 S3Msg.update_channel_status(channel_id, 1996 status=error, 1997 period=(300, 3600)) 1998 return error 1999 else: 2000 sms_list = json.loads(smspage.read()) 2001 messages = sms_list["sms_messages"] 2002 # Find all the SIDs we have already downloaded 2003 # (even if message was deleted) 2004 stable = db.msg_twilio_sid 2005 sids = db(stable.id > 0).select(stable.sid) 2006 downloaded_sms = [s.sid for s in sids] 2007 2008 mtable = s3db.msg_sms 2009 minsert = mtable.insert 2010 sinsert = stable.insert 2011 update_super = s3db.update_super 2012 2013 # Is this channel connected to a parser? 2014 parser = s3db.msg_parser_enabled(channel_id) 2015 if parser: 2016 ptable = db.msg_parsing_status 2017 pinsert = ptable.insert 2018 2019 for sms in messages: 2020 if (sms["direction"] == "inbound") and \ 2021 (sms["sid"] not in downloaded_sms): 2022 sender = "<" + sms["from"] + ">" 2023 _id = minsert(channel_id=channel_id, 2024 body=sms["body"], 2025 status=sms["status"], 2026 from_address=sender, 2027 received_on=sms["date_sent"]) 2028 record = dict(id=_id) 2029 update_super(mtable, record) 2030 message_id = record["message_id"] 2031 sinsert(message_id = message_id, 2032 sid=sms["sid"]) 2033 if parser: 2034 pinsert(message_id = message_id, 2035 channel_id = channel_id) 2036 return "OK"
2037 2038 # ------------------------------------------------------------------------- 2039 @staticmethod
2040 - def poll_rss(channel_id):
2041 """ 2042 Fetches all new messages from a subscribed RSS Feed 2043 """ 2044 2045 db = current.db 2046 s3db = current.s3db 2047 table = s3db.msg_rss_channel 2048 query = (table.channel_id == channel_id) 2049 channel = db(query).select(table.date, 2050 table.etag, 2051 table.url, 2052 table.content_type, 2053 table.username, 2054 table.password, 2055 limitby=(0, 1)).first() 2056 if not channel: 2057 return "No Such RSS Channel: %s" % channel_id 2058 2059 # http://pythonhosted.org/feedparser 2060 import feedparser 2061 # Basic Authentication 2062 username = channel.username 2063 password = channel.password 2064 if username and password: 2065 # feedparser doesn't do pre-emptive authentication with urllib2.HTTPBasicAuthHandler() and throws errors on the 401 2066 base64string = base64.encodestring("%s:%s" % (username, password)).replace("\n", "") 2067 request_headers = {"Authorization": "Basic %s" % base64string} 2068 else: 2069 # Doesn't help to encourage servers to set correct content-type 2070 #request_headers = {"Accept": "application/xml"} 2071 request_headers = None 2072 2073 if channel.content_type: 2074 # Override content-type (some feeds have text/html set which feedparser refuses to parse) 2075 response_headers = {"content-type": "application/xml"} 2076 else: 2077 response_headers = None 2078 2079 if channel.etag: 2080 # http://pythonhosted.org/feedparser/http-etag.html 2081 # NB This won't help for a server like Drupal 7 set to not allow caching & hence generating a new ETag/Last Modified each request! 2082 d = feedparser.parse(channel.url, 2083 etag=channel.etag, 2084 request_headers=request_headers, 2085 response_headers=response_headers, 2086 ) 2087 elif channel.date: 2088 d = feedparser.parse(channel.url, 2089 modified=channel.date.utctimetuple(), 2090 request_headers=request_headers, 2091 response_headers=response_headers, 2092 ) 2093 else: 2094 # We've not polled this feed before 2095 d = feedparser.parse(channel.url, 2096 request_headers=request_headers, 2097 response_headers=response_headers, 2098 ) 2099 if d.bozo: 2100 # Something doesn't seem right 2101 S3Msg.update_channel_status(channel_id, 2102 status = "ERROR: %s" % d.bozo_exception.message, 2103 period = (300, 3600), 2104 ) 2105 return 2106 2107 # Update ETag/Last-polled 2108 now = current.request.utcnow 2109 data = dict(date=now) 2110 etag = d.get("etag", None) 2111 if etag: 2112 data["etag"] = etag 2113 db(query).update(**data) 2114 2115 from time import mktime, struct_time 2116 gis = current.gis 2117 geocode_r = gis.geocode_r 2118 hierarchy_level_keys = gis.hierarchy_level_keys 2119 utcfromtimestamp = datetime.datetime.utcfromtimestamp 2120 gtable = db.gis_location 2121 ginsert = gtable.insert 2122 mtable = db.msg_rss 2123 minsert = mtable.insert 2124 ltable = db.msg_rss_link 2125 linsert = ltable.insert 2126 update_super = s3db.update_super 2127 2128 # Is this channel connected to a parser? 2129 parser = s3db.msg_parser_enabled(channel_id) 2130 if parser: 2131 ptable = db.msg_parsing_status 2132 pinsert = ptable.insert 2133 2134 entries = d.entries 2135 if entries: 2136 # Check how many we have already to see if any are new 2137 count_old = db(mtable.id > 0).count() 2138 for entry in entries: 2139 link = entry.get("link", None) 2140 2141 # Check for duplicates 2142 # (ETag just saves bandwidth, doesn't filter the contents of the feed) 2143 exists = db(mtable.from_address == link).select(mtable.id, 2144 mtable.location_id, 2145 mtable.message_id, 2146 limitby=(0, 1) 2147 ).first() 2148 if exists: 2149 location_id = exists.location_id 2150 else: 2151 location_id = None 2152 2153 title = entry.get("title") 2154 2155 content = entry.get("content", None) 2156 if content: 2157 content = content[0].value 2158 else: 2159 content = entry.get("description", None) 2160 2161 # Consider using dateutil.parser.parse(entry.get("published")) 2162 # http://www.deadlybloodyserious.com/2007/09/feedparser-v-django/ 2163 date_published = entry.get("published_parsed", entry.get("updated_parsed")) 2164 if isinstance(date_published, struct_time): 2165 date_published = utcfromtimestamp(mktime(date_published)) 2166 else: 2167 date_published = now 2168 2169 tags = entry.get("tags", None) 2170 if tags: 2171 tags = [t.term.encode("utf-8") for t in tags] 2172 2173 location = False 2174 lat = entry.get("geo_lat", None) 2175 lon = entry.get("geo_long", None) 2176 if lat is None or lon is None: 2177 # Try GeoRSS 2178 georss = entry.get("georss_point", None) 2179 if georss: 2180 location = True 2181 lat, lon = georss.split(" ") 2182 else: 2183 location = True 2184 if location: 2185 try: 2186 query = (gtable.lat == lat) &\ 2187 (gtable.lon == lon) 2188 lexists = db(query).select(gtable.id, 2189 limitby=(0, 1), 2190 orderby=gtable.level, 2191 ).first() 2192 if lexists: 2193 location_id = lexists.id 2194 else: 2195 data = dict(lat=lat, 2196 lon=lon, 2197 ) 2198 results = geocode_r(lat, lon) 2199 if isinstance(results, dict): 2200 for key in hierarchy_level_keys: 2201 v = results.get(key, None) 2202 if v: 2203 data[key] = v 2204 #if location_id: 2205 # Location has been changed 2206 #db(gtable.id == location_id).update(**data) 2207 location_id = ginsert(**data) 2208 data["id"] = location_id 2209 gis.update_location_tree(data) 2210 except: 2211 # Don't die on badly-formed Geo 2212 pass 2213 2214 # Get links - these can be multiple with certain type 2215 links = entry.get("links", []) 2216 if exists: 2217 db(mtable.id == exists.id).update(channel_id = channel_id, 2218 title = title, 2219 from_address = link, 2220 body = content, 2221 author = entry.get("author", None), 2222 date = date_published, 2223 location_id = location_id, 2224 tags = tags, 2225 # @ToDo: Enclosures 2226 ) 2227 if links: 2228 query_ = (ltable.rss_id == exists.id) & (ltable.deleted != True) 2229 for link_ in links: 2230 url_ = link_["url"] 2231 type_ = link_["type"] 2232 query = query_ & (ltable.url == url_) & \ 2233 (ltable.type == type_) 2234 dbset = db(query) 2235 row = dbset.select(ltable.id, limitby=(0, 1)).first() 2236 if row: 2237 dbset.update(rss_id = exists.id, 2238 url = url_, 2239 type = type_, 2240 ) 2241 else: 2242 linsert(rss_id = exists.id, 2243 url = url_, 2244 type = type_, 2245 ) 2246 if parser: 2247 pinsert(message_id = exists.message_id, 2248 channel_id = channel_id) 2249 2250 else: 2251 _id = minsert(channel_id = channel_id, 2252 title = title, 2253 from_address = link, 2254 body = content, 2255 author = entry.get("author", None), 2256 date = date_published, 2257 location_id = location_id, 2258 tags = tags, 2259 # @ToDo: Enclosures 2260 ) 2261 record = dict(id=_id) 2262 update_super(mtable, record) 2263 for link_ in links: 2264 linsert(rss_id = _id, 2265 url = link_["url"], 2266 type = link_["type"], 2267 ) 2268 if parser: 2269 pinsert(message_id = record["message_id"], 2270 channel_id = channel_id) 2271 2272 if entries: 2273 # Check again to see if there were any new ones 2274 count_new = db(mtable.id > 0).count() 2275 if count_new == count_old: 2276 # No new posts? 2277 # Back-off in-case the site isn't respecting ETags/Last-Modified 2278 S3Msg.update_channel_status(channel_id, 2279 status="+1", 2280 period=(300, 3600)) 2281 2282 return "OK"
2283 2284 #------------------------------------------------------------------------- 2285 @staticmethod
2286 - def poll_twitter(channel_id):
2287 """ 2288 Function to call to fetch tweets into msg_twitter table 2289 - called via Scheduler or twitter_inbox controller 2290 2291 http://tweepy.readthedocs.org/en/v3.3.0/api.html 2292 """ 2293 2294 try: 2295 import tweepy 2296 except ImportError: 2297 current.log.error("s3msg", "Tweepy not available, so non-Tropo Twitter support disabled") 2298 return False 2299 2300 db = current.db 2301 s3db = current.s3db 2302 2303 # Initialize Twitter API 2304 twitter_settings = S3Msg.get_twitter_api(channel_id) 2305 if twitter_settings: 2306 # This is an account with login info, so pull DMs 2307 dm = True 2308 else: 2309 # This is can account without login info, so pull public tweets 2310 dm = False 2311 table = s3db.msg_twitter_channel 2312 channel = db(table.channel_id == channel_id).select(table.twitter_account, 2313 limitby=(0, 1) 2314 ).first() 2315 screen_name = channel.twitter_account 2316 # Authenticate using login account 2317 twitter_settings = S3Msg.get_twitter_api() 2318 if twitter_settings is None: 2319 # Cannot authenticate 2320 return False 2321 2322 twitter_api = twitter_settings[0] 2323 2324 table = s3db.msg_twitter 2325 2326 # Get the latest Twitter message ID to use it as since_id 2327 query = (table.channel_id == channel_id) & \ 2328 (table.inbound == True) 2329 latest = db(query).select(table.msg_id, 2330 orderby=~table.date, 2331 limitby=(0, 1) 2332 ).first() 2333 2334 try: 2335 if dm: 2336 if latest: 2337 messages = twitter_api.direct_messages(since_id=latest.msg_id) 2338 else: 2339 messages = twitter_api.direct_messages() 2340 else: 2341 if latest: 2342 messages = twitter_api.user_timeline(screen_name=screen_name, 2343 since_id=latest.msg_id) 2344 else: 2345 messages = twitter_api.user_timeline(screen_name=screen_name) 2346 except tweepy.TweepError as e: 2347 error = e.message 2348 if isinstance(error, (tuple, list)): 2349 # Older Tweepy? 2350 error = e.message[0]["message"] 2351 current.log.error("Unable to get the Tweets for the user: %s" % error) 2352 return False 2353 2354 messages.reverse() 2355 2356 tinsert = table.insert 2357 update_super = s3db.update_super 2358 for message in messages: 2359 if dm: 2360 from_address = message.sender_screen_name 2361 to_address = message.recipient_screen_name 2362 else: 2363 from_address = message.author.screen_name 2364 to_address = message.in_reply_to_screen_name 2365 _id = tinsert(channel_id = channel_id, 2366 body = message.text, 2367 from_address = from_address, 2368 to_address = to_address, 2369 date = message.created_at, 2370 inbound = True, 2371 msg_id = message.id, 2372 ) 2373 update_super(table, dict(id=_id)) 2374 2375 return True
2376 2377 # ------------------------------------------------------------------------- 2378 @staticmethod
2379 - def update_channel_status(channel_id, status, period=None):
2380 """ 2381 Update the Status for a Channel 2382 """ 2383 2384 db = current.db 2385 2386 # Read current status 2387 stable = current.s3db.msg_channel_status 2388 query = (stable.channel_id == channel_id) 2389 old_status = db(query).select(stable.status, 2390 limitby=(0, 1) 2391 ).first() 2392 if old_status: 2393 # Update 2394 if status and status[0] == "+": 2395 # Increment status if-numeric 2396 old_status = old_status.status 2397 try: 2398 old_status = int(old_status) 2399 except (ValueError, TypeError): 2400 new_status = status 2401 else: 2402 new_status = old_status + int(status[1:]) 2403 else: 2404 new_status = status 2405 db(query).update(status = new_status) 2406 else: 2407 # Initialise 2408 stable.insert(channel_id = channel_id, 2409 status = status) 2410 if period: 2411 # Amend the frequency of the scheduled task 2412 ttable = db.scheduler_task 2413 args = '["msg_rss_channel", %s]' % channel_id 2414 query = ((ttable.function_name == "msg_poll") & \ 2415 (ttable.args == args) & \ 2416 (ttable.status.belongs(["RUNNING", "QUEUED", "ALLOCATED"]))) 2417 exists = db(query).select(ttable.id, 2418 ttable.period, 2419 limitby=(0, 1)).first() 2420 if not exists: 2421 return 2422 old_period = exists.period 2423 max_period = period[1] 2424 if old_period < max_period: 2425 new_period = old_period + period[0] 2426 new_period = min(new_period, max_period) 2427 db(ttable.id == exists.id).update(period=new_period)
2428 2429 # ------------------------------------------------------------------------- 2430 @staticmethod
2431 - def twitter_search(search_id):
2432 """ 2433 Fetch Results for a Twitter Search Query 2434 """ 2435 2436 try: 2437 import TwitterSearch 2438 except ImportError: 2439 error = "Unresolved dependency: TwitterSearch required for fetching results from twitter keyword queries" 2440 current.log.error("s3msg", error) 2441 current.session.error = error 2442 redirect(URL(f="index")) 2443 2444 db = current.db 2445 s3db = current.s3db 2446 2447 # Read Settings 2448 table = s3db.msg_twitter_channel 2449 # Doesn't need to be enabled for Polling 2450 settings = db(table.id > 0).select(table.consumer_key, 2451 table.consumer_secret, 2452 table.access_token, 2453 table.access_token_secret, 2454 limitby=(0, 1)).first() 2455 2456 if not settings: 2457 error = "Twitter Search requires an account configuring" 2458 current.log.error("s3msg", error) 2459 current.session.error = error 2460 redirect(URL(f="twitter_channel")) 2461 2462 qtable = s3db.msg_twitter_search 2463 rtable = db.msg_twitter_result 2464 search_query = db(qtable.id == search_id).select(qtable.id, 2465 qtable.keywords, 2466 qtable.lang, 2467 qtable.count, 2468 qtable.include_entities, 2469 limitby=(0, 1)).first() 2470 2471 tso = TwitterSearch.TwitterSearchOrder() 2472 tso.set_keywords(search_query.keywords.split(" ")) 2473 tso.set_language(search_query.lang) 2474 # @ToDo Handle more than 100 results per page 2475 # This may have to be changed upstream 2476 tso.set_count(int(search_query.count)) 2477 tso.set_include_entities(search_query.include_entities) 2478 2479 try: 2480 ts = TwitterSearch.TwitterSearch( 2481 consumer_key = settings.consumer_key, 2482 consumer_secret = settings.consumer_secret, 2483 access_token = settings.access_token, 2484 access_token_secret = settings.access_token_secret 2485 ) 2486 except TwitterSearch.TwitterSearchException as e: 2487 return(str(e)) 2488 2489 from dateutil import parser 2490 date_parse = parser.parse 2491 2492 gtable = db.gis_location 2493 # Disable validation 2494 rtable.location_id.requires = None 2495 update_super = s3db.update_super 2496 2497 for tweet in ts.search_tweets_iterable(tso): 2498 user = tweet["user"]["screen_name"] 2499 body = tweet["text"] 2500 tweet_id = tweet["id_str"] 2501 lang = tweet["lang"] 2502 created_at = date_parse(tweet["created_at"]) 2503 lat = None 2504 lon = None 2505 if tweet["coordinates"]: 2506 lat = tweet["coordinates"]["coordinates"][1] 2507 lon = tweet["coordinates"]["coordinates"][0] 2508 location_id = gtable.insert(lat=lat, lon=lon) 2509 else: 2510 location_id = None 2511 _id = rtable.insert(from_address = user, 2512 search_id = search_id, 2513 body = body, 2514 tweet_id = tweet_id, 2515 lang = lang, 2516 date = created_at, 2517 #inbound = True, 2518 location_id = location_id, 2519 ) 2520 update_super(rtable, dict(id=_id)) 2521 2522 # This is simplistic as we may well want to repeat the same search multiple times 2523 db(qtable.id == search_id).update(is_searched = True) 2524 2525 return "OK"
2526 2527 # ------------------------------------------------------------------------- 2528 @staticmethod
2529 - def process_keygraph(search_id):
2530 """ Process results of twitter search with KeyGraph.""" 2531 2532 import subprocess 2533 import tempfile 2534 2535 db = current.db 2536 s3db = current.s3db 2537 curpath = os.getcwd() 2538 preprocess = S3Msg.preprocess_tweet 2539 2540 def generateFiles(): 2541 2542 dirpath = tempfile.mkdtemp() 2543 os.chdir(dirpath) 2544 2545 rtable = s3db.msg_twitter_search_results 2546 tweets = db(rtable.deleted == False).select(rtable.body) 2547 tweetno = 1 2548 for tweet in tweets: 2549 filename = "%s.txt" % tweetno 2550 f = open(filename, "w") 2551 f.write(preprocess(tweet.body)) 2552 tweetno += 1 2553 2554 return dirpath
2555 2556 tpath = generateFiles() 2557 jarpath = os.path.join(curpath, "static", "KeyGraph", "keygraph.jar") 2558 resultpath = os.path.join(curpath, "static", "KeyGraph", "results", "%s.txt" % search_id) 2559 return subprocess.call(["java", "-jar", jarpath, tpath , resultpath]) 2560 2561 # ------------------------------------------------------------------------- 2562 @staticmethod
2563 - def preprocess_tweet(tweet):
2564 """ 2565 Preprocesses tweets to remove URLs, 2566 RTs, extra whitespaces and replace hashtags 2567 with their definitions. 2568 """ 2569 2570 tagdef = S3Msg.tagdef 2571 tweet = tweet.lower() 2572 tweet = re.sub(r"((www\.[\s]+)|(https?://[^\s]+))", "", tweet) 2573 tweet = re.sub(r"@[^\s]+", "", tweet) 2574 tweet = re.sub(r"[\s]+", " ", tweet) 2575 tweet = re.sub(r"#([^\s]+)", lambda m:tagdef(m.group(0)), tweet) 2576 tweet = tweet.strip('\'"') 2577 2578 return tweet
2579 2580 # ------------------------------------------------------------------------- 2581 @staticmethod
2582 - def tagdef(hashtag):
2583 """ 2584 Returns the definition of a hashtag. 2585 """ 2586 2587 hashtag = hashtag.split("#")[1] 2588 2589 turl = "http://api.tagdef.com/one.%s.json" % hashtag 2590 try: 2591 hashstr = urllib2.urlopen(turl).read() 2592 hashdef = json.loads(hashstr) 2593 except: 2594 return hashtag 2595 else: 2596 return hashdef["defs"]["def"]["text"]
2597
2598 # ============================================================================= 2599 -class S3Compose(S3CRUD):
2600 """ RESTful method for messaging """ 2601 2602 # -------------------------------------------------------------------------
2603 - def apply_method(self, r, **attr):
2604 """ 2605 API entry point 2606 2607 @param r: the S3Request instance 2608 @param attr: controller attributes for the request 2609 """ 2610 2611 if r.http in ("GET", "POST"): 2612 output = self.compose(r, **attr) 2613 else: 2614 r.error(405, current.ERROR.BAD_METHOD) 2615 return output
2616 2617 # -------------------------------------------------------------------------
2618 - def compose(self, r, **attr):
2619 """ 2620 Generate a form to send a message 2621 2622 @param r: the S3Request instance 2623 @param attr: controller attributes for the request 2624 """ 2625 2626 T = current.T 2627 auth = current.auth 2628 2629 self.url = url = r.url() 2630 2631 # @ToDo: Use API 2632 if auth.is_logged_in() or auth.basic(): 2633 pass 2634 else: 2635 redirect(URL(c="default", f="user", args="login", 2636 vars={"_next": url})) 2637 2638 if not current.deployment_settings.has_module("msg"): 2639 current.session.error = T("Cannot send messages if Messaging module disabled") 2640 redirect(URL(f="index")) 2641 2642 if not auth.permission.has_permission("update", c="msg"): 2643 current.session.error = T("You do not have permission to send messages") 2644 redirect(URL(f="index")) 2645 2646 #_vars = r.get_vars 2647 2648 # Set defaults for when not coming via msg.compose() 2649 self.contact_method = None 2650 self.recipient = None 2651 self.recipients = None 2652 self.recipient_type = None 2653 self.subject = None 2654 self.message = None 2655 #self.formid = None 2656 form = self._compose_form() 2657 # @ToDo: A 2nd Filter form 2658 # if form.accepts(r.post_vars, current.session, 2659 # formname="compose", 2660 # keepvalues=True): 2661 # query, errors = self._process_filter_options(form) 2662 # if r.http == "POST" and not errors: 2663 # self.resource.add_filter(query) 2664 # _vars = form.vars 2665 2666 # Apply method 2667 if self.method == "compose": 2668 output = dict(form=form) 2669 else: 2670 r.error(405, current.ERROR.BAD_METHOD) 2671 2672 # Complete the page 2673 if r.representation == "html": 2674 title = self.crud_string(self.tablename, "title_compose") 2675 if not title: 2676 title = T("Send Message") 2677 2678 # subtitle = self.crud_string(self.tablename, "subtitle_compose") 2679 # if not subtitle: 2680 # subtitle = "" 2681 2682 # Maintain RHeader for consistency 2683 if attr.get("rheader"): 2684 rheader = attr["rheader"](r) 2685 if rheader: 2686 output["rheader"] = rheader 2687 2688 output["title"] = title 2689 #output["subtitle"] = subtitle 2690 #output["form"] = form 2691 #current.response.view = self._view(r, "list_filter.html") 2692 current.response.view = self._view(r, "create.html") 2693 2694 return output
2695 2696 # -------------------------------------------------------------------------
2697 - def _compose_onvalidation(self, form):
2698 """ 2699 Set the sender 2700 Route the message 2701 """ 2702 2703 post_vars = current.request.post_vars 2704 settings = current.deployment_settings 2705 2706 if settings.get_mail_default_subject(): 2707 system_name_short = "%s - " % settings.get_system_name_short() 2708 else: 2709 system_name_short = "" 2710 2711 if settings.get_mail_auth_user_in_subject(): 2712 user = current.auth.user 2713 if user: 2714 authenticated_user = "%s %s - " % (user.first_name, 2715 user.last_name) 2716 else: 2717 authenticated_user = "" 2718 2719 post_vars.subject = authenticated_user + system_name_short + post_vars.subject 2720 contact_method = post_vars.contact_method 2721 2722 recipients = self.recipients 2723 if not recipients: 2724 if not post_vars.pe_id: 2725 if contact_method != "TWITTER": 2726 current.session.error = current.T("Please enter the recipient(s)") 2727 redirect(self.url) 2728 else: 2729 # This must be a Status Update 2730 if current.msg.send_tweet(post_vars.body): 2731 current.session.confirmation = current.T("Check outbox for the message status") 2732 else: 2733 current.session.error = current.T("Error sending message!") 2734 redirect(self.url) 2735 else: 2736 recipients = post_vars.pe_id 2737 2738 if current.msg.send_by_pe_id(recipients, 2739 post_vars.subject, 2740 post_vars.body, 2741 contact_method): 2742 current.session.confirmation = current.T("Check outbox for the message status") 2743 redirect(self.url) 2744 else: 2745 if current.mail.error: 2746 # set by mail.error 2747 current.session.error = "%s: %s" % (current.T("Error sending message"), 2748 current.mail.error) 2749 else: 2750 current.session.error = current.T("Error sending message!") 2751 redirect(self.url)
2752 2753 # -------------------------------------------------------------------------
2754 - def _compose_form(self):
2755 """ Creates the form for composing the message """ 2756 2757 T = current.T 2758 db = current.db 2759 s3db = current.s3db 2760 request = current.request 2761 get_vars = request.get_vars 2762 2763 mtable = s3db.msg_message 2764 etable = s3db.msg_email 2765 otable = s3db.msg_outbox 2766 2767 mtable.body.label = T("Message") 2768 mtable.body.default = self.message 2769 etable.subject.default = self.subject 2770 mtable.inbound.default = False 2771 mtable.inbound.writable = False 2772 2773 resource = self.resource 2774 2775 recipient_type = self.recipient_type # from msg.compose() 2776 if not recipient_type and resource: 2777 # See if we have defined a custom recipient type for this table 2778 # pr_person or pr_group 2779 recipient_type = self._config("msg_recipient_type", None) 2780 2781 contact_method = self.contact_method # from msg.compose() 2782 if not contact_method and resource: 2783 # See if we have defined a custom default contact method for this table 2784 contact_method = self._config("msg_contact_method", "EMAIL") 2785 2786 otable.contact_method.default = contact_method 2787 2788 recipient = self.recipient # from msg.compose() 2789 if not recipient: 2790 if "pe_id" in get_vars: 2791 recipient = get_vars.pe_id 2792 elif "person_id" in get_vars: 2793 # @ToDo 2794 pass 2795 elif "group_id" in get_vars: 2796 # @ToDo 2797 pass 2798 elif "human_resource.id" in get_vars: 2799 # @ToDo 2800 pass 2801 2802 if recipient: 2803 recipients = [recipient] 2804 else: 2805 recipients = [] 2806 2807 if resource: 2808 table = resource.table 2809 if "pe_id" in table: 2810 field = "pe_id" 2811 elif "person_id" in table: 2812 field = "person_id$pe_id" 2813 #elif "group_id" in table: 2814 # # @ToDo 2815 # field = "group_id$pe_id" 2816 else: 2817 field = None 2818 2819 if field: 2820 records = resource.select([field], limit=None)["rows"] 2821 recipients = [record.values()[0] for record in records] 2822 2823 pe_field = otable.pe_id 2824 pe_field.label = T("Recipient(s)") 2825 pe_field.writable = True 2826 if recipients: 2827 # Don't download a SELECT 2828 pe_field.requires = None 2829 # Tell onvalidation about them 2830 self.recipients = recipients 2831 2832 pe_field.default = recipients 2833 2834 if len(recipients) == 1: 2835 recipient = recipients[0] 2836 represent = s3db.pr_PersonEntityRepresent(show_label=False)(recipient) 2837 # Restrict message options to those available for the entity 2838 petable = s3db.pr_pentity 2839 entity_type = db(petable.pe_id == recipient).select(petable.instance_type, 2840 limitby=(0, 1) 2841 ).first().instance_type 2842 if entity_type == "pr_person": 2843 all_contact_opts = current.msg.MSG_CONTACT_OPTS 2844 contact_method_opts = {} 2845 ctable = s3db.pr_contact 2846 query = (ctable.deleted != True) & \ 2847 (ctable.pe_id == recipient) 2848 rows = db(query).select(ctable.contact_method) 2849 for row in rows: 2850 if row.contact_method in all_contact_opts: 2851 contact_method_opts[row.contact_method] = all_contact_opts[row.contact_method] 2852 if not contact_method_opts: 2853 current.session.error = T("There are no contacts available for this person!") 2854 controller = request.controller 2855 if controller == "hrm": 2856 url = URL(c="hrm", f="person", args="contacts", 2857 vars={"group": "staff", 2858 "human_resource.id": get_vars.get("human_resource.id")}) 2859 elif controller == "vol": 2860 url = URL(c="vol", f="person", args="contacts", 2861 vars={"group": "volunteer", 2862 "human_resource.id": get_vars.get("human_resource.id")}) 2863 elif controller == "member": 2864 url = URL(c="member", f="person", args="contacts", 2865 vars={"membership.id": get_vars.get("membership.id")}) 2866 else: 2867 # @ToDo: Lookup the type 2868 url = URL(f="index") 2869 redirect(url) 2870 otable.contact_method.requires = IS_IN_SET(contact_method_opts, 2871 zero=None) 2872 if contact_method not in contact_method_opts: 2873 otable.contact_method.default = contact_method_opts.popitem()[0] 2874 #elif entity_type = "pr_group": 2875 # @ToDo: Loop through members 2876 else: 2877 # @ToDo: This should display all the Recipients (truncated with option to see all) 2878 # - use pr_PersonEntityRepresent for bulk representation 2879 represent = T("%(count)s Recipients") % dict(count=len(recipients)) 2880 else: 2881 if recipient_type: 2882 # Filter by Recipient Type 2883 pe_field.requires = IS_ONE_OF(db, 2884 "pr_pentity.pe_id", 2885 # Breaks PG 2886 #orderby="instance_type", 2887 filterby="instance_type", 2888 filter_opts=(recipient_type,), 2889 ) 2890 pe_field.widget = S3PentityAutocompleteWidget(types=(recipient_type,)) 2891 else: 2892 # @ToDo A new widget (tree?) required to handle multiple persons and groups 2893 pe_field.widget = S3PentityAutocompleteWidget() 2894 2895 pe_field.comment = DIV(_class="tooltip", 2896 _title="%s|%s" % \ 2897 (T("Recipients"), 2898 T("Please enter the first few letters of the Person/Group for the autocomplete."))) 2899 2900 sqlform = S3SQLDefaultForm() 2901 2902 s3resource = s3db.resource 2903 logform = sqlform(request = request, 2904 resource = s3resource("msg_message"), 2905 onvalidation = self._compose_onvalidation, 2906 message = "Message Sent", 2907 format = "html") 2908 2909 outboxform = sqlform(request = request, 2910 resource = s3resource("msg_outbox"), 2911 message = "Message Sent", 2912 format = "html") 2913 2914 mailform = sqlform(request = request, 2915 resource = s3resource("msg_email"), 2916 message = "Message Sent", 2917 format = "html") 2918 2919 # Shortcuts 2920 lcustom = logform.custom 2921 ocustom = outboxform.custom 2922 mcustom = mailform.custom 2923 2924 pe_row = TR(TD(LABEL(ocustom.label.pe_id)), 2925 _id="msg_outbox_pe_id__row") 2926 if recipients: 2927 ocustom.widget.pe_id["_class"] = "hide" 2928 pe_row.append(TD(ocustom.widget.pe_id, 2929 represent)) 2930 else: 2931 pe_row.append(TD(ocustom.widget.pe_id)) 2932 pe_row.append(TD(ocustom.comment.pe_id)) 2933 2934 # Build a custom form from the 3 source forms 2935 form = DIV(lcustom.begin, 2936 TABLE(TBODY(TR(TD(LABEL(ocustom.label.contact_method)), 2937 TD(ocustom.widget.contact_method), 2938 TD(ocustom.comment.contact_method), 2939 _id="msg_outbox_contact_method__row" 2940 ), 2941 pe_row, 2942 TR(TD(LABEL(mcustom.label.subject)), 2943 TD(mcustom.widget.subject), 2944 TD(mcustom.comment.subject), 2945 _id="msg_log_subject__row" 2946 ), 2947 TR(TD(LABEL(lcustom.label.body)), 2948 TD(lcustom.widget.body), 2949 TD(lcustom.comment.body), 2950 _id="msg_log_message__row" 2951 ), 2952 #TR(TD(LABEL(lcustom.label.priority)), 2953 #TD(lcustom.widget.priority), 2954 #TD(lcustom.comment.priority), 2955 #_id="msg_log_priority__row" 2956 #), 2957 TR(TD(), 2958 TD(INPUT(_type="submit", 2959 _value=T("Send message"), 2960 _id="dummy_submit"), 2961 ), 2962 _id="submit_record__row" 2963 ), 2964 ), 2965 ), 2966 lcustom.end, 2967 ) 2968 2969 s3 = current.response.s3 2970 if s3.debug: 2971 s3.scripts.append("/%s/static/scripts/S3/s3.msg.js" % request.application) 2972 else: 2973 s3.scripts.append("/%s/static/scripts/S3/s3.msg.min.js" % request.application) 2974 script = '''i18n.none_of_the_above="%s"''' % T("None of the above") 2975 s3.js_global.append(script) 2976 # @ToDo: Port SMS maxLength from alert_create_script() in controllers/deploy.py 2977 2978 return form
2979 2980 # END ========================================================================= 2981