บางครั้งการสร้าง Reminders สำหรับ Annual Events เป็นวิธีดีที่สุดกันไม่ให้ลืมและพลาดวันสำคัญ
ถ้าคุณและทีม/เพื่อนใช้ Slack การทำให้ Reminders อัตโนมัติผ่าน Slackbots เป็นไอเดียดี
ขณะทำเช่นนั้น หากต้องการ Slackbot ที่ Low Maintenance (ดูแลน้อย) ควรใช้ Serverless Technologies สำหรับ Concurrent Interactions กับ Source และเปิด Horizontal Scalability
เรากำลังสร้างอะไร?
เราสร้าง Event Reminder Slackbot ด้วย Python, AWS Chalice, AWS Lambda และ API Gateway สำหรับ Hosting จะให้ Users:
- ตั้งค่า Birthdays สำหรับ Users
- ตั้งค่า Anniversaries สำหรับ Users
- ตั้งค่า Custom Events สำหรับ Users หรือ General Channel
หลังตั้งค่า Events แล้ว:
- Remind ผู้คนว่า Event เฉพาะกำลังจะมาถึง ยกเว้นคนที่เป็นศูนย์กลาง Event (คนที่ถูก Mention ตอนตั้งค่า)
- Post ไป General Channel เมื่อวันครบรอบ Event มาถึง Mention คนศูนย์กลาง (หรือ Everyone ใน Channel)
Commands
set
/event set birthday <YYYY-MM-DD> <user>ตั้งค่า Birthday ของ User

/event set anniversary <YYYY-MM-DD> <user>ตั้งค่า Anniversary (วันเริ่มงาน) ของ User

/event set custom <YYYY-MM-DD> <user> <any kind of message with whitespaces>ตั้งค่า Custom Reminder ด้วย Message ที่ให้มา

get-all
/event get-all: แสดง Events ทั้งหมดที่ตั้งค่า
/event get-all birthday: แสดง Birthdays ทั้งหมด
/event get-all anniversary: แสดง Anniversaries ทั้งหมด
/event get-all custom: แสดง Custom Events ทั้งหมด
get
/event get birthday <user>: แสดง Birthday Details ของ User
/event get anniversary <user>: แสดง Anniversary Details ของ User
/event get custom <event_name>(can be found with get-all): แสดง Custom Event Details
remove
/event remove birthday <user>: ลบ Birthday ของ User
/event remove anniversary <user>: ลบ Anniversary ของ User
/event remove custom <event_name>(can be found with get-all): ลบ Custom Event
Scheduled Reminder
Remind General Channel
เมื่อเวลามาถึง Slackbot ส่ง Reminder ไป Channel ที่ระบุ

Private Message From Bot
เมื่อเวลาลงตัว Slackbot ส่ง Private Reminder Messages

เครื่องมือนี้ใช้ติดตามวันสำคัญของสมาชิกทีม ช่วยรักษาความสัมพันธ์และการสื่อสารระหว่างกันให้แข็งแกร่ง
Getting Started
เตรียม Database
สร้าง Redis Database บน Upstash Console จด UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN ไว้ จะเป็น Environment Variables สำหรับ AWS
ตั้งค่า AWS Credentials
(จาก Official Chalice Repo อ้างอิงเพิ่มเติมได้ที่นั่น)
$ mkdir ~/.aws
$ cat >> ~/.aws/config
[default]
aws_access_key_id=YOUR_ACCESS_KEY_HERE
aws_secret_access_key=YOUR_SECRET_ACCESS_KEY
region=YOUR_REGION (such as us-west-2, us-west-1, etc)
Conventions บางอย่าง
- ไฟล์
.pyนอกapp.pyควรอยู่ใน Directorychalicelibมิฉะนั้น Import Statements อาจมีปัญหา - Environment Variables ทั้งหมดควร Config ใน
config.jsonภายใน Directory.chalice- Format JSON ด้วย Key: "environment_variables"
Project Source Development
ติดตั้ง Chalice:
pip install chalice
Initiate Chalice Project
chalice new-project <project_name>
cd เข้าโฟลเดอร์ โปรเจกต์มาพร้อม Template
Run: chalice local ดูว่า Project ทำงานได้
app.py
Main File สำหรับ Project Structure และ Handle Slack Requests
สร้าง Project Structure และ Endpoints ตัดสินใจ Handle Events และ Schedule สำหรับ Reminder
from chalice import Chalice, Cron, Rate
import os, random
from datetime import date
from chalicelib.utils import responseToDict, postToChannel, diffWithTodayFromString, allSlackUsers, sendDm, validateRequest, convertToCorrectMention
from chalicelib.upstash import setHandler, getAllHandler, getEvent, getAllKeys, removeEvent
app = Chalice(app_name='birthday-slackbot')
NOTIFY_TIME_LIMIT = int(os.getenv("NOTIFY_TIME_LIMIT"))
@app.route('/', methods=["GET"])
def something():
return { "Hello": "World" }
@app.route('/', methods=["POST"], content_types=["application/x-www-form-urlencoded"])
def index():
r = responseToDict(app.current_request.raw_body)
headers = app.current_request.headers
if not validateRequest(headers, r):
return {"Status": "Validation failed."}
commandArray = r['text'].split()
command = commandArray.pop(0)
try:
if command == "set":
setHandler(commandArray)
return { 'response_type': "ephemeral", 'text': "Set the event." }
elif command == "get":
eventType = commandArray[0]
eventName = eventType + "-" + commandArray[1]
resultDict = getEvent(eventName)
return { 'response_type': "ephemeral", 'text': "`{}` Details:\n\n Date: {}\nRemaining: {} days!".format(eventName, resultDict[0], resultDict[1]) }
elif command == "get-all":
stringResult = getAllHandler(commandArray)
return { 'response_type': "ephemeral", 'text': "{}".format(stringResult) }
elif command == "remove":
eventName = "{}-{}".format(commandArray[0], commandArray[1])
removeEvent(eventName)
return { 'response_type': "ephemeral", 'text': "Removed the event." }
else:
return { 'response_type': "ephemeral", 'text': "Wrong usage of the command." }
except:
print("some stuff")
return { 'response_type': "ephemeral", 'text': "Some problem occured. Please check your command." }
@app.schedule(Cron(0, 10, '*', '*', '?', '*'))
def periodicCheck(event):
allKeys = getAllKeys()
for key in allKeys:
handleEvent(key)
def handleEvent(eventName):
eventSplitted = eventName.split('-')
eventType = eventSplitted[0]
personName = eventSplitted[1][1:]
personMention = convertToCorrectMention(personName)
eventDict = getEvent(eventName)
remainingDays = eventDict[1]
totalTime = eventDict[2]
if eventType == "birthday":
birthdayHandler(personMention, personName, remainingDays)
elif eventType == "anniversary":
anniversaryHandler(personMention, personName, remainingDays, totalTime)
elif eventType == "custom":
eventMessage = "Not specified"
if len(eventSplitted) == 3:
eventMessage = eventSplitted[2]
customHandler(eventMessage, personMention, personName, remainingDays)
def birthdayHandler(personMention, personName, remainingDays):
if remainingDays == 0:
sendRandomBirthdayToChannel('general', personMention)
if remainingDays <= NOTIFY_TIME_LIMIT:
dmEveryoneExcept("{} day(s) until {}'s birthday!".format(remainingDays, personMention), personName)
def anniversaryHandler(personMention, personName, remainingDays, totalTime):
if remainingDays == 0:
sendRandomAnniversaryToChannel('general', personMention, totalTime)
if remainingDays <= NOTIFY_TIME_LIMIT:
dmEveryoneExcept("{} day(s) until {}'s anniversary! It will be {} year(s) since they joined!".format(remainingDays, personMention, totalTime), personName)
def customHandler(eventMessage, personMention, personName, remainingDays):
if remainingDays == 0:
postToChannel('general', "`{}` is here {}!".format(eventMessage, personMention))
elif remainingDays <= NOTIFY_TIME_LIMIT:
dmEveryoneExcept("{} day(s) until {} `{}`!".format(remainingDays, personMention, eventMessage), personName)
def dmEveryoneExcept(message, person):
usersAndIds = allSlackUsers()
for user in usersAndIds:
if user[0] != person:
sendDm(user[1], message)
def sendRandomBirthdayToChannel(channel, personMention):
messageList = [
"Happy Birthday {}! Wishing you the best!".format(personMention),
"Happy Birthday {}! Wishing you a happy age!".format(personMention),
"Happy Birthday {}! Wishing you a healthy, happy life!".format(personMention),
]
message = random.choice(messageList)
return postToChannel('general', message)
def sendRandomAnniversaryToChannel(channel, personMention, totalTime):
messageList = [
"Today is the anniversary of {} joining! It has been {} years since they joined!".format(personMention, totalTime - 1),
"Celebrating the anniversary of {} joining! It has been {} years!".format(personMention, totalTime - 1),
"Congratulating {} for entering {}(th) year here!".format(personMention, totalTime),
]
message = random.choice(messageList)
return postToChannel('general', message)
allKeys = getAllKeys()
for key in allKeys:
handleEvent(key)
chalicelib/utils.py
Main File สำหรับ Helper Functions และ Abstraction
ใช้ File นี้สำหรับ Abstractions เพื่อ Source Code ไม่ Errant และ Readability ดี
from urllib import request
import urllib
from urllib.parse import parse_qsl
import json, os, hmac, hashlib
from datetime import date
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
SLACK_SIGNING_SECRET = os.getenv("SLACK_SIGNING_SECRET")
def getRealName(slackUsers, username):
for user in slackUsers:
if user[0] == username:
return user[2]
return "Nameless"
def allSlackUsers():
resultDict = sendPostRequest("https://slack.com/api/users.list", SLACK_BOT_TOKEN)
members = resultDict['members']
userMembers = []
for member in members:
if not member['deleted'] and not member['is_bot']:
userMembers.append([member['name'], member['id'], member['real_name']])
return userMembers
def channelNameToId(channelName):
resultDict = sendPostRequest("https://slack.com/api/conversations.list", SLACK_BOT_TOKEN)
for channel in resultDict['channels']:
if channel['name'] == channelName:
return channel['id']
return None
def postToSlack(channelId, messageText):
data = { "channel": channelId, "text": messageText }
data = json.dumps(data).encode('utf-8')
resultDict = sendPostRequest("https://slack.com/api/chat.postMessage", SLACK_BOT_TOKEN, data)
return resultDict
def postToChannel(channel, messageText):
channelId = channelNameToId(channel)
return postToSlack(channelId, messageText)
def sendDm(userId, messageText):
return postToSlack(userId, messageText)
def sendPostRequest(requestURL, bearerToken, data={}):
req = request.Request(requestURL, method="POST", data=data)
req.add_header("Authorization", "Bearer {}".format(bearerToken))
req.add_header("Content-Type", "application/json; charset=utf-8")
r = request.urlopen(req)
resultDict = json.loads(r.read().decode())
return resultDict
def responseToDict(res):
return dict(parse_qsl(res.decode()))
def diffWithTodayFromString(dateString):
now = date.today()
currentYear = now.year
dateTokens = dateString.split("-")
month = int(dateTokens[1])
day = int(dateTokens[2])
if now > date(currentYear, month, day):
return (date((currentYear + 1), month, day) - now).days
return (date(currentYear, month, day) - now).days
def totalTimefromString(dateString):
now = date.today()
dateTokens = dateString.split("-")
year = int(dateTokens[0])
month = int(dateTokens[1])
day = int(dateTokens[2])
then = date(year, month, day)
years = now.year - then.year
return years + 1
def validateRequest(header, body):
bodyAsString = urllib.parse.urlencode(body)
timestamp = header['x-slack-request-timestamp']
slackSignature = header['x-slack-signature']
baseString = "v0:{}:{}".format(timestamp, bodyAsString)
h = hmac.new(SLACK_SIGNING_SECRET.encode(), baseString.encode(), hashlib.sha256)
hashResult = h.hexdigest()
mySignature = "v0=" + hashResult
return mySignature == slackSignature
def convertToCorrectMention(name):
if name == "channel" or name == "here" or name == "everyone":
return "<!{}>".format(name)
else:
return "<@{}>".format(name)
chalicelib/upstash.py
Main File สำหรับ Functions เกี่ยวกับ Database โดยตรง
จัดการ Database Calls Fetch, Set Key-Value Pairs Abstract Low-level Details จาก
app.pyเพื่อ Readability และ Modularity
Upstash Redis Database รองรับ RESTful API Calls เข้าถึง Database ได้โดยไม่ต้อง Create/Close Connections ตลอดเวลา เหมาะสำหรับ Serverless Applications
from chalicelib.utils import sendPostRequest, getRealName, allSlackUsers, diffWithTodayFromString, totalTimefromString
import os
UPSTASH_REST_URL = os.getenv("UPSTASH_REST_URL")
UPSTASH_TOKEN = os.getenv("UPSTASH_TOKEN")
def postToUpstash(parameters):
requestURL = UPSTASH_REST_URL
for parameter in parameters:
requestURL += ("/" + parameter)
resultDict = sendPostRequest(requestURL, UPSTASH_TOKEN)
return resultDict['result']
def setEvent(parameterArray):
postQueryParameters = ['SET']
for parameter in parameterArray:
parameter = parameter.split()
for subparameter in parameter:
postQueryParameters.append(subparameter)
resultDict = postToUpstash(postQueryParameters)
return resultDict
def getEvent(eventName):
postQueryParameters = ['GET', eventName]
date = postToUpstash(postQueryParameters)
timeDiff = diffWithTodayFromString(date)
totalTime = totalTimefromString(date)
mergedDict = [date, timeDiff, totalTime]
return mergedDict
def getAllKeys():
return postToUpstash(['KEYS', '*'])
def removeEvent(eventName):
postQueryParameters = ['DEL', eventName]
resultDict = postToUpstash(postQueryParameters)
return resultDict
def setHandler(commandArray):
eventType = commandArray.pop(0)
date = commandArray.pop(0)
user = commandArray.pop(0)
if eventType == "birthday":
listName = "birthday-" + user
return setEvent([listName, date])
elif eventType == "anniversary":
listName = "anniversary-" + user
return setEvent([listName, date])
elif eventType == "custom":
message = ""
for string in commandArray:
message += string + "_"
listName = "custom-" + user + "-" + message
user = commandArray[1]
return setEvent([listName, date])
else:
return
def getAllHandler(commandArray):
filterParameter = None
if len(commandArray) == 1:
filterParameter = commandArray[0]
allKeys = getAllKeys()
birthdays, anniversaries, customs = [], [], []
slackUsers = allSlackUsers()
stringResult = "\n"
for key in allKeys:
if key[0] == 'b':
birthdays.append(key)
elif key[0] == 'a':
anniversaries.append(key)
elif key[0] == 'c':
customs.append(key)
if filterParameter is None or filterParameter == "birthday":
stringResult += "Birthdays:\n"
for bday in birthdays:
tag = bday.split('-')[1]
username = tag[1:]
realName = getRealName(slackUsers, username)
details = getEvent(bday)
stringResult += "`{}` ({}): {} - `{} days` remaining!\n".format(tag, realName, details[0], details[1])
if filterParameter is None or filterParameter == "anniversary":
stringResult += "\nAnniversaries:\n"
for ann in anniversaries:
tag = ann.split('-')[1]
username = tag[1:]
realName = getRealName(slackUsers, username)
details = getEvent(ann)
stringResult += "`{}` ({}): {} - `{} days` remaining!\n".format(tag, realName, details[0], details[1])
if filterParameter is None or filterParameter == "custom":
stringResult += "\nCustom Reminders:\n"
for cstm in customs:
splitted = cstm.split('-')
username = splitted[2]
realName = getRealName(slackUsers, username)
details = getEvent(cstm)
stringResult += "`{}-{}` ({}): {}\n".format(splitted[1], splitted[2], getRealName(slackUsers, username), details[0])
return stringResult
.chalice/config.json
Config File สำหรับ Project บน AWS
กำหนด Project Details เช่น Environment Variables และ Deployment Stages สำหรับนี้ Config Environment Variables โดยเพิ่ม:
{
"environment_variables": {
"UPSTASH_REST_URL": "<UPSTASH_REDIS_REST_URL>",
"UPSTASH_TOKEN": "<UPSTASH_REDIS_REST_TOKEN>",
"SLACK_BOT_TOKEN": "<SLACK_BOT_TOKEN>",
"SLACK_SIGNING_SECRET": "<SLACK_SIGNING_SECRET>",
"NOTIFY_TIME_LIMIT": "<amount of days before getting notifications for events>"
}
}
หลังเสร็จสิ้น
Folder Structure
Folder Structure ควรประมาณนี้:
<project_name>:
app.py
chalicelib:
utils.py
upstash.py
<Some other default files generated by chalice>
.chalice:
config.json
<Some other default files generated by chalice>
Running Locally
Chalice รองรับ Local Deployment ทำ Development เร็วขึ้น
Run: chalice local 
หากไม่มี Static IP ใช้ Tunnelling Service เช่น ngrok เพื่อแสดง Endpoint ให้ Slack:
./ngrok http 8000--> Tunnels localhost:8000
Configure Slack
1. ไปที่ Slack API Apps Page:
- Create New App
- From Scratch
- ตั้งชื่อ App & เลือก Workspace
- ไป OAuth & Permissions
- Add Scopes:
- channels:read
- chat:write
- chat:write.public
- commands
- groups:read
- users:read
- Install App to Workspace
- Basic Information --> Install Your App --> Install To Workspace
- Add Scopes:
- จด Variables (จะเป็น Env Variables สำหรับ AWS Deploy):
SLACK_SIGNING_SECRET:- Basic Information --> App Credentials --> Signing Secret
SLACK_BOT_TOKEN:- OAuth & Permissions --> Bot User OAuth Token
3. ไป Slack API Apps Page เลือก App ที่เกี่ยวข้อง:
หลัง Deploy ใช้ REST_API_URL หรือ ngrok_domain เป็น <domain>
- ไป Slack API Apps Page เลือก App:
- ไป Slash Commands:
- Create New Command:
- Command:
event - Request URL:
<domain> - Config ส่วนอื่นๆ ตามต้องการ
- Command:
- Create New Command:
- หลังเปลี่ยนแปลง Slack อาจขอ Reinstall App
ยินดีด้วย!
ตอนนี้คุณมี Serverless Slackbot ที่ทำงานแล้ว Customize ได้อย่างอิสระ
พอพอใจ Local Hosting และ Results:
chalice deployสำหรับ Final Deployment บน AWS Lambda และ API Gateway
ใช้ REST_API_URL จาก AWS Chalice ใน Slack Configurations ได้แล้ว
