├── plugins ├── __init__.py ├── README.md ├── echo.py ├── help.py ├── stock.py ├── youtube.py ├── gif.py ├── image.py ├── wiki.py ├── stackdriver-reader.py └── atlassian-jira.py ├── Procfile ├── .gitignore ├── screenshot └── stackdriver.png ├── requirements.txt ├── config.py ├── README.md └── bot.py /plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python bot.py start 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ropeproject 2 | *.pyc 3 | *.log 4 | -------------------------------------------------------------------------------- /screenshot/stackdriver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsalum/slackbot-python/HEAD/screenshot/stackdriver.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.3.0 2 | boto==2.28.0 3 | importlib==1.0.3 4 | python-daemon==1.6 5 | beautifulsoup4==4.3.2 6 | jira==0.25 7 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | ## stackdriver-reader 4 | 5 | Stackdriver policy alerts integrated via SNS (https://app.stackdriver.com/settings/notifications/sns). 6 | 7 | ![image](https://raw.githubusercontent.com/fsalum/slackbot-python/master/screenshot/stackdriver.png) 8 | -------------------------------------------------------------------------------- /plugins/echo.py: -------------------------------------------------------------------------------- 1 | """!echo [] echo a test message""" 2 | 3 | import re 4 | 5 | def echo(message): 6 | return message 7 | 8 | 9 | def on_message(msg, server): 10 | text = msg.get("text", "") 11 | match = re.findall(r"!echo( .*)?", text) 12 | if not match: return 13 | 14 | message = match[0] 15 | return echo(message) 16 | -------------------------------------------------------------------------------- /plugins/help.py: -------------------------------------------------------------------------------- 1 | """!help [] prints help on all commands if no command given, or a specific command""" 2 | 3 | import re 4 | 5 | def on_message(msg, server): 6 | text = msg.get("text", "") 7 | match = re.findall(r"!help( .*)?", text) 8 | if not match: return 9 | 10 | helptopic = match[0].strip() 11 | if helptopic: 12 | return server["hooks"]["extendedhelp"].get(helptopic, "No help found for %s" % helptopic) 13 | else: 14 | return "\n".join(val for _, val in server["hooks"]["help"].iteritems()) 15 | -------------------------------------------------------------------------------- /plugins/stock.py: -------------------------------------------------------------------------------- 1 | """!stock return a stock photo for """ 2 | 3 | from random import shuffle 4 | import re 5 | 6 | import requests 7 | from bs4 import BeautifulSoup 8 | 9 | def stock(searchterm): 10 | url = "http://www.shutterstock.com/cat.mhtml?searchterm={}&search_group=&lang=en&language=en&search_source=search_form&version=llv1".format(searchterm) 11 | r = requests.get(url) 12 | soup = BeautifulSoup(r.text) 13 | images = [x["src"] for x in soup.select(".gc_clip img")] 14 | shuffle(images) 15 | 16 | return images[0] if images else "" 17 | 18 | def on_message(msg, server): 19 | text = msg.get("text", "") 20 | match = re.findall(r"!stock (.*)", text) 21 | if not match: return 22 | 23 | searchterm = match[0] 24 | return stock(searchterm) 25 | -------------------------------------------------------------------------------- /plugins/youtube.py: -------------------------------------------------------------------------------- 1 | """!youtube return the first youtube search result for """ 2 | 3 | import re 4 | from urllib import quote 5 | 6 | import requests 7 | 8 | def youtube(searchterm): 9 | searchterm = quote(searchterm) 10 | url = "https://gdata.youtube.com/feeds/api/videos?q={}&orderBy=relevance&alt=json" 11 | url = url.format(searchterm) 12 | 13 | j = requests.get(url).json() 14 | 15 | results = j["feed"] 16 | if "entry" not in results: 17 | return "sorry, no videos found" 18 | 19 | video = results["entry"][0]["link"][0]["href"] 20 | video = re.sub("&feature=youtube_gdata", "", video) 21 | 22 | return video 23 | 24 | def on_message(msg, server): 25 | text = msg.get("text", "") 26 | match = re.findall(r"!youtube (.*)", text) 27 | if not match: return 28 | 29 | searchterm = match[0] 30 | return youtube(searchterm) 31 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | config = { 2 | "webhook_token": '', 3 | "sqs_token": '', 4 | "aws_access_key": '', 5 | "aws_secret_key": '', 6 | "username": '', 7 | "icon_url": 'https://slack-assets2.s3-us-west-2.amazonaws.com/10068/img/slackbot_192.png', 8 | "domain": '', 9 | "queue": '', 10 | 11 | # stackdriver plugin 12 | "stackdriver_username": 'stackdriver', 13 | "stackdriver_channel": '#monitoring', 14 | "stackdriver_icon": 'https://fbcdn-profile-a.akamaihd.net/hprofile-ak-xaf1/t1.0-1/p160x160/417293_271031206332030_738543521_n.jpg', 15 | "stackdriver_sns_topic": 'arn:aws:sns:us-east-1::', 16 | 17 | # jira plugin 18 | "jira_username": '', 19 | "jira_password": '', 20 | } 21 | -------------------------------------------------------------------------------- /plugins/gif.py: -------------------------------------------------------------------------------- 1 | """!gif return a random result from the google gif search result for """ 2 | 3 | from urllib import quote 4 | import re 5 | import requests 6 | from random import shuffle 7 | 8 | def gif(searchterm, unsafe=False): 9 | searchterm = quote(searchterm) 10 | 11 | safe = "&safe=" if unsafe else "&safe=active" 12 | searchurl = "https://www.google.com/search?tbs=itp:animated&tbm=isch&q={0}{1}".format(searchterm, safe) 13 | 14 | # this is an old iphone user agent. Seems to make google return good results. 15 | useragent = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us) AppleWebKit/532.9 (KHTML, like Gecko) Versio n/4.0.5 Mobile/8A293 Safari/6531.22.7" 16 | 17 | result = requests.get(searchurl, headers={"User-agent": useragent}).text 18 | 19 | gifs = re.findall(r'imgurl.*?(http.*?)\\', result) 20 | shuffle(gifs) 21 | 22 | return gifs[0] if gifs else "" 23 | 24 | def on_message(msg, server): 25 | text = msg.get("text", "") 26 | match = re.findall(r"!gif (.*)", text) 27 | if not match: return 28 | 29 | searchterm = match[0] 30 | return gif(searchterm) 31 | -------------------------------------------------------------------------------- /plugins/image.py: -------------------------------------------------------------------------------- 1 | """!image return a random result from the google image search result for """ 2 | 3 | from urllib import quote 4 | import re 5 | import requests 6 | from random import shuffle 7 | 8 | def image(searchterm, unsafe=False): 9 | searchterm = quote(searchterm) 10 | 11 | safe = "&safe=" if unsafe else "&safe=active" 12 | searchurl = "https://www.google.com/search?tbm=isch&q={0}{1}".format(searchterm, safe) 13 | 14 | # this is an old iphone user agent. Seems to make google return good results. 15 | useragent = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us) AppleWebKit/532.9 (KHTML, like Gecko) Versio n/4.0.5 Mobile/8A293 Safari/6531.22.7" 16 | 17 | result = requests.get(searchurl, headers={"User-agent": useragent}).text 18 | 19 | images = re.findall(r'imgurl.*?(http.*?)\\', result) 20 | shuffle(images) 21 | 22 | return images[0] if images else "" 23 | 24 | def on_message(msg, server): 25 | text = msg.get("text", "") 26 | match = re.findall(r"!image (.*)", text) 27 | if not match: return 28 | 29 | searchterm = match[0] 30 | return image(searchterm) 31 | -------------------------------------------------------------------------------- /plugins/wiki.py: -------------------------------------------------------------------------------- 1 | """!wiki returns a wiki link for """ 2 | import json 3 | import re 4 | from urllib import quote 5 | import sys 6 | 7 | import requests 8 | from bs4 import BeautifulSoup 9 | 10 | def wiki(searchterm): 11 | """return the top wiki search result for the term""" 12 | searchterm = quote(searchterm) 13 | 14 | url = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={0}&format=json" 15 | url = url.format(searchterm) 16 | 17 | result = requests.get(url).json() 18 | 19 | pages = result["query"]["search"] 20 | 21 | # try to reject disambiguation pages 22 | pages = [p for p in pages if not 'may refer to' in p["snippet"]] 23 | 24 | if not pages: 25 | return "" 26 | 27 | page = quote(pages[0]["title"].encode("utf8")) 28 | link = "http://en.wikipedia.org/wiki/{0}".format(page) 29 | 30 | r = requests.get("http://en.wikipedia.org/w/api.php?format=json&action=parse&page={}".format(page)).json() 31 | soup = BeautifulSoup(r["parse"]["text"]["*"]) 32 | p = soup.find('p').get_text() 33 | p = p[:8000] 34 | 35 | return u"{}\n{}".format(p, link) 36 | 37 | def on_message(msg, server): 38 | text = msg.get("text", "") 39 | match = re.findall(r"!wiki (.*)", text) 40 | if not match: return 41 | 42 | searchterm = match[0] 43 | return wiki(searchterm) 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlackBot 2 | A [Slack](https://slack.com/) bot running in *daemon mode* with *Amazon SQS* integration. 3 | 4 | Original version using *Flask* and *Outgoing WebHooks* integration by [llimllib/slask](https://github.com/llimllib/slask). 5 | 6 | ## Installation 7 | 8 | 1. Clone this repo 9 | 2. `pip install -r requirements.txt` 10 | 3. Add the Amazon SQS integration on [Slack](https://slack.com) 11 | 4. Add the Incoming WebHooks integration on [Slack](https://slack.com) 12 | 5. Update config.py with your information (tokens,keys,botname,etc) 13 | 6. Run `python bot.py start` 14 | 7. That's it! Try typing `!echo Hello World` into any chat room 15 | 16 | ### IAM Permission for Slack 17 | 18 | ``` 19 | { 20 | "Version": "2012-10-17", 21 | "Statement": [ 22 | { 23 | "Sid": "Stmt2123981740180", 24 | "Effect": "Allow", 25 | "Action": [ 26 | "sqs:*" 27 | ], 28 | "Resource": [ 29 | "arn:aws:sqs:us-east-1::" 30 | ] 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | ### Heroku 37 | 38 | You can also host your Bot for free on [Heroku](http://heroku.com). It is ready to deploy. 39 | 40 | ```bash 41 | heroku create 42 | git push heroku master 43 | heroku ps:scale worker=1 44 | heroku ps 45 | heroku logs 46 | ``` 47 | 48 | ## Commands 49 | 50 | Right now, `!help`, `!echo`, `!gif`, `!image`, `!youtube` and `!wiki` are the only available commands. 51 | 52 | It's super easy to add your own commands! Just create a python file in the plugins directory with an `on_message` function that returns a string. 53 | 54 | ## Integrations 55 | 56 | Besides commands you can also integrate other types of plugins that just listen for a specific message in the SQS queue. 57 | 58 | See per example the [stackdriver-reader](https://github.com/fsalum/slackbot-python/blob/master/plugins/README.md) plugin that wait for Stackdriver to send policy alerts to a SNS topic which is subscribed by the SQS queue used by this daemon. 59 | -------------------------------------------------------------------------------- /plugins/stackdriver-reader.py: -------------------------------------------------------------------------------- 1 | """stackdriver-reader listens to policy alerts sent via SNS notification integration""" 2 | 3 | import requests 4 | import json 5 | from config import config 6 | 7 | 8 | def stackdriver(text): 9 | incident_id = text.get("incident_id", "") 10 | resource_id = text.get("resource_id", "") 11 | resource_name = text.get("resource_name", "") 12 | state = text.get("state", "") 13 | started_at = text.get("started_at", "") 14 | ended_at = text.get("ended_at", "") 15 | incident_url = "<" + text.get("url", "") + "|" + incident_id + ">" 16 | summary = text.get("summary", "") 17 | 18 | if state == "open": 19 | color = "#d00000" 20 | elif state == "acknowledged": 21 | color = "0000d0" 22 | elif state == "closed": 23 | color = "00d000" 24 | 25 | send_msg(summary, color, incident_url, resource_id, resource_name, state) 26 | return 27 | 28 | 29 | def send_msg(summary, color, incident_url, resource_id, resource_name, state): 30 | webhook_token = config.get("webhook_token") 31 | domain = config.get("domain") 32 | stackdriver_username = config.get("stackdriver_username") 33 | stackdriver_channel = config.get("stackdriver_channel") 34 | stackdriver_icon = config.get("stackdriver_icon") 35 | url = "https://" + domain + "/services/hooks/incoming-webhook?token=" + webhook_token 36 | 37 | payload = {'channel': stackdriver_channel, 'username': stackdriver_username, 'icon_url': stackdriver_icon, 38 | 'attachments': [{'fallback': 'stackdriver alerts', 'pretext': summary, "color": color, 39 | 'fields': [{'title': 'Incident', 'value': incident_url, 'short': True}, 40 | {'title': 'State', 'value': state, 'short': True}, 41 | {'title': 'Resource ID', 'value': resource_id, 'short': True}, 42 | {'title': 'Resource Name', 'value': resource_name, 'short': True}, ]}]} 43 | 44 | r = requests.post(url, data=json.dumps(payload), timeout=5) 45 | print r.status_code 46 | 47 | 48 | def on_message(msg, server): 49 | topic_arn = msg.get("TopicArn", "") 50 | 51 | if topic_arn: 52 | stackdriver_sns_topic = config.get("stackdriver_sns_topic") 53 | if stackdriver_sns_topic in topic_arn: 54 | incident_dict = json.loads(msg.get("Message", "")) 55 | text = incident_dict.get("incident", "") 56 | else: 57 | return 58 | else: 59 | return 60 | 61 | if not text: 62 | return 63 | 64 | return stackdriver(text) 65 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Slack.com Python bot using Amazon SNS/Incoming WebHook integration 4 | # 5 | 6 | import sys 7 | import os 8 | import re 9 | import time 10 | import json 11 | import requests 12 | import importlib 13 | import traceback 14 | import boto.sqs 15 | from boto.sqs.message import RawMessage 16 | from glob import glob 17 | from daemon import runner 18 | 19 | curdir = os.path.dirname(os.path.abspath(__file__)) 20 | os.chdir(curdir) 21 | 22 | from config import config 23 | 24 | queue_name = config.get("queue") 25 | aws_access_key = config.get("aws_access_key") 26 | aws_secret_key = config.get("aws_secret_key") 27 | region = 'us-east-1' 28 | hooks = {} 29 | 30 | conn = boto.sqs.connect_to_region(region, aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key) 31 | 32 | q = conn.get_queue(queue_name) 33 | q.set_message_class(RawMessage) 34 | 35 | DEPLOY_DAEMON_PID = "/tmp/slackbot.pid" 36 | DEPLOY_DAEMON_ERROR = "/tmp/slackbot.log" 37 | 38 | 39 | class StartBot(): 40 | def __init__(self): 41 | self.stdin_path = '/dev/null' 42 | self.stdout_path = '/dev/null' 43 | self.stderr_path = DEPLOY_DAEMON_ERROR 44 | self.pidfile_path = DEPLOY_DAEMON_PID 45 | self.pidfile_timeout = 5 46 | 47 | def run(self): 48 | while True: 49 | get_msg() 50 | time.sleep(1) 51 | 52 | 53 | def init_plugins(): 54 | for plugin in glob('plugins/[!_]*.py'): 55 | print "plugin: %s" % plugin 56 | try: 57 | mod = importlib.import_module(plugin.replace("/", ".")[:-3]) 58 | modname = mod.__name__.split('.')[1] 59 | 60 | for hook in re.findall("on_(\w+)", " ".join(dir(mod))): 61 | hookfun = getattr(mod, "on_" + hook) 62 | print "attaching %s.%s to %s" % (modname, hookfun, hook) 63 | hooks.setdefault(hook, []).append(hookfun) 64 | 65 | if mod.__doc__: 66 | firstline = mod.__doc__.split('\n')[0] 67 | hooks.setdefault('help', {})[modname] = firstline 68 | hooks.setdefault('extendedhelp', {})[modname] = mod.__doc__ 69 | 70 | # bare except, because the modules could raise any number of errors 71 | # on import, and we want them not to kill our server 72 | except: 73 | print "import failed on module %s, module not loaded" % plugin 74 | print "%s" % sys.exc_info()[0] 75 | print "%s" % traceback.format_exc() 76 | 77 | 78 | def run_hook(hook, data, server): 79 | responses = [] 80 | for hook in hooks.get(hook, []): 81 | h = hook(data, server) 82 | if h: 83 | responses.append(h) 84 | 85 | return responses 86 | 87 | 88 | def get_msg(): 89 | results = q.get_messages(num_messages=1, wait_time_seconds=1, visibility_timeout=30) 90 | 91 | sqs_token = config.get("sqs_token") 92 | 93 | if not len(results) == 0: 94 | for result in results: 95 | body = json.loads(result.get_body()) 96 | user = body.get("user_name", "") 97 | channel = "#" + body.get("channel_name", "") 98 | msgtoken = body.get("token", "") 99 | 100 | # ignore/delete message we sent 101 | if user == "slackbot": 102 | q.delete_message(result) 103 | return "" 104 | 105 | response = run_hook("message", body, {"config": config, "hooks": hooks}) 106 | q.delete_message(result) 107 | 108 | if not response: 109 | return "" 110 | 111 | send_msg(channel, response) 112 | 113 | 114 | def send_msg(channel, response): 115 | username = config.get("username") 116 | icon_url = config.get("icon_url") 117 | webhook_token = config.get("webhook_token") 118 | domain = config.get("domain") 119 | url = "https://" + domain + "/services/hooks/incoming-webhook?token=" + webhook_token 120 | 121 | for item in response: 122 | if 'fallback' in item: 123 | payload = {'channel': channel, 'username': username, 'icon_url': icon_url, 'attachments': response} 124 | else: 125 | payload = {'channel': channel, 'username': username, 'text': response, 'icon_url': icon_url} 126 | r = requests.post(url, data=json.dumps(payload), timeout=5) 127 | print r.status_code 128 | 129 | 130 | if __name__ == '__main__': 131 | init_plugins() 132 | app = StartBot() 133 | daemon_runner = runner.DaemonRunner(app) 134 | daemon_runner.do_action() 135 | -------------------------------------------------------------------------------- /plugins/atlassian-jira.py: -------------------------------------------------------------------------------- 1 | """!jira - !help atlassian-jira 2 | !jira info 3 | !jira assign 4 | !jira comment 5 | !jira create 6 | !jira close 7 | !jira projects 8 | """ 9 | 10 | import re 11 | from config import config 12 | from jira.client import JIRA 13 | from jira.exceptions import JIRAError 14 | 15 | 16 | def atlassian_jira(user, action, parameter): 17 | 18 | jira_username = config.get("jira_username") 19 | jira_password = config.get("jira_password") 20 | 21 | options = { 22 | 'server': 'https://agapigroup.atlassian.net', 23 | } 24 | jira = JIRA(options, basic_auth=(jira_username, jira_password)) 25 | 26 | if action == 'projects': 27 | return projects(jira, parameter) 28 | elif action == 'info': 29 | return info(jira, parameter) 30 | elif action == 'assign': 31 | return assign(jira, parameter) 32 | elif action == 'comment': 33 | return comment(user, jira, parameter) 34 | elif action == 'create': 35 | return create(user, jira, parameter) 36 | elif action == 'close': 37 | return close(user, jira, parameter) 38 | 39 | 40 | def info(jira, parameter): 41 | issue = jira.issue(parameter, fields='summary,assignee,status') 42 | issue_id = parameter 43 | issue_url = "<" + issue.permalink() + "|" + issue_id + ">" 44 | summary = issue.fields.summary 45 | assignee = str(issue.fields.assignee) 46 | status = str(issue.fields.status) 47 | 48 | if status == "Open": 49 | color = "#4a6785" 50 | elif status == "Resolved": 51 | color = "#14892c" 52 | elif status == "Closed": 53 | color = "#14892c" 54 | elif status == "In Progress": 55 | color = "#ffd351" 56 | 57 | message = {'fallback': 'jira integration', 'pretext': summary, 'color': color, 58 | 'fields': [{'title': 'Issue ID', 'value': issue_url, 'short': True}, 59 | {'title': 'Assignee', 'value': assignee, 'short': True}, 60 | {'title': 'Status', 'value': status, 'short': True}, ]} 61 | return message 62 | 63 | 64 | def assign(jira, parameter): 65 | m = re.match(r"(\w+-\d+) (.*)", parameter) 66 | jira_id = m.group(1) 67 | jira_assign_user = m.group(2) 68 | jira.assign_issue(jira_id, jira_assign_user) 69 | 70 | 71 | def comment(user, jira, parameter): 72 | m = re.match(r"(\w+-\d+) (.*)", parameter) 73 | jira_id = m.group(1) 74 | jira_comment = m.group(2) 75 | jira_comment =+ "\n Comment by %s" % user 76 | 77 | try: 78 | jira.add_comment(jira_id, jira_comment) 79 | except JIRAError as e: 80 | response = "%s: ERROR %s %s (!jira comment %s)" % (user, str(e.status_code), str(e.text), parameter) 81 | return response 82 | 83 | 84 | def create(user, jira, parameter): 85 | m = re.match(r"(\w+) (.*)", parameter) 86 | jira_key = m.group(1) 87 | jira_summary = m.group(2) 88 | jira_description = "Created by user %s" % user 89 | issue_dict = { 90 | 'project': {'key': jira_key}, 91 | 'summary': jira_summary, 92 | 'description': jira_description, 93 | 'issuetype': {'name': 'Bug'}, 94 | 'assignee': {'name': user}, 95 | } 96 | 97 | try: 98 | jira.create_issue(fields=issue_dict) 99 | except JIRAError as e: 100 | response = "%s: ERROR %s %s (!jira create %s)" % (user, str(e.status_code), str(e.text), parameter) 101 | return response 102 | 103 | 104 | def close(user, jira, parameter): 105 | m = re.match(r"(\w+-\d+) ?(.*)", parameter) 106 | jira_comment = None 107 | jira_key = m.group(1) 108 | jira_comment = m.group(2) 109 | jira_comment += "\n Closed by user %s" % user 110 | issue = jira.issue(jira_key) 111 | 112 | try: 113 | jira.transition_issue(issue, '5', comment=jira_comment) 114 | except JIRAError as e: 115 | response = "%s: ERROR %s %s (!jira close %s)" % (user, str(e.status_code), str(e.text), parameter) 116 | return response 117 | 118 | def projects(jira, parameter): 119 | p = jira.projects() 120 | 121 | all_projects = '' 122 | for project in p: 123 | all_projects += "%s: %s\n" % (project.key, project.name) 124 | 125 | return all_projects 126 | 127 | 128 | def on_message(msg, server): 129 | text = msg.get("text", "") 130 | user = msg.get("user_name", "") 131 | parameter = None 132 | m = re.match(r"!jira (info|assign|comment|create|close|projects) ?(.*)", text) 133 | if not m: 134 | return 135 | 136 | action = m.group(1) 137 | parameter = m.group(2) 138 | return atlassian_jira(user, action, parameter) 139 | 140 | 141 | if __name__ == '__main__': 142 | print "Running from cmd line" 143 | atlassian_jira('fsalum', 'info', 'INFRA-35') 144 | #atlassian_jira('fsalum', 'comment', 'INFRA-20 testing comment') 145 | #atlassian_jira('fsalum', 'create', 'INFRA Create new ticket via Slack') 146 | #atlassian_jira('fsalum', 'close', 'INFRA-33 test completed') 147 | #atlassian_jira('fsalum', 'projects', None) 148 | --------------------------------------------------------------------------------