diff --git a/.gitignore b/.gitignore index b6e4761..b1e3e98 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + + +credentials.json +token.pickle diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..fbf4980 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 lza_menace + +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. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ce1df44 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +setup: + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt + +dev: + ./bin/dev_app + +prod: + ./bin/prod_app diff --git a/README.md b/README.md index 77fb0a9..237915f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # slackbkpy -Block kit implementation in Python +Block kit implementation in Python + +## Setup + +`make setup` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..78b5186 --- /dev/null +++ b/app/app.py @@ -0,0 +1,194 @@ +import logging +import os +import json +from logging.config import dictConfig +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier +from slack_sdk.errors import SlackApiError +from slugify import slugify +from flask import Flask, request, make_response +from app.ux import SlackInterface + + +app = Flask(__name__) +client = WebClient(token=os.getenv('SLACK_API_TOKEN')) +signature_verifier = SignatureVerifier(os.getenv('SLACK_SIGNING_SECRET')) +# client.api_test() + +dictConfig({ + 'version': 1, + 'disable_existing_loggers': True, + 'formatters': { + 'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }, + 'access': { + 'format': '%(message)s', + } + }, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'default', + 'stream': 'ext://sys.stdout', + } + }, + 'loggers': { + 'gunicorn.error': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'gunicorn.access': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + } + }, + 'root': { + 'level': 'DEBUG', + 'handlers': ['console'], + } +}) + +def verify_slack(req: request): + signature_verifier = SignatureVerifier(os.getenv('SLACK_SIGNING_SECRET')) + if not signature_verifier.is_valid_request(req.get_data(), req.headers): + return make_response('invalid request', 403) + +@app.route('/slack/command', methods=['POST']) +def slack_command(): + verify_slack(request) + logging.info(request.form) + client.views_open( + trigger_id=request.form.get('trigger_id'), + view=SlackInterface().select_action_modal() + ) + return make_response('', 200) + + +@app.route('/slack/events', methods=['POST']) +def slack_events(): + verify_slack(request) + if 'payload' in request.form: + payload = json.loads(request.form.get('payload')) + print('debug: %s \n' % payload) + + if 'actions' in payload: + action_id = payload['actions'][0]['action_id'] + trigger_id = payload['trigger_id'] + + if action_id == 'create_new_channel_modal': + client.views_push( + trigger_id=trigger_id, + view=SlackInterface().new_channel_modal(payload['user']['id']) + ) + elif action_id == 'generate_documents_modal': + client.views_push( + trigger_id=trigger_id, + view=SlackInterface().generate_docs_modal() + ) + + return make_response('', 200) + + elif 'view' in payload: + + callback_id = payload['view']['callback_id'] + + if callback_id == 'submit_new_channel': + users_to_add = list() + values = payload['view']['state']['values'] + cx_name = values['customer_name']['customer_name']['value'] + cx_slug = slugify(cx_name) + selected_users = values['users_to_add']['users_to_add']['selected_users'] + for i in selected_users: + username = client.users_info(user=i) + users_to_add.append(username['user']['name']) + + # Create channel + try: + res = client.conversations_create(name=cx_slug) + # Notify da peeps + client.chat_postMessage( + channel=res['channel']['id'], + text=f'sup bros! {" ".join(["@" + i for i in users_to_add])}' + ) + return make_response('', 200) + except SlackApiError as e: + logging.error("Error creating conversation: {}".format(e)) + return make_response('', 403) + + elif callback_id == 'generate_documents': + pass + else: + return make_response('', 404) + + +# +# // Handle actions based upon user selection and inputs +# if ( body.view.callback_id == 'submit_new_channel' ) { +# console.log('[+] Creating new channel for @' + body.user.username); +# +# // Gather vars and setup slug from customer name +# let cx_name = body.view.state.values.customer_name.customer_name.value; +# let cx_char = cx_name.charAt(0); +# let cx_slug = slugify(cx_name, { +# strict: true, +# lower: true +# }); +# +# // Check if first character is a number so it can go into numeric group +# if ( !isNaN(cx_char) ) { +# var gdrive_prefix = '0-9'; +# } else { +# var gdrive_prefix = cx_char.toUpperCase(); +# } +# +# // Create users array to add to channel +# const users = body.view.state.values.users_to_add.users_to_add.selected_users.map(async function(item) { +# var result = await api.callSlackAPI('users.info', { +# user: item +# }); +# let slack_username = '@' + result.user.name; +# return slack_username +# }); +# +# await Promise.all(users).then(async function(result) { +# // Post to Zapier to run Zap to create new channel +# await api.postZapierWebhook(process.env.ZAPIER_WEBHOOK_submit_new_channel, { +# 'customer_name': cx_name, +# 'gdrive_prefix': gdrive_prefix, +# 'slack_channel': cx_slug, +# 'users': result +# }); +# }) +# } else if ( body.view.callback_id == 'generate_documents' ) { +# console.log('[+] Generating documents for @' + body.user.username); +# +# // Gather vars and setup slug from customer name +# let cx_name = body.view.state.values.customer_name.customer_name.value; +# let opp_name = body.view.state.values.opportunity_name.opportunity_name.value; +# let cx_char = cx_name.charAt(0); +# +# // Check if first character is a number so it can go into numeric group +# if ( !isNaN(cx_char) ) { +# var gdrive_prefix = '0-9'; +# } else { +# var gdrive_prefix = cx_char.toUpperCase(); +# } +# +# // Post to Zapier to run Zap to generate new docs in the channel +# await api.postZapierWebhook(process.env.ZAPIER_WEBHOOK_generate_documents, { +# 'customer_name': cx_name, +# 'gdrive_prefix': gdrive_prefix, +# 'gdrive_item': cx_name + ' - ' + opp_name, +# 'slack_channel': body.view.state.values.channel_to_post_to.channel_to_post_to.selected_channels[0], +# }); +# } + + + + +if __name__ == '__main__': + app.run() diff --git a/app/gdrive.py b/app/gdrive.py new file mode 100644 index 0000000..025be60 --- /dev/null +++ b/app/gdrive.py @@ -0,0 +1,54 @@ +from __future__ import print_function +import pickle +import os.path +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request + +# If modifying these scopes, delete the file token.pickle. +SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly'] + +def gdrive_client(): + creds = None + # The file token.pickle stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists('token.pickle'): + with open('token.pickle', 'rb') as token: + creds = pickle.load(token) + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + 'credentials.json', + SCOPES + ) + creds = flow.run_local_server(port=0) + # Save the credentials for the next run + with open('token.pickle', 'wb') as token: + pickle.dump(creds, token) + service = build('drive', 'v3', credentials=creds) + return service + + +def get_files(): + service = gdrive_client() + # Call the Drive v3 API + results = service.files().list( + pageSize=10, + spaces='Customers (Clients)' + fields="nextPageToken, files(id, name)" + ).execute() + items = results.get('files', []) + + if not items: + print('No files found.') + else: + print('Files:') + for item in items: + print(u'{0} ({1})'.format(item['name'], item['id'])) + +if __name__ == '__main__': + main() diff --git a/app/ux.py b/app/ux.py new file mode 100644 index 0000000..2918979 --- /dev/null +++ b/app/ux.py @@ -0,0 +1,163 @@ +class SlackInterface(object): + def select_action_modal(self): + return { + 'type': 'modal', + 'title': { + 'type': 'plain_text', + 'text': 'What do you want to do?' + }, + 'callback_id': 'select-action', + 'submit': { + 'type': 'plain_text', + 'text': 'Submit' + }, + 'blocks': [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': 'Create new Slack channel for opportunity.' + }, + 'accessory': { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Go' + }, + 'action_id': 'create_new_channel_modal' + } + }, + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': 'Generate new documents for opportunity.' + }, + 'accessory': { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Go' + }, + 'action_id': 'generate_documents_modal' + } + } + ] + } + + def new_channel_modal(self, initial_user=None): + initial_users = list() + if initial_user: + initial_users.append(initial_user) + return { + 'type': 'modal', + 'title': { + 'type': 'plain_text', + 'text': 'Create new channel' + }, + 'callback_id': 'submit_new_channel', + 'submit': { + 'type': 'plain_text', + 'text': 'Submit' + }, + 'blocks': [ + { + 'block_id': 'customer_name', + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': 'Customer Name' + }, + 'element': { + 'action_id': 'customer_name', + 'type': 'plain_text_input' + }, + 'hint': { + 'type': 'plain_text', + 'text': 'Please use full spelling of the company' + } + }, + { + 'block_id': 'users_to_add', + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': 'Select Users to Add' + }, + 'element': { + 'action_id': 'users_to_add', + 'type': 'multi_users_select', + 'initial_users': initial_users + }, + 'hint': { + 'type': 'plain_text', + 'text': 'Include others who will need to be present' + } + } + ] + } + + def generate_docs_modal(self): + return { + 'type': 'modal', + 'title': { + 'type': 'plain_text', + 'text': 'Generate Documents' + }, + 'callback_id': 'generate_documents', + 'submit': { + 'type': 'plain_text', + 'text': 'Submit' + }, + 'blocks': [ + { + 'block_id': 'customer_name', + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': 'Customer Name' + }, + 'element': { + 'action_id': 'customer_name', + 'type': 'plain_text_input' + }, + 'hint': { + 'type': 'plain_text', + 'text': 'Name of the customer ie. ACME Corp, Fancy Corp LLC, Such and Such Enterprises, etc' + } + }, + { + 'block_id': 'opportunity_name', + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': 'Opportunity Name' + }, + 'element': { + 'action_id': 'opportunity_name', + 'type': 'plain_text_input' + }, + 'hint': { + 'type': 'plain_text', + 'text': 'Name of the opportunity, ie. MD40, PS k8s advisory/consulting, CloudOne, etc' + } + }, + { + 'block_id': 'channel_to_post_to', + 'type': 'input', + 'label': { + 'type': 'plain_text', + 'text': 'Select channel to notify' + }, + 'element': { + 'action_id': 'channel_to_post_to', + 'type': 'multi_channels_select', + 'max_selected_items': 1 + }, + 'hint': { + 'type': 'plain_text', + 'text': 'Select the channel to notify when documents are generated' + } + } + ] + } diff --git a/bin/cmd b/bin/cmd new file mode 100755 index 0000000..791b96b --- /dev/null +++ b/bin/cmd @@ -0,0 +1,7 @@ +#!/bin/bash + +source .venv/bin/activate +export FLASK_APP=app/app.py +export FLASK_DEBUG=0 +export FLASK_ENV=production +flask $@ diff --git a/bin/dev_app b/bin/dev_app new file mode 100755 index 0000000..a645916 --- /dev/null +++ b/bin/dev_app @@ -0,0 +1,7 @@ +#!/bin/bash + +source .venv/bin/activate +export FLASK_APP=app/app.py +export FLASK_DEBUG=1 +export FLASK_ENV=development +flask run diff --git a/bin/prod_app b/bin/prod_app new file mode 100755 index 0000000..e005e88 --- /dev/null +++ b/bin/prod_app @@ -0,0 +1,27 @@ +#!/bin/bash + +BASE=data/gunicorn + +source .venv/bin/activate +export FLASK_APP=app/app.py +export FLASK_DEBUG=0 +export FLASK_ENV=production + +mkdir -p $BASE + +kill $(cat $BASE/gunicorn.pid) 2>&1 + +sleep 2 + +gunicorn \ + --bind 127.0.0.1:4000 "app.app:app" \ + --daemon \ + --log-file $BASE/gunicorn.log \ + --pid $BASE/gunicorn.pid \ + --access-logfile $BASE/access.log \ + --capture-output \ + --reload + +sleep 2 + +echo "Starting gunicorn with pid $(cat $BASE/gunicorn.pid)" diff --git a/env-example b/env-example new file mode 100644 index 0000000..445561c --- /dev/null +++ b/env-example @@ -0,0 +1,4 @@ +SLACK_ACCESS_TOKEN= +SLACK_SIGNING_SECRET= +ZAPIER_WEBHOOK_submit_new_channel= +ZAPIER_WEBHOOK_generate_documents= diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d31b876 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask +gunicorn +slack_sdk +python-slugify +google-api-python-client +google-auth-httplib2 +google-auth-oauthlib