diff --git a/.gitignore b/.gitignore index dccaa0b..103448f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +__pycache__ *.pyc *~ *.DS_Store diff --git a/README.md b/README.md index 7944b77..ef0a224 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@ Quip Automation API =================== -## NOTE: If you're looking for the Quip Live Apps repository, it lives at https://github.com/quip/quip-apps - - This is the official repository for the [Quip Automation API](https://quip.com/api/). - * [Quip Automation API Reference](https://quip.com/api/reference) -* [Get a Personal Automatoin API Access Token](https://quip.com/api/personal-token) +* [Get a Personal Automation API Access Token](https://quip.com/api/personal-token) ## Client Libraries @@ -23,18 +19,14 @@ This is the official repository for the [Quip Automation API](https://quip.com/a * [Ruby](https://github.com/jacamat/quip-ruby) (from [@jacamat](https://github.com/jacamat)) * [Go](https://github.com/mduvall/go-quip) (from [@mduvall](https://github.com/mduvall)) * [Elixir](https://github.com/mmartinson/e_quip) (from [@mmartinson](https://github.com/mmartinson)) +* [Java](https://github.com/kenichi-ando/quip-api-java) (from [@kenichi-ando](https://github.com/kenichi-ando)) ## Sample Apps ### Official * [`baqup`](samples/baqup): Exports all of your Quip folders, documents and messages to a local directory. _Python on the command line_ -* [`mail2quip`](samples/mail2quip): Create Quip messages by email. [Sample server](http://mail2quip.appspot.com/). _Python on App Engine_ * [`twitterbot`](samples/twitterbot): Post Twitter messages to a Quip thread via the Twitter streaming API. _Python on the command line_ * [`webhooks`](samples/webhooks): Inbound [Webhook](http://en.wikipedia.org/wiki/Webhook) support for posting GitHub, Crashlytics, PagerDuty and other service notifications to threads. _Python on App Engine_ * [`wordpress`](samples/wordpress): Publish Quip documents to [WordPress](http://wordpress.org/). _Python on the command line_ * [`websocket`](samples/websocket): Receive messages from Quip in real time. _Python on the command line_ - -### Community - -* [`quiptree`](https://github.com/kwent/quiptree): Browser extension to display Quip folders and files in tree format. _Javascript on Browser Extension Engine_ diff --git a/nodejs/quip.js b/nodejs/quip.js index 38dfe26..72bebbf 100644 --- a/nodejs/quip.js +++ b/nodejs/quip.js @@ -42,6 +42,15 @@ var Operation = { DELETE_SECTION: 5 }; +/** + * Message Type + * @enum {string} + */ +var MessageType = { + MESSAGE: 'message', + EDIT: 'edit' +}; + /** * A Quip API client. * @@ -224,13 +233,15 @@ Client.prototype.removeFolderMembers = function(options, callback) { /** * @param {{threadId: string, * maxUpdatedUsec: (number|undefined), - * count: (number|undefined)}} options + * count: (number|undefined) + * messageType: (MessageType|undefined)}} options * @param {function(Error, Object)} callback */ Client.prototype.getMessages = function(options, callback) { this.call_('messages/' + options.threadId + '?' + querystring.stringify({ 'max_updated_usec': options.maxUpdatedUsec, - 'count': options.count + 'count': options.count, + 'message_type': options.messageType }), callback); }; @@ -418,5 +429,6 @@ ClientError.prototype = Object.create(Error.prototype); exports.Color = Color; exports.Operation = Operation; +exports.MessageType = MessageType; exports.Client = Client; exports.ClientError = ClientError; diff --git a/python/quip.py b/python/quip.py index 82b1c29..b39eafe 100644 --- a/python/quip.py +++ b/python/quip.py @@ -35,15 +35,34 @@ import ssl import sys import time -try: - from urllib import urlencode - from urllib2 import HTTPError, Request, urlopen -except ImportError: - from urllib.error import HTTPError - from urllib.parse import urlencode - from urllib.request import Request, urlopen import xml.etree.cElementTree +PY3 = sys.version_info > (3,) + +if PY3: + import urllib.request + import urllib.parse + import urllib.error + + Request = urllib.request.Request + urlencode = urllib.parse.urlencode + urlopen = urllib.request.urlopen + HTTPError = urllib.error.HTTPError + + iteritems = dict.items + +else: + import urllib + import urllib2 + + Request = urllib2.Request + urlencode = urllib.urlencode + urlopen = urllib2.urlopen + HTTPError = urllib2.HTTPError + + iteritems = dict.iteritems + + try: reload(sys) sys.setdefaultencoding('utf8') @@ -77,25 +96,32 @@ # https://cloud.google.com/appengine/docs/python/sockets/ssl_support +class QuipError(Exception): + def __init__(self, code, message, http_error): + Exception.__init__(self, "%d: %s" % (code, message)) + self.code = code + self.http_error = http_error + + class QuipClient(object): """A Quip API client""" # Edit operations APPEND, \ - PREPEND, \ - AFTER_SECTION, \ - BEFORE_SECTION, \ - REPLACE_SECTION, \ - DELETE_SECTION = range(6) + PREPEND, \ + AFTER_SECTION, \ + BEFORE_SECTION, \ + REPLACE_SECTION, \ + DELETE_SECTION = range(6) # Folder colors MANILA, \ - RED, \ - ORANGE, \ - GREEN, \ - BLUE = range(5) + RED, \ + ORANGE, \ + GREEN, \ + BLUE = range(5) def __init__(self, access_token=None, client_id=None, client_secret=None, - base_url=None, request_timeout=None, retry_rate_limit=False): + base_url=None, request_timeout=None): """Constructs a Quip API client. If `access_token` is given, all of the API methods in the client @@ -110,7 +136,6 @@ def __init__(self, access_token=None, client_id=None, client_secret=None, self.client_secret = client_secret self.base_url = base_url if base_url else "https://platform.quip.com" self.request_timeout = request_timeout if request_timeout else 10 - self.retry_rate_limit = retry_rate_limit def get_authorization_url(self, redirect_uri, state=None): """Returns the URL the user should be redirected to to sign in.""" @@ -144,6 +169,12 @@ def get_users(self, ids): """Returns a dictionary of users for the given IDs.""" return self._fetch_json("users/", post_data={"ids": ",".join(ids)}) + def update_user(self, user_id, picture_url=None): + return self._fetch_json("users/update", post_data={ + "user_id": user_id, + "picture_url": picture_url, + }) + def get_contacts(self): """Returns a list of the users in the authenticated user's contacts.""" return self._fetch_json("users/contacts") @@ -185,6 +216,10 @@ def remove_folder_members(self, folder_id, member_ids): "member_ids": ",".join(member_ids), }) + def get_teams(self): + """Returns the teams for the user corresponding to our access token.""" + return self._fetch_json("teams/current") + def get_messages(self, thread_id, max_created_usec=None, count=None): """Returns the most recent messages for the given thread. @@ -218,11 +253,17 @@ def get_threads(self, ids): """Returns a dictionary of threads for the given IDs.""" return self._fetch_json("threads/", post_data={"ids": ",".join(ids)}) - def get_recent_threads(self, max_updated_usec=None, count=None): + def get_recent_threads(self, max_updated_usec=None, count=None, **kwargs): """Returns the recently updated threads for a given user.""" return self._fetch_json( "threads/recent", max_updated_usec=max_updated_usec, - count=count) + count=count, **kwargs) + + def get_matching_threads( + self, query, count=None, only_match_titles=False, **kwargs): + """Returns the recently updated threads for a given user.""" + return self._fetch_json("threads/search", query=query, count=count, + only_match_titles=only_match_titles, **kwargs) def add_thread_members(self, thread_id, member_ids): """Adds the given folder or user IDs to the given thread.""" @@ -231,6 +272,12 @@ def add_thread_members(self, thread_id, member_ids): "member_ids": ",".join(member_ids), }) + def delete_thread(self, thread_id): + """Deletes the thread with the given thread id or secret""" + return self._fetch_json("threads/delete", post_data={ + "thread_id": thread_id, + }) + def remove_thread_members(self, thread_id, member_ids): """Removes the given folder or user IDs from the given thread.""" return self._fetch_json("threads/remove-members", post_data={ @@ -244,6 +291,15 @@ def move_thread(self, thread_id, source_folder_id, destination_folder_id): self.add_thread_members(thread_id, [destination_folder_id]) self.remove_thread_members(thread_id, [source_folder_id]) + def new_chat(self, message, title=None, member_ids=[]): + """Creates a chat with the given title and members, and send the + initial message.""" + return self._fetch_json("threads/new-chat", post_data={ + "message": message, + "title": title, + "member_ids": ",".join(member_ids), + }) + def new_document(self, content, format="html", title=None, member_ids=[]): """Creates a new document from the given content. @@ -252,7 +308,7 @@ def new_document(self, content, format="html", title=None, member_ids=[]): client = quip.QuipClient(...) user = client.get_authenticated_user() - client.new_document(..., member_ids=[user["private_folder_id"]]) + client.new_document(..., member_ids=[user["archive_folder_id"]]) """ return self._fetch_json("threads/new-document", post_data={ @@ -262,19 +318,32 @@ def new_document(self, content, format="html", title=None, member_ids=[]): "member_ids": ",".join(member_ids), }) - def copy_document(self, id, title=None, member_ids=[]): - """Creates a new document from the given thread ID. - - To create it in a folder, include the folder ID in member_ids. + def copy_document(self, thread_id, folder_ids=None, member_ids=None, + title=None, values=None, **kwargs): + """Copies the given document, optionally replaces template variables + in the document with values in 'values' arg. The values argument + must be a dictionary that contains string keys and values that + are either strings, numbers or dictionaries. """ - old_thread = self.get_thread(id) - return self.new_document( - old_thread["html"], title=title or old_thread["thread"]["title"], - member_ids=member_ids) - def merge_comments(self, original_id, children_ids): + args = {"thread_id": thread_id} + if folder_ids: + args["folder_ids"] = ",".join(folder_ids) + if member_ids: + args["member_ids"] = ",".join(member_ids) + if title: + args["title"] = title + if values: + args["values"] = json.dumps(values) + args.update(kwargs) + return self._fetch_json("threads/copy-document", post_data=args) + + def merge_comments(self, original_id, children_ids, ignore_user_ids=[]): """Given an original document and a set of exact duplicates, copies all comments and messages on the duplicates to the original. + + Impersonates the commentors if the access token used has + permission, but does not add them to the thread. """ import re threads = self.get_threads(children_ids + [original_id]) @@ -287,7 +356,13 @@ def merge_comments(self, original_id, children_ids): parent_map = dict(zip(child_section_ids, original_section_ids)) messages = self.get_messages(thread_id) for message in reversed(messages): - kwargs = {} + if message["author_id"] in ignore_user_ids: + continue + kwargs = { + "user_id": message["author_id"], + "frame": "bubble", + "service_id": message["id"], + } if "parts" in message: kwargs["parts"] = json.dumps(message["parts"]) else: @@ -302,7 +377,7 @@ def merge_comments(self, original_id, children_ids): '= 0 and loc >= 0: - section_id = thread["html"][loc+4:loc+15] + section_id = thread["html"][loc + 4:loc + 15] if section_id and section_id in parent_map: kwargs["section_id"] = parent_map[section_id] if "files" in message: @@ -324,12 +399,18 @@ def edit_document(self, thread_id, content, operation=APPEND, format="html", `operation` is relative to another section of the document, you must also specify the `section_id`. """ + + # Since our cell ids in 10x contain ';', which is a valid cgi + # parameter separator, we are replacing them with '_' in 10x cell + # sections. This should be no op for all other sections. + section_id = None if not section_id else section_id.replace(";", "_") + args = { "thread_id": thread_id, "content": content, "location": operation, "format": format, - "section_id": section_id, + "section_id": section_id } args.update(kwargs) return self._fetch_json("threads/edit-document", post_data=args) @@ -350,13 +431,13 @@ def add_to_first_list(self, thread_id, *items, **kwargs): } args.update(kwargs) if "section_id" not in args: - first_list = self.get_first_list(thread_id) - if not first_list: - return None - args["section_id"] = self.get_last_list_item_id(first_list) - if not args["section_id"]: - # section_id = first_list.attrib["id"] - return None + first_list = self.get_first_list( + thread_id, kwargs.pop("document_html", None)) + if first_list: + args["section_id"] = self.get_last_list_item_id(first_list) + if not args.get("section_id"): + args["operation"] = self.APPEND + args["content"] = "\n\n".join([" * %s" % i for i in items]) return self.edit_document(**args) def add_to_spreadsheet(self, thread_id, *rows, **kwargs): @@ -373,12 +454,17 @@ def add_to_spreadsheet(self, thread_id, *rows, **kwargs): spreadsheet = self.get_named_spreadsheet(kwargs["name"], thread_id) else: spreadsheet = self.get_first_spreadsheet(thread_id) - section_id = self.get_last_row_item_id(spreadsheet) + if kwargs.get("add_to_top"): + section_id = self.get_first_row_item_id(spreadsheet) + operation = self.BEFORE_SECTION + else: + section_id = self.get_last_row_item_id(spreadsheet) + operation = self.AFTER_SECTION return self.edit_document( thread_id=thread_id, content=content, section_id=section_id, - operation=self.AFTER_SECTION) + operation=operation) def update_spreadsheet_row(self, thread_id, header, value, updates, **args): """Finds the row where the given header column is the given value, and @@ -392,12 +478,15 @@ def update_spreadsheet_row(self, thread_id, header, value, updates, **args): """ response = None - spreadsheet = self.get_first_spreadsheet(thread_id) + if args.get("name"): + spreadsheet = self.get_named_spreadsheet(args["name"], thread_id) + else: + spreadsheet = self.get_first_spreadsheet(thread_id) headers = self.get_spreadsheet_header_items(spreadsheet) row = self.find_row_from_header(spreadsheet, header, value) if row: ids = self.get_row_ids(row) - for head, val in updates.iteritems(): + for head, val in iteritems(updates): index = self.get_index_of_header(headers, head) if not index or index >= len(ids) or not ids[index]: continue @@ -410,16 +499,25 @@ def update_spreadsheet_row(self, thread_id, header, value, updates, **args): **args) else: updates[header] = value - indexed_items = {} - extra_items = [] - for head, val in updates.iteritems(): - index = self.get_index_of_header( - headers, head, default=None) - if index is None or index in indexed_items: - extra_items.append(val) - else: - indexed_items[index] = val - cells = [] + response = self.add_spreadsheet_row( + thread_id, spreadsheet, updates, headers=headers, **args) + return response + + def add_spreadsheet_row( + self, thread_id, spreadsheet, updates, headers=None, **args): + if not headers: + headers = self.get_spreadsheet_header_items(spreadsheet) + indexed_items = {} + extra_items = [] + for head, val in iteritems(updates): + index = self.get_index_of_header( + headers, head, default=None) + if index is None or index in indexed_items: + extra_items.append(val) + else: + indexed_items[index] = val + cells = [] + if indexed_items: for i in range(max(indexed_items.keys()) + 1): if i in indexed_items: cells.append(indexed_items[i]) @@ -427,16 +525,16 @@ def update_spreadsheet_row(self, thread_id, header, value, updates, **args): cells.append(extra_items.pop(0)) else: cells.append("") - cells.extend(extra_items) - content = "%s" % "".join( - ["%s" % cell for cell in cells]) - section_id = self.get_last_row_item_id(spreadsheet) - response = self.edit_document( - thread_id=thread_id, - content=content, - section_id=section_id, - operation=self.AFTER_SECTION, - **args) + cells.extend(extra_items) + content = "%s" % "".join( + ["%s" % cell for cell in cells]) + section_id = self.get_last_row_item_id(spreadsheet) + response = self.edit_document( + thread_id=thread_id, + content=content, + section_id=section_id, + operation=self.AFTER_SECTION, + **args) return response def toggle_checkmark(self, thread_id, item, checked=True): @@ -535,6 +633,11 @@ def get_last_row_item_id(self, spreadsheet_tree): items = list(spreadsheet_tree.iter("tr")) return items[-1].attrib["id"] if items else None + def get_first_row_item_id(self, spreadsheet_tree): + """Returns the last row in the given spreadsheet `ElementTree`.""" + items = list(spreadsheet_tree.iter("tr")) + return items[1].attrib["id"] if items else None + def get_row_items(self, row_tree): """Returns the text of items in the given row `ElementTree`.""" return [(list(x.itertext()) or [None])[0] for x in row_tree] @@ -563,7 +666,7 @@ def get_index_of_header(self, header_items, header, default=0): if ord('A') < char < ord('Z'): return char - ord('A') + 1 else: - logging.warning("Could not find header, using first column") + pass return default def find_row_from_header(self, spreadsheet_tree, header, value): @@ -599,10 +702,20 @@ def parse_spreadsheet_contents(self, spreadsheet_tree): for i, cell in enumerate(row): if cell.tag != "td": continue - value["cells"][spreadsheet["headers"][i]] = { + data = { "id": cell.attrib.get("id"), - "content": list(cell.itertext())[0], } + images = list(cell.iter("img")) + if images: + data["content"] = images[0].attrib.get("src") + else: + data["content"] = list(cell.itertext())[0].replace( + u"\u200b", "") + style = cell.attrib.get("style") + if style and "background-color:#" in style: + sharp = style.find("#") + data["color"] = style[sharp + 1:sharp + 7] + value["cells"][spreadsheet["headers"][i]] = data if len(value["cells"]): spreadsheet["rows"].append(value) return spreadsheet @@ -632,19 +745,10 @@ def get_blob(self, thread_id, blob_id): except HTTPError as error: try: # Extract the developer-friendly error message from the response - message = json.loads(error.read())["error_description"] + message = json.loads(error.read().decode())["error_description"] except Exception: raise error - if (self.retry_rate_limit and error.code == 503 and - message == "Over Rate Limit"): - # Retry later. - reset_time = float(error.headers.get("X-RateLimit-Reset")) - delay = max(2, reset_time - time.time() + 1) - logging.warning("Rate Limit, delaying for %d seconds" % delay) - time.sleep(delay) - return self.get_blob(thread_id, blob_id) - else: - raise QuipError(error.code, message, error) + raise QuipError(error.code, message, error) def put_blob(self, thread_id, blob, name=None): """Uploads an image or other blob to the given Quip thread. Returns an @@ -673,45 +777,39 @@ def put_blob(self, thread_id, blob, name=None): raise error raise QuipError(error.response.status_code, message, error) - def new_websocket(self): - """Returns a websocket URL to connect to. The URL may expire if no - connection is initiated within 60 seconds.""" - return self._fetch_json("websockets/new") + def new_websocket(self, **kwargs): + """Gets a websocket URL to connect to. + """ + return self._fetch_json("websockets/new", **kwargs) def _fetch_json(self, path, post_data=None, **args): request = Request(url=self._url(path, **args)) if post_data: post_data = dict((k, v) for k, v in post_data.items() if v or isinstance(v, int)) - request.data = urlencode(self._clean(**post_data)).encode("utf-8") + request_data = urlencode(self._clean(**post_data)) + if PY3: + request.data = request_data.encode() + else: + request.data = request_data + if self.access_token: request.add_header("Authorization", "Bearer " + self.access_token) try: return json.loads( - urlopen(request, timeout=self.request_timeout).read().decode('utf-8')) + urlopen( + request, timeout=self.request_timeout).read().decode()) except HTTPError as error: try: # Extract the developer-friendly error message from the response - message = json.loads(error.read())["error_description"] + message = json.loads(error.read().decode())["error_description"] except Exception: raise error - if (self.retry_rate_limit and error.code == 503 and - message == "Over Rate Limit"): - # Retry later. - reset_time = float(error.headers.get("X-RateLimit-Reset")) - delay = max(2, reset_time - time.time() + 1) - logging.warning("Rate Limit, delaying for %d seconds", delay) - time.sleep(delay) - return self._fetch_json(path, post_data, **args) - else: - raise QuipError(error.code, message, error) + raise QuipError(error.code, message, error) def _clean(self, **args): - # We only expect ints or strings, but on Windows ints can become longs - return dict((k, str(v) if isinstance( - v, (int, float, complex)) else v.encode("utf-8")) - for k, v in args.items() if v or isinstance( - v, (int, float, complex))) + return dict((k, str(v) if isinstance(v, int) else v.encode("utf-8")) + for k, v in args.items() if v or isinstance(v, int)) def _url(self, path, **args): url = self.base_url + "/1/" + path @@ -719,10 +817,3 @@ def _url(self, path, **args): if args: url += "?" + urlencode(args) return url - - -class QuipError(Exception): - def __init__(self, code, message, http_error): - Exception.__init__(self, "%d: %s" % (code, message)) - self.code = code - self.http_error = http_error diff --git a/samples/README.md b/samples/README.md index a8ce4ae..6c313ec 100644 --- a/samples/README.md +++ b/samples/README.md @@ -7,7 +7,4 @@ Quip API Sample Apps * [`webhooks`](webhooks): Inbound [Webhook](http://en.wikipedia.org/wiki/Webhook) support for posting GitHub, Crashlytics, PagerDuty and other service notifications to threads. _Python on App Engine_ * [`wordpress`](wordpress): Publish Quip documents to [WordPress](http://wordpress.org/). _Python on the command line_ * [`websocket`](websocket): Receive messages from Quip in real time. _Python on the command line_ - -# Community Samples - -* [`quiptree`](https://github.com/kwent/quiptree): Browser extension to display Quip folders and files in tree format. _Javascript on Browser Extension Engine_ +* [`events-api`](events-api): Examine, audit, and quarantine Quip content in real-time based on the Events API feed. _Python on the command line_ diff --git a/samples/baqup/main.py b/samples/baqup/main.py index 865456f..401bdc2 100755 --- a/samples/baqup/main.py +++ b/samples/baqup/main.py @@ -62,7 +62,7 @@ def main(): client = quip.QuipClient( access_token=args.access_token, base_url=args.quip_api_base_url, - retry_rate_limit=True, request_timeout=120) + request_timeout=120) output_directory = os.path.join( _normalize_path(args.output_directory), "baqup") _ensure_path_exists(output_directory) @@ -98,7 +98,7 @@ def _descend_into_folder(folder_id, processed_folder_ids, client, processed_folder_ids.add(folder_id) try: folder = client.get_folder(folder_id) - except quip.QuipError, e: + except quip.QuipError as e: if e.code == 403: logging.warning("%sSkipped over restricted folder %s.", " " * depth, folder_id) @@ -106,7 +106,7 @@ def _descend_into_folder(folder_id, processed_folder_ids, client, logging.warning("%sSkipped over folder %s due to unknown error %d.", " " * depth, folder_id, e.code) return - except urllib2.HTTPError, e: + except urllib2.HTTPError as e: logging.warning("%sSkipped over folder %s due to HTTP error %d.", " " * depth, folder_id, e.code) return diff --git a/samples/events-api/README.md b/samples/events-api/README.md new file mode 100644 index 0000000..ee1bdbb --- /dev/null +++ b/samples/events-api/README.md @@ -0,0 +1,18 @@ +# Examine, audit, and quarantine Quip content in real-time based on the Events API feed. + +A simple webserver to manage [Events API](https://quip.com/dev/admin/documentation#events-requires-subscription) cursors, audit messages, examine threads, and quarantine content. + +## Requirements +Uses the [tornado](https://pypi.org/project/tornado/) networking library to run a local server. + +``` +pip install -r requirements.txt +``` + +## Running + +``` +./quarantine_demo.py +``` + +Requires an admin [access token](https://quip.com/dev/automation) and [Events API](https://quip.com/dev/admin/documentation#events-requires-subscription) subscription. \ No newline at end of file diff --git a/samples/events-api/quarantine_demo.py b/samples/events-api/quarantine_demo.py new file mode 100644 index 0000000..f672dab --- /dev/null +++ b/samples/events-api/quarantine_demo.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python +# +# pip install tornado +# +# Copyright 2019 Quip +# http://www.apache.org/licenses/LICENSE-2.0.html + +""" +Examples: + +Audit contents of newly created messages. + -Loop over all company events. + -Fetch and examine messages' contents, based on `create-message` events. + -Conditionally quarantine message contents based on admin-specified criteria. + +Quarantine sensitive threads if certain events, e.g. sharing, occur. + -Loop over all company events. + -Check if ids from `share-thread` events match an admin-specified list. + -Conditionally quarantine threads' contents. + +Examine thread metadata and content. + -Fetch a thread using its id. + +""" + +import json +import logging +import os.path +import re +import requests +import tornado.auth +import tornado.gen +import tornado.ioloop +import tornado.web +import tornado.httpclient + +from tornado.options import define, options + +define("port", type=int, default=8080) +define("admin_endpoint_base", default="https://platform.quip.com/1/admin") +define("company_id", default="<11_character_company_id") +# Must be for an admin. +define("access_token", + default="", +] + + +class Application(tornado.web.Application): + def __init__(self): + base_dir = os.path.dirname(__file__) + settings = { + "debug": True + } + tornado.web.Application.__init__(self, [ + tornado.web.url(r"/events/realtime", RealtimeHandler, name="realtime"), + tornado.web.url(r"/events/cursor", CursorHandler, name="cursor"), + tornado.web.url(r"/audit/message/(.*)", AuditMessageHandler, name="message"), + tornado.web.url(r"/threads/(.*)", GetThread, name="get-thread"), + ], **settings) + + +class EventsDemoHandler(tornado.web.RequestHandler): + def authorized_headers(self): + return { + 'Authorization': f"Bearer {options.access_token}", + } + + def fetch_new_cursor_json(self): + url = f"{options.admin_endpoint_base}/events/1/cursor/realtime/create" + params = {"company_id": options.company_id} + response = requests.request("GET", url, headers=self.authorized_headers(), params=params) + return response.json() + + def next_cursor(self): + try: + cursor = self.get_argument("cursor") + except tornado.web.MissingArgumentError as e: + cursor = self.fetch_new_cursor_json()["next_cursor"] + return cursor + + def quarantine_id(self, object_id): + url = f"{options.admin_endpoint_base}/quarantine" + params = { + "company_id": options.company_id, + "object_id": object_id + } + requests.request("POST", url, headers=self.authorized_headers(), params=params) + + def unquarantine_id(self, object_id): + url = f"{options.admin_endpoint_base}/quarantine" + params = { + "company_id": options.company_id, + "object_id": object_id + } + requests.request("DELETE", url, headers=self.authorized_headers(), params=params) + + +class CursorHandler(EventsDemoHandler): + @tornado.gen.coroutine + def get(self): + response_json = self.fetch_new_cursor_json() + pretty_response = json.dumps(response_json, sort_keys=True, indent=4) + self.write(f"
{pretty_response}
") + + +class RealtimeHandler(EventsDemoHandler): + @tornado.gen.coroutine + def get(self): + url = f"{options.admin_endpoint_base}/events/1/events/realtime/get" + params = { + "company_id": options.company_id, + "cursor": self.next_cursor() + } + response = requests.request("GET", url, headers=self.authorized_headers(), params=params) + raw_html_output = self.pretty_html_formatting(response) + if options.enable_audit_for_realtime: + for event in response.json()["events"]: + audit_event(self, event) + self.write(raw_html_output) + + def pretty_html_formatting(self, response): + response_json = response.json() + next_events_url = f"{self.reverse_url('realtime')}?cursor={response_json['next_cursor']}" + pretty_response = json.dumps(response_json, sort_keys=True, indent=4).replace( + '"event"', '"event"') + return f""" + Fetch Next Events +
+
+

Raw JSON response:

+
{pretty_response}
+ """ + + +def audit_event(handler, event): + if event["event"] == "share-thread": + if event["thread_id"] in SHARE_DENYLIST: + handler.quarantine_id(event["thread_id"]) + elif event["event"] == "create-message": + audit_message(handler, event["message_id"]) + + +def audit_message_summary(handler, message_id): + url = f"{options.admin_endpoint_base}/message/{message_id}" + params = { + "company_id": options.company_id + } + response = requests.request("GET", url, headers=handler.authorized_headers(), params=params) + unredacted_message_text = response.json()[0]["text"] + + denied_words = set() + for word in DENYLIST_WORDS: + if word in unredacted_message_text: + denied_words.add(word) + + denied_patterns = set() + for name, pattern in DENYLIST_PATTERNS.items(): + if re.search(pattern, unredacted_message_text): + denied_patterns.add(name) + + is_disallowed = bool(denied_words or denied_patterns) + + audit_summary = f"Message {message_id}

Audit: {'FAILED' if is_disallowed else 'PASSED'}." + if denied_words: + audit_summary += f"

Denylist words: {denied_words}" + if denied_patterns: + audit_summary += f"

Denylist patterns: {denied_patterns}" + + return audit_summary + + +def audit_message(handler, message_id): + audit_summary = audit_message_summary(handler, message_id) + # Crude, but hey it's just a demo... + if "FAILED" in audit_summary: + handler.quarantine_id(message_id) + + +class AuditMessageHandler(EventsDemoHandler): + @tornado.gen.coroutine + def get(self, message_id): + audit_summary = audit_message_summary(self, message_id) + audit_message(self, message_id) + self.write(audit_summary) + + +class GetThread(EventsDemoHandler): + @tornado.gen.coroutine + def get(self, thread_id): + url = f"{options.admin_endpoint_base}/threads/{thread_id}" + params = { + "company_id": options.company_id + } + response = requests.request("GET", url, headers=self.authorized_headers(), params=params) + response_json = response.json() + + # Field contains raw html that formats oddly. + thread_html = response_json["html"] + del response_json["html"] + + pretty_response = json.dumps(response_json, sort_keys=True, indent=4) + output = f"
{pretty_response}
" + output += f"

Thread's HTML (extracted out of above):

{thread_html}" + self.write(output) + + +def main(): + tornado.options.parse_command_line() + Application().listen(options.port) + logging.info("Running at http://localhost:%d", options.port) + tornado.ioloop.IOLoop.instance().start() + + +if __name__ == "__main__": + main() diff --git a/samples/events-api/requirements.txt b/samples/events-api/requirements.txt new file mode 100644 index 0000000..46690a7 --- /dev/null +++ b/samples/events-api/requirements.txt @@ -0,0 +1 @@ +tornado==5.1.1 diff --git a/samples/mail2quip/README.md b/samples/mail2quip/README.md deleted file mode 100644 index 64a214a..0000000 --- a/samples/mail2quip/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Mail2Quip - -Simple (state-less) server that creates Quip messages from emails. Sample server [running here](http://mail2quip.appspot.com/). - -## Caveats - -API access tokens are embedded in the email. If you generate a new access token, you will need to start using a new email address. - -## Running Locally - -First, install the App Engine SDK from https://developers.google.com/appengine/downloads and open it to install the symlinks. Then run: - -``` -PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python dev_appserver.py ./ -``` - -The App Engine admin console has a tool for testing incoming emails without deploying. - -## Deploying to App Engine - -``` -appcfg.py --oauth2 --no_cookies update ./ -``` diff --git a/samples/mail2quip/app.py b/samples/mail2quip/app.py deleted file mode 100644 index ee1bcb7..0000000 --- a/samples/mail2quip/app.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright 2014 Quip -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -"""Inbound email handler that creates Quip messages from emails. - -This is a sample app for the Quip API - https://quip.com/api/. -""" - -import logging -import jinja2 -import os -import quip -import urllib -import webapp2 - -from google.appengine.api import files -from google.appengine.ext import blobstore -from google.appengine.ext.webapp import blobstore_handlers -from google.appengine.ext.webapp.mail_handlers import InboundMailHandler - -JINJA_ENVIRONMENT = jinja2.Environment( - loader=jinja2.FileSystemLoader(os.path.dirname(__file__)), - extensions=["jinja2.ext.autoescape"], - autoescape=True) - -class Home(webapp2.RequestHandler): - def get(self): - template = JINJA_ENVIRONMENT.get_template( - "templates/index.html") - self.response.write(template.render()) - - -class MailHandler(InboundMailHandler): - def receive(self, message): - addresses = message.to.split(',') - thread_id = "" - token = "" - client = None - for address in addresses: - try: - (to, domain) = address.split('@') - (thread_id, token) = to.split('+', 1) - except: - pass - if len(thread_id) in [11,12] and len(token) > 0: - client = quip.QuipClient(access_token=token, request_timeout=30) - try: - client.get_thread(thread_id) - break - except Exception as e: - client = None - logging.exception(e) - client = quip.QuipClient(access_token=to, request_timeout=30) - try: - client.get_authenticated_user() - thread_id = "" - break - except Exception as e: - client = None - logging.exception(e) - if not client: - logging.error("Could not find token in %r", addresses) - self.abort(404) - text = None - for content_type, body in message.bodies("text/plain"): - text = body.decode() - # Strip some common signature patterns - for pattern in ["\n----", "\nIFTTT"]: - if pattern in text: - text = text[:text.find(pattern)] - if len(text) > 0: - break - html = None - for content_type, body in message.bodies("text/html"): - html = body.decode() - if len(html) > 0: - break - attachments = [] - if hasattr(message, "attachments"): - for filename, attachment in message.attachments: - try: - blob = files.blobstore.create( - _blobinfo_uploaded_filename=filename) - with files.open(blob, 'a') as f: - f.write(attachment.decode()) - files.finalize(blob) - host = self.request.host_url.replace("http:", "https:") - attachments.append("%s/attach/%s" % ( - host, files.blobstore.get_blob_key(blob))) - except Exception: - pass - message_id = None - if "message-id" in message.original: - message_id = message.original["message-id"] - if thread_id: - # Post a message - args = { - "silent": "silent" in message.subject, - } - if attachments: - args["attachments"] = ",".join(attachments) - if message_id: - args["service_id"] = message_id - client.new_message(thread_id, text, **args) - else: - # Create a thread from the message body - thread = client.new_document( - html or text, format="html" if html else "markdown", - title=message.subject) - if attachments: - client.new_message( - thread["thread"]["id"], attachments=",".join(attachments)) - - -class AttachmentHandler(blobstore_handlers.BlobstoreDownloadHandler): - def get(self, resource): - resource = str(urllib.unquote(resource)) - blob_info = blobstore.BlobInfo.get(resource) - self.send_blob(blob_info) - - -application = webapp2.WSGIApplication([ - ('/', Home), - ('/attach/([^/]+)?', AttachmentHandler), - MailHandler.mapping(), -], debug=True) diff --git a/samples/mail2quip/app.yaml b/samples/mail2quip/app.yaml deleted file mode 100644 index 5ecd03e..0000000 --- a/samples/mail2quip/app.yaml +++ /dev/null @@ -1,26 +0,0 @@ -application: mail2quip -version: 1 -runtime: python27 -api_version: 1 -threadsafe: true - -inbound_services: -- mail - -handlers: -- url: /static - static_dir: static - -- url: /admin/.* - script: google.appengine.ext.admin.application - login: admin - -- url: /.* - script: app.application - secure: always - -libraries: -- name: webapp2 - version: latest -- name: jinja2 - version: latest diff --git a/samples/mail2quip/quip.py b/samples/mail2quip/quip.py deleted file mode 120000 index 6ca5967..0000000 --- a/samples/mail2quip/quip.py +++ /dev/null @@ -1 +0,0 @@ -../../python/quip.py \ No newline at end of file diff --git a/samples/mail2quip/static/mail2quip.css b/samples/mail2quip/static/mail2quip.css deleted file mode 100644 index f738ae7..0000000 --- a/samples/mail2quip/static/mail2quip.css +++ /dev/null @@ -1,6 +0,0 @@ -body { - font-family: Helvetica, Arial, sans-serif; - font-size: 14px; - padding: 20px; - line-height: 1.4; -} diff --git a/samples/mail2quip/templates/index.html b/samples/mail2quip/templates/index.html deleted file mode 100644 index a6b2b57..0000000 --- a/samples/mail2quip/templates/index.html +++ /dev/null @@ -1,28 +0,0 @@ - -{% autoescape true %} - - - Mail2Quip - email to Quip gateway - - - -

Mail 2 Quip

-

- Do you have emailed alerts you'd like to appear in Quip? IFTTT - or Zapier recipes that should go to Quip? Mail2Quip allows you - to create new messages or threads by email. -

-
    -
  1. Get a token for - the user you want to post as.
  2. -
  3. Send an email: -
      -
    • To create a new thread: <token>@mail2quip.appspotmail.com
    • -
    • To send a message to an existing thread: <thread_id>+<token>@mail2quip.appspotmail.com
    • -
    -
  4. -
  5. See your message or thread posted on Quip.
  6. -
- - -{% endautoescape %} diff --git a/samples/websocket/main.py b/samples/websocket/main.py index 64a0a1e..ad71e7b 100755 --- a/samples/websocket/main.py +++ b/samples/websocket/main.py @@ -9,29 +9,34 @@ import argparse import json -import logging import quip -import thread +import sys import time import websocket +PY3 = sys.version_info > (3,) +if PY3: + import _thread as thread +else: + import thread HEARTBEAT_INTERVAL = 20 + def open_websocket(url): def on_message(ws, message): - print "message:" - print json.dumps(json.loads(message), indent=4) + print("message:") + print(json.dumps(json.loads(message), indent=4)) def on_error(ws, error): - print "error:" - print error + print("error:") + print(error) def on_close(ws): - print "### connection closed ###" + print("### connection closed ###") def on_open(ws): - print "### connection established ###" + print("### connection established ###") def run(*args): while True: @@ -40,7 +45,7 @@ def run(*args): thread.start_new_thread(run, ()) - websocket.enableTrace(True) + # websocket.enableTrace(True) ws = websocket.WebSocketApp( url, on_message=on_message, on_error=on_error, on_close=on_close) ws.on_open = on_open diff --git a/samples/websocket/requirements.txt b/samples/websocket/requirements.txt new file mode 100644 index 0000000..965ad95 --- /dev/null +++ b/samples/websocket/requirements.txt @@ -0,0 +1 @@ +websocket-client==0.57.0 diff --git a/slack.json b/slack.json new file mode 100644 index 0000000..53e2783 --- /dev/null +++ b/slack.json @@ -0,0 +1,8 @@ +{ + "checkpoint": { + "checkpoint_ready_check": false, + "notifications": { + "dm": false + } + } + } \ No newline at end of file