Automate the Boring Stuff with Slackbot (ver. 2)

Takanori Suzuki

EuroPython 2022 / 2022 Jul 13

Agenda

  • Background and Motivation for Slackbot

  • How to create simple bot

  • How to create interactive bot

  • How to extend bot using libs and APIs

Photos 📷 Tweets 🐦 👍

#EuroPython2022 / @takanory

Slide 💻

slides.takanory.net

Why ver. 2 in the title?

Back to 2019

  • Title: “Automate the Boring Stuff with Slackbot”

  • Talk in 🇵🇭 🇹🇭 🇲🇾 🇯🇵 🇹🇼 🇸🇬 🇮🇩

../_images/pycon2019-collage.jpg

And the 2022

  • Updated with latest information 🆕

  • 1st in-person intl event after COVID-19 🗣👥

  • Thanks to EuroPython staff and volunteers!! 👏

Who am I? 👤

../_images/sokidan-square.jpg ../_images/kurokuri.jpg

PyCon JP 2022 🇯🇵

  • 2022.pycon.jp

  • Date: 2022 Oct 14 (Fri) - 16 (Sun)

  • Venue: Tokyo, Japan (in-person)

../_images/pyconjp2022logo.png

Background and Motivation

Conference Tasks

  • I held PyCon JP(2014-2016) as Chair

  • Conference tasks:

    • 👨‍💻 Keynotes, Talks and Trainings

    • 🎫 Ticket sales and reception

    • 🏬 Venue and facility(WiFi, Video…)

    • 🍱 Foods, ☕️ Coffee, 🧁 Snacks and 🍺 Beers

Staff ask me the same things

  • 40+ staff

  • 🐣 NEW staff : 🐔 OLD staff = 50 : 50

Programmer is Lazy

Let’s create a secretary!!

Goal 🥅

  • How to create simple bot

  • How to create interactive bot

  • How to extend bot using libs and APIs

Why Slack ?

  • Launching the Slack app at any time 💻 📱

  • Easy to access

  • To do everything

../_images/slack1.png

You can create interactive bot

../_images/bot-result11.png ../_images/bot-result21.png

Simple integration with Incoming Webhooks 🪝

System overview

../_images/diagram-webhook1.png

Create Incoming Webhooks Integration 🔧

Create Incoming Webhooks Integration

1. Create a Slack app

../_images/create-webhook1-11.png ../_images/create-webhook1-21.png
  • Enter name and choose workspace

../_images/create-webhook21.png
  • Set app icon (optional)

../_images/create-webhook31.png

2. Activate Incoming Webhooks

../_images/create-webhook4-11.png

3. Add Webhook to Workspace

  • Click “Add New Webhook to Workspace”

../_images/create-webhook4-21.png
  • Choose channel → Click “Allow”

../_images/create-webhook51.png
  • Get Webhook URL: https://hooks.slack.com/...

../_images/create-webhook61.png

Post message via Webhook URL 📬

Post message with cURL

$ curl -X POST -H 'Content-type: application/json' \
> --data '{"text":"Hello Slack!"}' \
> https://hooks.slack.com/services/T000...
../_images/webhook-curl1.png

Post message with Python

import json
from urllib import request

url = "https://hooks.slack.com/services/T000..."
message = {"text": "Hello from Python!"}
data = json.dumps(message).encode()
request.urlopen(url, data=data)
../_images/webhook-python1.png

Post message with Requests

$ pip install requests
import requests

url = "https://hooks.slack.com/services/T000..."
message = {"text": "Hello from Requests!"}
r = requests.post(url, json=message)
../_images/webhook-requests1.png

Post message with Slack SDK

$ pip install slack-sdk
from slack_sdk.webhook import WebhookClient

url = "https://hooks.slack.com/services/T000..."
webhook = WebhookClient(url)
r = webhook.send(text="Hello from Slack SDK!")
../_images/webhook-slacksdk1.png

Formatting text

from slack_sdk.webhook import WebhookClient

url = "https://hooks.slack.com/services/T000..."
webhook = WebhookClient(url)
# *bold*, <url|text>, :emoji: and etc.
r = webhook.send(text="*Hello* from "
  "<https://slack.dev/python-slack-sdk/|Slack SDK>! :beer:")
../_images/webhook-formatting1.gif

Block Kit 🧱

Block Kit

../_images/block-kit1.png

Example of Block Kit

blocks = [{
    "type": "section",
    "text": {
         "text": "*THANK YOU* for coming to my talk !:tada: Please give me *feedback* about this talk :bow:",
         "type": "mrkdwn"
    },
    "fields": [
         {"type": "mrkdwn", "text": "*Love*"},
         {"type": "mrkdwn", "text": "*From*"},
         {"type": "plain_text", "text": ":beer:, Ferrets, LEGO"},
         {"type": "plain_text", "text": "Japan :jp:"},
    ],
}]
response = webhook.send(blocks=blocks)
../_images/webhook-blocks1.png

Block Kit Builder

../_images/block-kit-builder1.gif

Summary of Incoming Webhooks

  • Easy to post messages from programs 📬

  • Create complex messages with Block Kit 🧱

  • But one-way (program➡️Webhook➡️Slack)

Interactive bot 🤝

Connection protocols

Events API over HTTP

../_images/diagram-eventsapi1.png

Socket Mode

../_images/diagram-socketmode1.png

Connection protocols

  • Events API over HTTP

  • Socket Mode 👈

Create bot user 🤖

Create bot user

  • Create bot user with Socket Mode

    1. Create a Slack app (same procedure)

    2. Enable Socket Mode

    3. Subscribe bot event

    4. Add Bot Token Scopes

    5. Install App to Workspace

  • Invite bot user to Slack channels

1. Create a Slack app

../_images/create-webhook1-11.png ../_images/create-webhook1-21.png
  • Enter name and choose workspace

../_images/create-bot21.png
  • Set app icon (optional)

../_images/create-bot31.png

2. Enable Socket Mode

  • Select “Socket Mode” → Turn toggle on

../_images/create-bot41.png
  • Enter token name → Click “Generate”

../_images/create-bot51.png
  • Get app-level token: xapp-...

../_images/create-bot61.png

3. Subscribe bot event

  • Select “Event Subscriptions” → Turn toggle on

../_images/create-bot3-11.png
  • Add “message.channels” to bot events

../_images/create-bot3-2-11.png ../_images/create-bot3-2-21.png

4. Add Bot Token Scopes

  • Select “OAuth & Permissions”

../_images/create-bot71.png
  • Click “Add on OAuth Scope”

../_images/create-bot81.png
  • Add “chat:write” to Bot Token Scopes

../_images/create-bot91.png ../_images/create-bot101.png

5. Install App to Workspace

  • Select “Install App” → Click “Install to Workspace”

../_images/create-bot111.png
  • Switch OAuth screen → Click “Allow” button

../_images/create-bot121.png
  • Get Bot Token: xoxb-...

../_images/create-bot131.png

Invite bot user to channels

../_images/invite-bot1.png

Long and Complex !! 🤯

App Manifest ⚙️

App Manifest

Example of App Manifest

display_information:
  name: beerbot2
features:
  bot_user:
    display_name: beerbot2
    always_online: false
oauth_config:
  scopes:
    bot:
      - channels:history
      - chat:write
settings:
  event_subscriptions:
    bot_events:
      - message.channels
  interactivity:
    is_enabled: true
  org_deploy_enabled: false
  socket_mode_enabled: true
  token_rotation_enabled: false

Get App Manifest

  • Select “App Manifest” menu

../_images/get-app-manifest1.png

Create new app with App Manifest

  • Select “From an app manifest”

  • Select workspace → Click “Next”

../_images/app-manifest11.png ../_images/app-manifest21.png
  • Enter app manifest YAML

../_images/app-manifest31.png
  • Review app summary → Click “Create”

../_images/app-manifest41.png ../_images/app-manifest51.png ../_images/app-manifest61.png
  • Install App to Workspace

../_images/app-manifest71.png
  • Generate App-Level Token

../_images/app-manifest81.png ../_images/app-manifest91.png

Short and Reusable !! 🥳

Create bot with Bolt ⚡️

Bolt for Python

Install Bolt for Python

$ mkdir beerbot
$ cd beerbot
$ python3.10 -m venv env
$ . env/bin/activate
(env) $ pip install slack-bolt

Create a simple bot with Bolt

app.py
import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])

# match any message contains "Hi"
@app.message("Hi")
def handle_hi_message(message, say):
    say("Hi!!! I am beerbot! :beer:")

if __name__ == "__main__":
    app_token = os.environ["SLACK_APP_TOKEN"]
    SocketModeHandler(app, app_token).start()

Running bot

# Set 2 tokens in environment variables
(env) $ export SLACK_APP_TOKEN=xapp-...
(env) $ export SLACK_BOT_TOKEN=xoxb-...
(env) $ python app.py
⚡️ Bolt app is running!

I can interact with the bot ! 🎉

../_images/bot-hi1.png

Extend bot 🛠

@app.message() decolator

# match any message contains "Hi"
@app.message("Hi")
def handle_hi_message(message, say):
    say("Hi!!! I am beerbot! :beer:")


# match any message contains "cheers"
@app.message("cheers")
def handle_cheers_message(mesasge, say):
    say("Cheers! :beers:")

../_images/bot-decolator1.png

mention

# mention
@app.message("morning")
def handle_morning_message(message, say):
    user = message["user"]  # get user ID
    say(f"Good morning <@{user}>!")

../_images/bot-mention1.png

Using regular expression

import random
import re

@app.message(re.compile(r"choice (.*)"))
def handle_choice(say, context):
    # get matched text from context["matches"]
    words = context["matches"][0].split()
    say(random.choice(words))


../_images/bot-choice1.png

Using regular expression

@app.message(re.compile(r"(\d+)\s*(beer|tea)"))
def handle_beer_or_tea(say, context):
    count = int(context["matches"][0])
    drink = context["matches"][1]
    say(f":{drink}:" * count)


../_images/bot-beers1.png

Block Kit support

@app.message("follow me")
def handle_follow_me(message, say):
    blocks = [{
        "type": "section",
        "text": {
            "type": "mrkdwn",
            "text": "*Takanori Suzuki*\nFollow me! <https://twitter.com/takanory|@takanory>"
        },
        "accessory": {
            "type": "image",
            "image_url": "https://pbs.twimg.com/profile_images/192722095/kurokuri_400x400.jpg",
            "alt_text": "takanory"
        }}]
    say(blocks=blocks)


../_images/bot-followme1.png

Logging

import logging
logging.basicConfig(level=logging.DEBUG)

@app.message("log")
def handle_log(message, say, logger):
    logger.debug(f"message: {message['text']}")
    say("logs exported!")

$ python app.py
⚡️ Bolt app is running!
DEBUG:app.py:handle_log:message: log
DEBUG:app.py:handle_log:message: please write log
../_images/bot-logging1.png

Events and Scopes 🔭

Events and Scopes

  • Can only receive events in Bot Events

  • Can only execute APIs allowed by Bot Token Scopes

Current Bot Events and Scopes

  • Events

    message.channels:

    message posted to public channels

  • Scopes

    channels:history:

    View messages in public channels

    chat:write:

    Post message

Current Bot Events and Scopes

  • Cannot read/write messages on private channels

../_images/bot-private-cannot-view1.png

Add Events and Scopes for private channels

  • Select “Event Subscriptions” → Click “Add Bot User Event”

  • Add message.groups event→ Click “Save Changes”

../_images/add-events-and-scopes11.png

Add Events and Scopes for private channels

  • Select “OAuth & Permissions”

  • groups:history scope is automatically added

../_images/add-events-and-scopes21.png

Add Events and Scopes for private channels

  • Reinstall app to workspace

../_images/add-events-and-scopes31.png ../_images/add-events-and-scopes41.png

Add Events and Scopes for private channels

  • Bot can read/write messages in private channel

../_images/add-events-and-scopes61.png

To know user joined a channel

  • Add member_joined_channel event → Reinstall app



# A user joined a public or private channel
@app.event("member_joined_channel")
def member_joined(event, say):
    user = event["user"]  # get user ID
    say(f"Welcome <@{user}>! :tada:")
../_images/event-member-joined1.png

Add Emoji reaction

  • Add reactions:write scope → Reinstall app



@app.message("beer")
def add_beer_emoji(client, message):
    # add reaction beer emoji
    client.reactions_add(
        channel=message["channel"],
        timestamp=message["ts"],
        name="beer",
    )
../_images/scope-reactions-write1.png

Summary of Events and Scopes

Case studies 📚

Calculator function using SymPy 🔢

Calculator function using SymPy

  • Motivation

    • I feel heavy to call a calculator app on my smartphone

    • It seems useful if Slack as a calculator

System overview

../_images/diagram-sympy1.png

about SymPy

https://www.sympy.org/static/images/logo.png
$ pip install sympy

calc() function using Sympy

from sympy import sympify, SympifyError

@app.message(re.compile(r"^([-+*/^%!().\d\s]+)$"))
def calc(message, context, say):
    try:
        formula = context["matches"][0]
        result = sympify(formula)  # Simplifies the formula
        if result.is_Integer:
            answer = int(result)  # Convert to integer value
        else:
            answer = float(result)  # Convert to float value
        say(f"{answer:,}")
    except SympifyError:
        pass


Slack as a calculator !! 🎉

../_images/case-sympy1.png

Plus-plus feature using Peewee ORM 👍

Plus-plus feature using Peewee ORM

  • Motivation

    • In PyCon JP, I want to make a culture that appreciates each other staff 👍

System overview

../_images/diagram-peewee1.png

about Peewee

https://docs.peewee-orm.com/en/latest/_images/peewee3-logo.png
  • Simple and small ORM.

    • a small, expressive ORM

    • supports sqlite, mysql and postgresql

  • docs.peewee-orm.com

$ pip install peewee

plusplus_model.py

from peewee import SqliteDatabase, Model, CharField, IntegerField

db = SqliteDatabase("plusplus.db")

class Plusplus(Model):
    name = CharField(primary_key=True)  # fields
    counter = IntegerField(default=0)

    class Meta:
        database = db

db.connect()
db.create_tables([Plusplus], safe=True)

plusplus() function using Peewee

from plusplus_model import Plusplus

# match "word++" pattern
@app.message(re.compile(r"^(\w+)\+\+"))
def plusplus(say, context):
    name = context["matches"][0]
    # Get or create object
    plus, created = Plusplus.get_or_create(
        name=name.lower(),
        defaults={'counter': 0}
    )
    plus.counter += 1
    plus.save()
    say(f"Thank you {name}! (count: {plus.counter})")

I can appreciate it! 🎉

../_images/case-peewee1.png

Search issues with Jira APIs 🔎

Search issues with Jira APIs

  • Motivation

    • Jira is very useful

    • Jira Web is slow

    • Search issues without Jira Web

System overview

../_images/diagram-jira1.png

about Python Jira

$ pip install jira

Authentication

from jira import JIRA

url = 'https://jira.atlassian.com/'
jira = JIRA(url, basic_auth=('email', 'API token'))

Search issues

@app.message(re.compile(r"^jira (.*)$"))
def jira_search(message, context, say):
    keywords = context["matches"][0]
    jql = f'text ~ "{keywords}" order by created desc'
    text = ""
    # get 5 recent issues
    for issue in jira.search_issues(jql, maxResults=5):
        issue_id = issue.key
        url = issue.permalink()
        summary = issue.fields.summary
        text += f"* <{url}|{issue_id}> {summary}\n"
    if not text:
        text = "No issues found"
    say(text)

Free from Jira web! 🎉

../_images/bot-jira1.png

Create multiple issues from a template 📝

Create multiple issues from a template

  • Motivation

    • In pycamp event, 20+ issues are required for each event

    • Copying issues by hand is painful

    • Jira Web is slow (again)

System overview

../_images/diagram-template1.png

Google Authorization is Complex

  • Create a Google Cloud project

    • Enable API(in this case: Google Sheets API)

    • Download credentials.json

  • Install Google Client Library

    $ pip install google-api-python-client \
      google-auth-httplib2 google-auth-oauthlib
  • Download quickstart.py from GitHub

Google Authorization is Complex

  • Run quickstart.py

    • Select your Google account in Web browser

    • Click “Accept” button

    • Get token.json (finish!!)

$ python quickstart.py
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?....
Name, Major:
Alexandra, English
:

Issue template

../_images/bot-issue-template1.png

Get Spreadsheet data

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
SHEET = "1i3YQPObe3ZC_i1Jalo44_2_..."

@app.message("create issues")
def creaete_issues(message, say):
    creds = Credentials.from_authorized_user_file('token.json', SCOPES)
    service = build('sheets', 'v4', credentials=creds)
    sheet = service.spreadsheets()
    result = sheet.values().get(spreadsheetId=SHEET, range="A2:C4").execute()
    for row in result.get("values", []):
        say(f"* Title: {row[0]}, Delta: {row[1]}")

Get Spreadsheet data

../_images/bot-sheet11.png

Create Jira issues

def creaete_issues(message, say):
    # --snip--
    today = datetime.date.today()
    for row in result.get("values", []):
        duedate = today + datetime.timedelta(days=int(row[1]))
        issue_dict = {"project": {"key": "ISSHA"},
                      "summary": row[0],
                      "description": row[2],
                      "duedate": f"{duedate:%Y-%m-%d}",
                      "issuetype": {"name": "Task"}}
        issue = jira.create_issue(fields=issue_dict)
        url = issue.permalink()
        say(f"* Create: <{url}|{issue.key}> {row[0]}\n")

Free from copying issues! 🎉

../_images/bot-sheet21.png ../_images/bot-sheet31.png

Account management of Google Workspace 👥

Account management of Google Workspace

  • Motivation

    • PyCon JP Association use pycon.jp domain with Google Workspace

    • I only use Google Admin web occasionally

    • I forgot to use admin screen

System overview

../_images/diagram-directory1.png

Update Google Authorization

  • Update a Google Cloud project

    • add Directory API

    • re-download credentials.json

  • Remove token.json

  • Add Directory API quickstart.py

    • Re-run quickstart.py

    • Get new token.json

Get user list

SCOPES = ["https://www.googleapis.com/auth/admin.directory.user"]

@app.message("user_list")
def user_list(message, say):
    creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    service = build("admin", "directory_v1", credentials=creds)

    users_list = service.users().list(
        orderBy="email", maxResults=10, customer="my_customer").execute()

    for user in users_list.get("users", []):
        email = user["primaryEmail"]
        fullname = user["name"]["fullName"]
        say(f"* {email} {fullname}")

Get user list

../_images/bot-user-list1.png

Add user

@app.message(re.compile(r"user_add (\w+) (\w+) (\w+) (\w+)"))
def insert_user(message, say, context):
    creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    service = build("admin", "directory_v1", credentials=creds)

    body = {
        "primaryEmail": context["matches"][0] + "@pycon.jp",
        "password": context["matches"][1],
        "name": {
            "givenName": context["matches"][2],
            "familyName": context["matches"][3]
        }}
    service.users().insert(body=body).execute()
    say(f"User {body['primaryEmail']} created")

Add user

../_images/bot-user-add1.png ../_images/bot-user-add21.png

I can forget Google Admin! 🎉

Security Issue 🔓

  • Anyone can run it

  • Run only Slack Admin 🔒

Not-Admin cannot run

@app.message("user_list")
def user_list(message, say, client):
    # get user info: https://api.slack.com/methods/users.info
    response = client.users_info(user=message["user"])
    user = response.data["user"]
    if not user["is_admin"]:
        say("You are not an admin!")
        return

../_images/bot-not-admin.png

Resolve a security issue 🎊

Summary

  • Simple bot using Incoming Webhooks

  • Interactive bot using Bolt for Python

  • Extend bot using libraries and APIs

Next Step 🪜

  • Let’s make your Slackbot

  • Let’s connect with libraries and APIs

  • Automate your Boring Stuff with Slackbot

Thank you! 🙏

../_images/bot-translate1.png

@takanory

slides.takanory.net

github.com/takanory/slides/tree/master/slides/20220713europython/code

translate command

$ pip install deepl
import deepl

translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"])

@app.message(re.compile(r"^translate (.*)"))
def translate(message, context, say):
    text = context["matches"][0]
    result = translator.translate_text(text,
                                       target_lang="EN-US")
    say(result.text)

Thank you! 🙏

../_images/bot-translate1.png

@takanory

slides.takanory.net

github.com/takanory/slides/tree/master/slides/20220713europython/code