Automate the Boring Stuff with Slackbot (ver. 2)

Takanori Suzuki

PyCon US 2022 / 2022 Apr 29

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 🐦 👍

#pyconus2022 / @takanory

Slide 💻

slides.takanory.net

Why ver. 2 in the title?

Back to 2020

../_images/pyconus2020.png

Came back at 2022

  • Talk updated with latest information

  • I am very happy!!

  • Thanks to PyCon US staff!!

Who am I? 👤

../_images/sokidan-square.jpg

PyCon JP 2022 🇯🇵

  • 2022.pycon.jp

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

  • Venue: Tokyo, Japan (in-person)

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 Slack

  • To do everything in Slack

../_images/slack1.png

You can create interactive bot

../_images/bot-result1.png ../_images/bot-result2.png

Simple integration with Incoming Webhooks 🪝

System overview

../_images/diagram-webhook.png

Create Incoming Webhooks Integration 🔧

Create Incoming Webhooks Integration

1. Create a Slack app

../_images/create-webhook1-1.png ../_images/create-webhook1-2.png
  • Enter name and choose workspace

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

../_images/create-webhook3.png

2. Activate Incoming Webhooks

../_images/create-webhook4-1.png

3. Add Webhook to Workspace

  • Click “Add New Webhook to Workspace”

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

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

../_images/create-webhook6.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-curl.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-python.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-requests.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-slacksdk.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-formatting.gif

Block Kit 🧱

Block Kit

../_images/block-kit.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-blocks.png

Block Kit Builder

../_images/block-kit-builder.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-eventsapi.png

Socket Mode

../_images/diagram-socketmode.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-1.png ../_images/create-webhook1-2.png
  • Enter name and choose workspace

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

../_images/create-bot3.png

2. Enable Socket Mode

  • Select “Socket Mode” → Turn toggle on

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

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

../_images/create-bot6.png

3. Subscribe bot event

  • Select “Event Subscriptions” → Turn toggle on

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

../_images/create-bot3-2-1.png ../_images/create-bot3-2-2.png

4. Add Bot Token Scopes

  • Select “OAuth & Permissions”

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

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

../_images/create-bot9.png ../_images/create-bot10.png

5. Install App to Workspace

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

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

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

../_images/create-bot13.png

Invite bot user to channels

../_images/invite-bot.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-manifest.png

Create new app with App Manifest

  • Select “From an app manifest”

  • Select workspace → Click “Next”

../_images/app-manifest1.png ../_images/app-manifest2.png
  • Enter app manifest YAML

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

../_images/app-manifest4.png ../_images/app-manifest5.png ../_images/app-manifest6.png
  • Install App to Workspace

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

../_images/app-manifest8.png ../_images/app-manifest9.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!
../_images/bot-hi.png

I can interact with the bot ! 🎉

../_images/bot-hi.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-decolator.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-mention.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-choice.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-beers.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-followme.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-logging.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-view.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-scopes1.png

Add Events and Scopes for private channels

  • Select “OAuth & Permissions”

  • groups:history scope is automatically added

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

Add Events and Scopes for private channels

  • Reinstall app to workspace

../_images/add-events-and-scopes3.png ../_images/add-events-and-scopes4.png

Add Events and Scopes for private channels

  • Bot can read/write messages in private channel

../_images/add-events-and-scopes6.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-joined.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-write.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-sympy.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-sympy.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-peewee.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-peewee.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-jira.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-jira.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-template.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-template.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-sheet1.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-sheet2.png ../_images/bot-sheet3.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-directory.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-list.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-add.png ../_images/bot-user-add2.png

I can forget Google Admin!

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-translate.png

@takanory

slides.takanory.net

github.com/takanory/slides/tree/master/20220429pyconus/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-translate.png

@takanory

slides.takanory.net

github.com/takanory/slides/tree/master/20220429pyconus/code