mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	Add basic Asana integration.
Due to limitations in their API, we have to poll and check for creation and completion events. (imported from commit be65e507fac16a7f8ad3dc57b2af9c4b98aacf39)
This commit is contained in:
		
							
								
								
									
										58
									
								
								api/integrations/asana/zulip_asana_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								api/integrations/asana/zulip_asana_config.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/env python
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Copyright © 2013 Zulip, Inc.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### REQUIRED CONFIGURATION ###
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Change these values to your Asana credentials.
 | 
				
			||||||
 | 
					ASANA_API_KEY = "0123456789abcdef0123456789abcdef"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Change these values to the credentials for your Asana bot.
 | 
				
			||||||
 | 
					ZULIP_USER = "asana-bot@example.com"
 | 
				
			||||||
 | 
					ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# The Zulip stream that will receive Asana task updates.
 | 
				
			||||||
 | 
					ZULIP_STREAM_NAME = "asana"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### OPTIONAL CONFIGURATION ###
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Set to None for logging to stdout when testing, and to a file for
 | 
				
			||||||
 | 
					# logging when deployed.
 | 
				
			||||||
 | 
					#LOG_FILE = "/var/tmp/zulip_asana.log"
 | 
				
			||||||
 | 
					LOG_FILE = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# This file is used to resume this mirror in case the script shuts down.
 | 
				
			||||||
 | 
					# It is required and needs to be writeable.
 | 
				
			||||||
 | 
					RESUME_FILE = "/var/tmp/zulip_asana.state"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# When initially started, how many hours of messages to include.
 | 
				
			||||||
 | 
					ASANA_INITIAL_HISTORY_HOURS = 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# This should not need to change unless you have a custom Zulip
 | 
				
			||||||
 | 
					# subdomain.
 | 
				
			||||||
 | 
					ZULIP_SITE = "https://api.zulip.com"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# If properly installed, the Zulip API should be in your import
 | 
				
			||||||
 | 
					# path, but if not, set a custom path below
 | 
				
			||||||
 | 
					ZULIP_API_PATH = None
 | 
				
			||||||
							
								
								
									
										276
									
								
								api/integrations/asana/zulip_asana_mirror
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										276
									
								
								api/integrations/asana/zulip_asana_mirror
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,276 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/env python
 | 
				
			||||||
 | 
					# -*- coding: utf-8 -*-
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Asana integration for Zulip
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Copyright © 2013 Zulip, Inc.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# 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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import base64
 | 
				
			||||||
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
 | 
					import dateutil.parser
 | 
				
			||||||
 | 
					import dateutil.tz
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					import urllib2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					sys.path.insert(0, os.path.dirname(__file__))
 | 
				
			||||||
 | 
					import zulip_asana_config as config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if config.ZULIP_API_PATH is not None:
 | 
				
			||||||
 | 
					    sys.path.append(config.ZULIP_API_PATH)
 | 
				
			||||||
 | 
					import zulip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if config.LOG_FILE:
 | 
				
			||||||
 | 
					    logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING)
 | 
				
			||||||
 | 
					else:
 | 
				
			||||||
 | 
					    logging.basicConfig(level=logging.INFO)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					client = zulip.Client(email=config.ZULIP_USER, api_key=config.ZULIP_API_KEY,
 | 
				
			||||||
 | 
					                      site=config.ZULIP_SITE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def fetch_from_asana(path):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Request a resource through the Asana API, authenticating using
 | 
				
			||||||
 | 
					    HTTP basic auth.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    auth = base64.encodestring('%s:' % (config.ASANA_API_KEY,))
 | 
				
			||||||
 | 
					    headers = {"Authorization": "Basic %s" % auth}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    url = "https://app.asana.com/api/1.0" + path
 | 
				
			||||||
 | 
					    request = urllib2.Request(url, None, headers)
 | 
				
			||||||
 | 
					    result = urllib2.urlopen(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return json.load(result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def send_zulip(topic, content):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Send a message to Zulip using the configured stream and bot credentials.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    message = {"type": "stream",
 | 
				
			||||||
 | 
					               "sender": config.ZULIP_USER,
 | 
				
			||||||
 | 
					               "to": config.ZULIP_STREAM_NAME,
 | 
				
			||||||
 | 
					               "subject": topic,
 | 
				
			||||||
 | 
					               "content": content,
 | 
				
			||||||
 | 
					               }
 | 
				
			||||||
 | 
					    return client.send_message(message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def datestring_to_datetime(datestring):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Given an ISO 8601 datestring, return the corresponding datetime object.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return dateutil.parser.parse(datestring).replace(
 | 
				
			||||||
 | 
					        tzinfo=dateutil.tz.gettz('Z'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TaskDict(dict):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    A helper class to turn a dictionary with task information into an
 | 
				
			||||||
 | 
					    object where each of the keys is an attribute for easy access.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    def __getattr__(self, field):
 | 
				
			||||||
 | 
					        return self.get(field)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_topic(task, projects):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Return a string that will be the Zulip message topic for this task.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # Tasks can be associated with multiple projects, but in practice they seem
 | 
				
			||||||
 | 
					    # to mostly be associated with one.
 | 
				
			||||||
 | 
					    project_name = projects[task.projects[0]["id"]]
 | 
				
			||||||
 | 
					    return "%s: %s" % (project_name, task.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_assignee(task, users):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Return a string describing the task's assignee.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if task.assignee:
 | 
				
			||||||
 | 
					        assignee_name = users[task.assignee["id"]]
 | 
				
			||||||
 | 
					        assignee_info = "**Assigned to**: %s (%s)" % (
 | 
				
			||||||
 | 
					            assignee_name, task.assignee_status)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        assignee_info = "**Status**: Unassigned"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return assignee_info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_due_date(task):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Return a string describing the task's due date.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if task.due_on:
 | 
				
			||||||
 | 
					        due_date_info = "**Due on**: %s" % (task.due_on,)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        due_date_info = "**Due date**: None"
 | 
				
			||||||
 | 
					    return due_date_info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_task_creation_event(task, projects, users):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Format the topic and content for a newly-created task.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    topic = format_topic(task, projects)
 | 
				
			||||||
 | 
					    assignee_info = format_assignee(task, users)
 | 
				
			||||||
 | 
					    due_date_info = format_due_date(task)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    content = """Task **%s** created:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					~~~ quote
 | 
				
			||||||
 | 
					%s
 | 
				
			||||||
 | 
					~~~
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					%s
 | 
				
			||||||
 | 
					%s
 | 
				
			||||||
 | 
					""" % (task.name, task.notes, assignee_info, due_date_info)
 | 
				
			||||||
 | 
					    return topic, content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def format_task_completion_event(task, projects, users):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Format the topic and content for a completed task.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    topic = format_topic(task, projects)
 | 
				
			||||||
 | 
					    assignee_info = format_assignee(task, users)
 | 
				
			||||||
 | 
					    due_date_info = format_due_date(task)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    content = """Task **%s** completed. :white_check_mark:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					%s
 | 
				
			||||||
 | 
					%s
 | 
				
			||||||
 | 
					""" % (task.name, assignee_info, due_date_info)
 | 
				
			||||||
 | 
					    return topic, content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def since():
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Return a newness threshold for task events to be processed.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # If we have a record of the last event processed and it is recent, use it,
 | 
				
			||||||
 | 
					    # else process everything from ASANA_INITIAL_HISTORY_HOURS ago.
 | 
				
			||||||
 | 
					    def default_since():
 | 
				
			||||||
 | 
					        return datetime.utcnow() - timedelta(
 | 
				
			||||||
 | 
					            hours=config.ASANA_INITIAL_HISTORY_HOURS)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if os.path.exists(config.RESUME_FILE):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            with open(config.RESUME_FILE, "r") as f:
 | 
				
			||||||
 | 
					                datestring = f.readline().strip()
 | 
				
			||||||
 | 
					                timestamp = float(datestring)
 | 
				
			||||||
 | 
					                max_timestamp_processed = datetime.fromtimestamp(timestamp)
 | 
				
			||||||
 | 
					                logging.info("Reading from resume file: " + datestring)
 | 
				
			||||||
 | 
					        except (ValueError,IOError) as e:
 | 
				
			||||||
 | 
					            logging.warn("Could not open resume file: %s" % (
 | 
				
			||||||
 | 
					                    e.message or e.strerror,))
 | 
				
			||||||
 | 
					            max_timestamp_processed = default_since()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logging.info("No resume file, processing an initial history.")
 | 
				
			||||||
 | 
					        max_timestamp_processed = default_since()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Even if we can read a timestamp from RESUME_FILE, if it is old don't use
 | 
				
			||||||
 | 
					    # it.
 | 
				
			||||||
 | 
					    return max(max_timestamp_processed, default_since())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def process_new_events():
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Forward new Asana task events to Zulip.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # In task queries, Asana only exposes IDs for projects and users, so we need
 | 
				
			||||||
 | 
					    # to look up the mappings.
 | 
				
			||||||
 | 
					    projects = dict((elt["id"], elt["name"]) for elt in \
 | 
				
			||||||
 | 
					                        fetch_from_asana("/projects")["data"])
 | 
				
			||||||
 | 
					    users = dict((elt["id"], elt["name"]) for elt in \
 | 
				
			||||||
 | 
					                     fetch_from_asana("/users")["data"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cutoff = since()
 | 
				
			||||||
 | 
					    max_timestamp_processed = cutoff
 | 
				
			||||||
 | 
					    time_operations = (("created_at", format_task_creation_event),
 | 
				
			||||||
 | 
					                       ("completed_at", format_task_completion_event))
 | 
				
			||||||
 | 
					    task_fields = ["assignee", "assignee_status", "created_at", "completed_at",
 | 
				
			||||||
 | 
					                   "modified_at", "due_on", "name", "notes", "projects"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # First, gather all of the tasks that need processing. We'll
 | 
				
			||||||
 | 
					    # process them in order.
 | 
				
			||||||
 | 
					    new_events = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for project_id in projects:
 | 
				
			||||||
 | 
					        project_url = "/projects/%d/tasks?opt_fields=%s" % (
 | 
				
			||||||
 | 
					            project_id, ",".join(task_fields))
 | 
				
			||||||
 | 
					        tasks = fetch_from_asana(project_url)["data"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for task in tasks:
 | 
				
			||||||
 | 
					            task = TaskDict(task)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for time_field, operation in time_operations:
 | 
				
			||||||
 | 
					                if task[time_field]:
 | 
				
			||||||
 | 
					                    operation_time = datestring_to_datetime(task[time_field])
 | 
				
			||||||
 | 
					                    if operation_time > cutoff:
 | 
				
			||||||
 | 
					                        new_events.append((operation_time, time_field, operation, task))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    new_events.sort()
 | 
				
			||||||
 | 
					    now = datetime.utcnow()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for operation_time, time_field, operation, task in new_events:
 | 
				
			||||||
 | 
					        # Unfortunately, creating an Asana task is not an atomic operation. If
 | 
				
			||||||
 | 
					        # the task was just created, or is missing basic information, it is
 | 
				
			||||||
 | 
					        # probably because the task is still being filled out -- wait until the
 | 
				
			||||||
 | 
					        # next round to process it.
 | 
				
			||||||
 | 
					        if (time_field == "created_at") and \
 | 
				
			||||||
 | 
					                (now - operation_time < timedelta(seconds=30)):
 | 
				
			||||||
 | 
					            # The task was just created, give the user some time to fill out
 | 
				
			||||||
 | 
					            # more information.
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (time_field == "created_at") and (not task.name) and \
 | 
				
			||||||
 | 
					                (now - operation_time < timedelta(seconds=60)):
 | 
				
			||||||
 | 
					            # If this new task hasn't had a name for a full 30 seconds, assume
 | 
				
			||||||
 | 
					            # you don't plan on giving it one.
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        topic, content = operation(task, projects, users)
 | 
				
			||||||
 | 
					        logging.info("Sending Zulip for " + topic)
 | 
				
			||||||
 | 
					        result = send_zulip(topic, content)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If the Zulip wasn't sent successfully, don't update the
 | 
				
			||||||
 | 
					        # max timestamp processed so the task has another change to
 | 
				
			||||||
 | 
					        # be forwarded. Exit, giving temporary issues time to
 | 
				
			||||||
 | 
					        # resolve.
 | 
				
			||||||
 | 
					        if not result.get("result"):
 | 
				
			||||||
 | 
					            logging.warn("Malformed result, exiting:")
 | 
				
			||||||
 | 
					            logging.warn(result)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if result["result"] != "success":
 | 
				
			||||||
 | 
					            logging.warn(result["msg"])
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if operation_time > max_timestamp_processed:
 | 
				
			||||||
 | 
					            max_timestamp_processed = operation_time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if max_timestamp_processed > cutoff:
 | 
				
			||||||
 | 
					        max_datestring = max_timestamp_processed.strftime("%s.%f")
 | 
				
			||||||
 | 
					        logging.info("Updating resume file: " + max_datestring)
 | 
				
			||||||
 | 
					        open(config.RESUME_FILE, 'w').write(max_datestring)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					while True:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        process_new_events()
 | 
				
			||||||
 | 
					        time.sleep(5)
 | 
				
			||||||
 | 
					    except KeyboardInterrupt:
 | 
				
			||||||
 | 
					        logging.info("Shutting down...")
 | 
				
			||||||
 | 
					        logging.info("Set LOG_FILE to log to a file instead of stdout.")
 | 
				
			||||||
 | 
					        break
 | 
				
			||||||
		Reference in New Issue
	
	Block a user