2016-04-07 15:03:22 +02:00
|
|
|
#!/usr/bin/env python
|
2013-08-22 23:52:57 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# Zulip mirror of Basecamp activity
|
2014-02-04 20:15:02 +01:00
|
|
|
# Copyright © 2014 Zulip, Inc.
|
2013-08-22 23:52:57 +02:00
|
|
|
#
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
|
|
# in the Software without restriction, including without limitation the rights
|
|
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
#
|
|
|
|
# The above copyright notice and this permission notice shall be included in
|
|
|
|
# all copies or substantial portions of the Software.
|
|
|
|
#
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
|
|
# THE SOFTWARE.
|
|
|
|
#
|
|
|
|
# The "basecamp-mirror.py" script is run continuously, possibly on a work computer
|
|
|
|
# or preferably on a server.
|
|
|
|
# You may need to install the python-requests library.
|
|
|
|
|
2016-03-10 18:13:01 +01:00
|
|
|
from __future__ import absolute_import
|
2013-08-22 23:52:57 +02:00
|
|
|
import requests
|
|
|
|
import logging
|
|
|
|
import time
|
|
|
|
import re
|
|
|
|
import sys
|
|
|
|
import os
|
|
|
|
from datetime import datetime, timedelta
|
2016-03-10 18:28:02 +01:00
|
|
|
from six.moves.html_parser import HTMLParser
|
2016-03-10 18:13:01 +01:00
|
|
|
import six
|
2013-08-22 23:52:57 +02:00
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
|
|
import zulip_basecamp_config as config
|
2013-12-05 23:42:33 +01:00
|
|
|
VERSION = "0.9"
|
2013-08-22 23:52:57 +02:00
|
|
|
|
|
|
|
if config.ZULIP_API_PATH is not None:
|
|
|
|
sys.path.append(config.ZULIP_API_PATH)
|
|
|
|
import zulip
|
|
|
|
|
|
|
|
|
|
|
|
client = zulip.Client(
|
|
|
|
email=config.ZULIP_USER,
|
|
|
|
site=config.ZULIP_SITE,
|
2013-12-05 23:42:33 +01:00
|
|
|
api_key=config.ZULIP_API_KEY,
|
2013-12-06 23:50:55 +01:00
|
|
|
client="ZulipBasecamp/" + VERSION)
|
2015-09-29 06:17:08 +02:00
|
|
|
user_agent = "Basecamp To Zulip Mirroring script (zulip-devel@googlegroups.com)"
|
2013-08-22 23:52:57 +02:00
|
|
|
htmlParser = HTMLParser()
|
|
|
|
|
|
|
|
# find some form of JSON loader/dumper, with a preference order for speed.
|
|
|
|
json_implementations = ['ujson', 'cjson', 'simplejson', 'json']
|
|
|
|
|
|
|
|
while len(json_implementations):
|
|
|
|
try:
|
|
|
|
json = __import__(json_implementations.pop(0))
|
|
|
|
break
|
|
|
|
except ImportError:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# void function that checks the permissions of the files this script needs.
|
|
|
|
def check_permissions():
|
|
|
|
# check that the log file can be written
|
|
|
|
if config.LOG_FILE:
|
|
|
|
try:
|
|
|
|
open(config.LOG_FILE, "w")
|
|
|
|
except IOError as e:
|
|
|
|
sys.stderr("Could not open up log for writing:")
|
|
|
|
sys.stderr(e)
|
|
|
|
# check that the resume file can be written (this creates if it doesn't exist)
|
|
|
|
try:
|
|
|
|
open(config.RESUME_FILE, "a+")
|
|
|
|
except IOError as e:
|
|
|
|
sys.stderr("Could not open up the file %s for reading and writing" % (config.RESUME_FILE,))
|
|
|
|
sys.stderr(e)
|
|
|
|
|
|
|
|
# builds the message dict for sending a message with the Zulip API
|
|
|
|
def build_message(event):
|
2016-03-10 14:39:44 +01:00
|
|
|
if not ('bucket' in event and 'creator' in event and 'html_url' in event):
|
2013-08-22 23:52:57 +02:00
|
|
|
logging.error("Perhaps the Basecamp API changed behavior? "
|
|
|
|
"This event doesn't have the expected format:\n%s" %(event,))
|
|
|
|
return None
|
|
|
|
# adjust the topic length to be bounded to 60 characters
|
|
|
|
topic = event['bucket']['name']
|
|
|
|
if len(topic) > 60:
|
|
|
|
topic = topic[0:57] + "..."
|
|
|
|
# get the action and target values
|
|
|
|
action = htmlParser.unescape(re.sub(r"<[^<>]+>", "", event.get('action', '')))
|
|
|
|
target = htmlParser.unescape(event.get('target', ''))
|
|
|
|
# Some events have "excerpts", which we blockquote
|
2016-03-10 16:33:07 +01:00
|
|
|
excerpt = htmlParser.unescape(event.get('excerpt', ''))
|
2013-08-22 23:52:57 +02:00
|
|
|
if excerpt.strip() == "":
|
|
|
|
message = '**%s** %s [%s](%s).' % (event['creator']['name'], action, target, event['html_url'])
|
|
|
|
else:
|
|
|
|
message = '**%s** %s [%s](%s).\n> %s' % (event['creator']['name'], action, target, event['html_url'], excerpt)
|
|
|
|
# assemble the message data dict
|
|
|
|
message_data = {
|
|
|
|
"type": "stream",
|
|
|
|
"to": config.ZULIP_STREAM_NAME,
|
|
|
|
"subject": topic,
|
|
|
|
"content": message,
|
|
|
|
}
|
|
|
|
return message_data
|
|
|
|
|
|
|
|
# the main run loop for this mirror script
|
|
|
|
def run_mirror():
|
|
|
|
# we should have the right (write) permissions on the resume file, as seen
|
|
|
|
# in check_permissions, but it may still be empty or corrupted
|
|
|
|
try:
|
|
|
|
with open(config.RESUME_FILE) as f:
|
2013-08-29 20:07:49 +02:00
|
|
|
since = f.read()
|
2013-08-22 23:52:57 +02:00
|
|
|
since = re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}-\d{2}:\d{2}", since)
|
|
|
|
assert since, "resume file does not meet expected format"
|
|
|
|
since = since.string
|
2016-03-10 16:33:07 +01:00
|
|
|
except (AssertionError, IOError) as e:
|
2013-08-22 23:52:57 +02:00
|
|
|
logging.warn("Could not open resume file: %s" % (e.message or e.strerror,))
|
|
|
|
since = (datetime.utcnow() - timedelta(hours=config.BASECAMP_INITIAL_HISTORY_HOURS)).isoformat() + "-00:00"
|
|
|
|
try:
|
|
|
|
# we use an exponential backoff approach when we get 429 (Too Many Requests).
|
|
|
|
sleepInterval = 1
|
2016-03-10 14:52:28 +01:00
|
|
|
while True:
|
2013-08-22 23:52:57 +02:00
|
|
|
time.sleep(sleepInterval)
|
|
|
|
response = requests.get("https://basecamp.com/%s/api/v1/events.json" % (config.BASECAMP_ACCOUNT_ID),
|
|
|
|
params={'since': since},
|
|
|
|
auth=(config.BASECAMP_USERNAME, config.BASECAMP_PASSWORD),
|
|
|
|
headers = {"User-Agent": user_agent})
|
|
|
|
if response.status_code == 200:
|
|
|
|
sleepInterval = 1
|
|
|
|
events = json.loads(response.text)
|
|
|
|
if len(events):
|
|
|
|
logging.info("Got event(s): %s" % (response.text,))
|
|
|
|
if response.status_code >= 500:
|
|
|
|
logging.error(response.status_code)
|
|
|
|
continue
|
|
|
|
if response.status_code == 429:
|
|
|
|
# exponential backoff
|
|
|
|
sleepInterval *= 2
|
|
|
|
logging.error(response.status_code)
|
|
|
|
continue
|
|
|
|
if response.status_code == 400:
|
|
|
|
logging.error("Something went wrong. Basecamp must be unhappy for this reason: %s" % (response.text,))
|
|
|
|
sys.exit(-1)
|
|
|
|
if response.status_code == 401:
|
|
|
|
logging.error("Bad authorization from Basecamp. Please check your Basecamp login credentials")
|
|
|
|
sys.exit(-1)
|
|
|
|
if len(events):
|
|
|
|
since = events[0]['created_at']
|
|
|
|
for event in reversed(events):
|
|
|
|
message_data = build_message(event)
|
|
|
|
if not message_data:
|
|
|
|
continue
|
|
|
|
zulip_api_result = client.send_message(message_data)
|
|
|
|
if zulip_api_result['result'] == "success":
|
|
|
|
logging.info("sent zulip with id: %s" % (zulip_api_result['id'],))
|
|
|
|
else:
|
|
|
|
logging.warn("%s %s" % (zulip_api_result['result'], zulip_api_result['msg']))
|
|
|
|
# update 'since' each time in case we get KeyboardInterrupted
|
|
|
|
since = event['created_at']
|
|
|
|
# avoid hitting rate-limit
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
logging.info("Shutting down, please hold")
|
|
|
|
open("events.last", 'w').write(since)
|
|
|
|
logging.info("Done!")
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2016-03-10 18:13:01 +01:00
|
|
|
if not isinstance(config.RESUME_FILE, six.string_types):
|
2013-08-22 23:52:57 +02:00
|
|
|
sys.stderr("RESUME_FILE path not given; refusing to continue")
|
|
|
|
check_permissions()
|
|
|
|
if config.LOG_FILE:
|
|
|
|
logging.basicConfig(filename=config.LOG_FILE, level=logging.INFO)
|
|
|
|
else:
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
run_mirror()
|