├── birthday-slackbot
├── tests
│ ├── __init__.py
│ └── test_app.py
├── chalicelib
│ ├── __init__.py
│ ├── requirements.txt
│ ├── utils.py
│ └── upstash.py
├── .gitignore
├── requirements-dev.txt
└── app.py
├── LICENSE
└── README.md
/birthday-slackbot/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/birthday-slackbot/chalicelib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/birthday-slackbot/chalicelib/requirements.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/birthday-slackbot/.gitignore:
--------------------------------------------------------------------------------
1 | .chalice
2 | __pycache__
3 | envSetter.txt
--------------------------------------------------------------------------------
/birthday-slackbot/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | chalice==1.26.6
2 | py.test
3 |
--------------------------------------------------------------------------------
/birthday-slackbot/tests/test_app.py:
--------------------------------------------------------------------------------
1 | from chalice.test import Client
2 | from app import app
3 |
4 |
5 | def test_index():
6 | with Client(app) as client:
7 | response = client.http.get('/')
8 | assert response.json_body == {'hello': 'world'}
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 burak-upstash
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/birthday-slackbot/chalicelib/utils.py:
--------------------------------------------------------------------------------
1 | from urllib import request
2 | import urllib
3 | from urllib.parse import parse_qsl
4 | import json
5 | import os
6 | import hmac
7 | import hashlib
8 | from datetime import date
9 |
10 |
11 | SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
12 | SLACK_SIGNING_SECRET = os.getenv("SLACK_SIGNING_SECRET")
13 |
14 | # Returns real name of the slack user.
15 | def getRealName(slackUsers, username):
16 | for user in slackUsers:
17 | if user[0] == username:
18 | return user[2]
19 | return "Nameless"
20 |
21 | # Returns all slack users in the workspace.
22 | def allSlackUsers():
23 | resultDict = sendPostRequest("https://slack.com/api/users.list", SLACK_BOT_TOKEN)
24 | members = resultDict['members']
25 |
26 | userMembers = []
27 | for member in members:
28 | if not member['deleted'] and not member['is_bot']:
29 | userMembers.append([member['name'], member['id'], member['real_name']])
30 |
31 | return userMembers
32 |
33 | # Returns the id of the given channel.
34 | def channelNameToId(channelName) :
35 | resultDict = sendPostRequest("https://slack.com/api/conversations.list", SLACK_BOT_TOKEN)
36 | for channel in resultDict['channels']:
37 | if (channel['name'] == channelName):
38 | return channel['id']
39 | return None
40 |
41 | # Posts to given slack channelId with given message.
42 | def postToSlack(channelId, messageText):
43 | data = {
44 | "channel": channelId,
45 | "text": messageText
46 | }
47 | data = json.dumps(data)
48 | data = str(data)
49 | data = data.encode('utf-8')
50 | resultDict = sendPostRequest("https://slack.com/api/chat.postMessage", SLACK_BOT_TOKEN, data)
51 | return resultDict
52 |
53 | # Posts to a slack channel.
54 | def postToChannel(channel, messageText):
55 | channelId = channelNameToId(channel)
56 | return postToSlack(channelId, messageText)
57 |
58 | # Sends a private message to a user with userId.
59 | def sendDm(userId, messageText):
60 | return postToSlack(userId, messageText)
61 |
62 | # Sends generic post request and returns the result.
63 | def sendPostRequest(requestURL, bearerToken, data={}):
64 | req = request.Request(requestURL, method="POST", data=data)
65 | req.add_header("Authorization", "Bearer {}".format(bearerToken))
66 | req.add_header("Content-Type", "application/json; charset=utf-8")
67 |
68 | r = request.urlopen(req)
69 | resultDict = json.loads(r.read().decode())
70 | return resultDict
71 |
72 | # Parses and converts the res to dict.
73 | def responseToDict(res):
74 | return dict(parse_qsl(res.decode()))
75 |
76 |
77 | # Dates are given as: YYYY-MM-DD
78 | # Returns difference between current day and the anniversary.
79 | def diffWithTodayFromString(dateString):
80 | now = date.today()
81 | currentYear = now.year
82 |
83 | dateTokens = dateString.split("-")
84 | month = int(dateTokens[1])
85 | day = int(dateTokens[2])
86 |
87 | if now > date(currentYear, month, day):
88 | return (date((currentYear + 1), month, day) - now).days
89 | return (date(currentYear, month, day) - now).days
90 |
91 |
92 | # Dates are given as: YYYY-MM-DD
93 | # Calculates the total time that has passed until current date.
94 | def totalTimefromString(dateString):
95 | now = date.today()
96 |
97 | dateTokens = dateString.split("-")
98 | year = int(dateTokens[0])
99 | month = int(dateTokens[1])
100 | day = int(dateTokens[2])
101 |
102 | then = date(year, month, day)
103 |
104 | years = now.year - then.year
105 | return years + 1
106 |
107 | # Validate requests coming to endpoint.
108 | # Hashes request body with timestamp and signing secret.
109 | # Then, compares that hash with slack signature.
110 | def validateRequest(header, body):
111 |
112 | bodyAsString = urllib.parse.urlencode(body)
113 |
114 | timestamp = header['x-slack-request-timestamp']
115 | slackSignature = header['x-slack-signature']
116 | baseString = "v0:{}:{}".format(timestamp, bodyAsString)
117 |
118 | h = hmac.new(SLACK_SIGNING_SECRET.encode(), baseString.encode(), hashlib.sha256)
119 | hashResult = h.hexdigest()
120 | mySignature = "v0=" + hashResult
121 |
122 | return mySignature == slackSignature
123 |
124 | # Converts given name to mention string.
125 | def convertToCorrectMention(name):
126 | if name == "channel" or name == "here" or name == "everyone":
127 | return "".format(name)
128 | else:
129 | return "<@{}>".format(name)
--------------------------------------------------------------------------------
/birthday-slackbot/chalicelib/upstash.py:
--------------------------------------------------------------------------------
1 | from chalicelib.utils import sendPostRequest, getRealName, allSlackUsers, diffWithTodayFromString, totalTimefromString
2 | import os
3 |
4 | UPSTASH_REST_URL = os.getenv("UPSTASH_REST_URL")
5 | UPSTASH_TOKEN = os.getenv("UPSTASH_TOKEN")
6 |
7 | # Posts to Upstash Rest Url with parameters given.
8 | def postToUpstash(parameters):
9 | requestURL = UPSTASH_REST_URL
10 | for parameter in parameters:
11 | requestURL += ("/" + parameter)
12 |
13 | resultDict = sendPostRequest(requestURL, UPSTASH_TOKEN)
14 | return resultDict['result']
15 |
16 |
17 | # Sets key-value pair for the event with given parameters.
18 | def setEvent(parameterArray):
19 |
20 | postQueryParameters = ['SET']
21 |
22 | for parameter in parameterArray:
23 | parameter = parameter.split()
24 | for subparameter in parameter:
25 | postQueryParameters.append(subparameter)
26 |
27 | resultDict = postToUpstash(postQueryParameters)
28 |
29 | return resultDict
30 |
31 |
32 | # Returns event details from the event given.
33 | def getEvent(eventName):
34 | postQueryParameters = ['GET', eventName]
35 | date = postToUpstash(postQueryParameters)
36 |
37 | timeDiff = diffWithTodayFromString(date)
38 | totalTime = totalTimefromString(date)
39 | mergedDict = [date, timeDiff, totalTime]
40 | return mergedDict
41 |
42 | # Fetches all keys (events) from the database
43 | def getAllKeys():
44 | return postToUpstash(['KEYS', '*'])
45 |
46 | # Deletes given event from the database.
47 | def removeEvent(eventName):
48 | postQueryParameters = ['DEL', eventName]
49 | resultDict = postToUpstash(postQueryParameters)
50 | return resultDict
51 |
52 |
53 | # Handles set request by parsing and configuring setEvent function parameters.
54 | def setHandler(commandArray):
55 | eventType = commandArray.pop(0)
56 | date = commandArray.pop(0)
57 | user = commandArray.pop(0)
58 |
59 | if eventType == "birthday":
60 | listName = "birthday-" + user
61 | return setEvent( [listName, date] )
62 |
63 | elif eventType == "anniversary":
64 | listName = "anniversary-" + user
65 | return setEvent( [listName, date] )
66 |
67 | elif eventType == "custom":
68 | message = ""
69 | for string in commandArray:
70 | message += string + "_"
71 |
72 | listName = "custom-" + user + "-" + message
73 | user = commandArray[1]
74 | return setEvent( [listName, date] )
75 | else:
76 | return
77 |
78 | # Handles get-all requests.
79 | def getAllHandler(commandArray):
80 | filterParameter = None
81 | if len(commandArray) == 1:
82 | filterParameter = commandArray[0]
83 |
84 | allKeys = getAllKeys()
85 | birthdays = []
86 | anniversaries = []
87 | customs = []
88 |
89 | slackUsers = allSlackUsers()
90 |
91 | stringResult = "\n"
92 | for key in allKeys:
93 | if key[0] == 'b':
94 | birthdays.append(key)
95 | elif key[0] == 'a':
96 | anniversaries.append(key)
97 | elif key[0] == 'c':
98 | customs.append(key)
99 |
100 | if filterParameter is None or filterParameter == "birthday":
101 | stringResult += "Birthdays:\n"
102 | for bday in birthdays:
103 | tag = bday.split('-')[1]
104 | username = tag[1:]
105 | realName = getRealName(slackUsers, username)
106 | details = getEvent(bday)
107 |
108 | stringResult += "`{}` ({}): {} - `{} days` remaining!\n".format(tag, realName, details[0], details[1])
109 |
110 | if filterParameter is None or filterParameter == "anniversary":
111 | stringResult += "\nAnniversaries:\n"
112 | for ann in anniversaries:
113 | tag = ann.split('-')[1]
114 | username = tag[1:]
115 | realName = getRealName(slackUsers, username)
116 | details = getEvent(ann)
117 |
118 | stringResult += "`{}` ({}): {} - `{} days` remaining!\n".format(tag, realName, details[0], details[1])
119 |
120 | if filterParameter is None or filterParameter == "custom":
121 | stringResult += "\nCustom Reminders:\n"
122 | for cstm in customs:
123 | splitted = cstm.split('-')
124 | username = splitted[2]
125 | realName = getRealName(slackUsers, username)
126 | details = getEvent(cstm)
127 |
128 | stringResult += "`{}-{}` ({}): {}\n".format(splitted[1], splitted[2], getRealName(slackUsers, username), details[0])
129 |
130 | return stringResult
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Birthday - Anniversary - Custom Reminder Slackbot
2 | This slackbot is written in python, using `AWS Chalice` to configure AWS Lambda and API Gateway automatically. Project uses Upstash Redis for database.
3 |
4 | ## Docs
5 | - ### [What the Bot Does](#what-this-bot-does)
6 | - ### [Configuring Upstash](#configure-upstash)
7 | - ### [Configuring Slack - 1](#configure-slack-bot-1)
8 | - ### [Configuring AWS Credentials](#configure-aws-credentials)
9 | - ### [Deployment using Chalice](#deploy-on-chalice)
10 | - #### [Running Locally](#run-locally)
11 | - #### [Deploying to AWS](#deploy-to-aws)
12 | - ### [Configuring Slack - 2](#configure-slack-bot-2)
13 |
14 |
15 | ### What The Bot Does
16 |
17 |
19 |
20 | * Commands:
21 | * set:
22 | * `/event set birthday ` :
23 | * Sets the birthday of the user.
24 | * `/event set anniversary ` :
25 | * Sets the anniversary for the user, when they started working there.
26 | * `/event set custom ` :
27 | * Sets a custom reminder using the message provided.
28 | * get-all:
29 | * `/event get-all birthday` :
30 | * Shows all birthdays that are set.
31 | * `/event get-all anniversary` :
32 | * Shows all anniversaries that are set.
33 | * `/event get-all custom` :
34 | * Shows all custom events that are set.
35 | * get:
36 | * `/event get birthday ` :
37 | * Shows the birthday details of the user.
38 | * `/event get anniversary ` :
39 | * Shows the anniversary details for the user, when they started working there.
40 | * `/event get custom (can be found with get-all)` :
41 | * Shows the custom event details using the message provided.
42 | * remove:
43 | * `/event remove birthday ` :
44 | * Removes the birthday of the user.
45 | * `/event remove anniversary ` :
46 | * Removes the anniversary for the user, when they started working there.
47 | * `/event remove custom (can be found with get-all)` :
48 | * Removes the custom event using the message provided.
49 |
50 | * If a single user is mentioned while setting the event:
51 | * When the actual event date comes, mentions them in the general channel with a celebratory message.
52 | * Sends a private message to everyone except the mentioned user before the actual date comes.
53 | #### Add some SS here.
54 |
55 | ### Configuring Upstash
56 |
57 | 1. Go to the [Upstash Console](https://console.upstash.com/) and create a new database
58 |
59 | #### Upstash environment
60 | Find the variables in the database details page in Upstash Console:
61 | `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`
62 |
63 | (These will be the env variables for chalice deployment)
64 |
65 |
66 |
67 |
68 | ### Configuring Slack - 1
69 |
70 | May need to revise permissions and what not
71 |
72 | 1. Go to [Slack API Apps Page](https://api.slack.com/apps):
73 | * Create new App
74 | * From Scratch
75 | * Name your app & pick a workspace
76 | * Go to Oauth & Permissions
77 | * Add the following scopes
78 | * channels:read
79 | * chat:write
80 | * chat:write.public
81 | * commands
82 | * groups:read
83 | * users:read
84 | * Install App to workspace
85 | * Basic Information --> Install Your App --> Install To Workspace
86 | 2. Note the variables (These will be the env variables for vercel deployment) :
87 | * `SLACK_SIGNING_SECRET`:
88 | * Go to Basic Information
89 | * App Credentials --> Signing Secret
90 | * `SLACK_BOT_TOKEN`:
91 | * Go to OAuth & Permissions
92 | * Bot User OAuth Token
93 |
94 |
95 |
96 |
97 | ### Configuring AWS Credentials
98 |
99 |
100 | (Taken from [Official Chalice Repo](https://github.com/aws/chalice). You can refer for more info there.)
101 | ```
102 | $ mkdir ~/.aws
103 | $ cat >> ~/.aws/config
104 | [default]
105 | aws_access_key_id=YOUR_ACCESS_KEY_HERE
106 | aws_secret_access_key=YOUR_SECRET_ACCESS_KEY
107 | region=YOUR_REGION (such as us-west-2, us-west-1, etc)
108 | ```
109 |
110 | ### Deployment Using Chalice
111 |
112 | * Install `chalice` if not installed:
113 |
114 | `
115 | pip install chalice
116 | `
117 |
118 | * Create chalice project by running:
119 |
120 | `
121 | chalice new-project
122 | `
123 |
124 | * Configure `config.json` file inside the `.chalice` directory by adding:
125 | ```
126 | "environment_variables": {
127 | "UPSTASH_REST_URL": ,
128 | "UPSTASH_TOKEN": ,
129 | "SLACK_BOT_TOKEN": ,
130 | "SLACK_SIGNING_SECRET": ,
131 | "NOTIFY_TIME_LIMIT": ""
132 | }
133 | ```
134 | ### Running Locally
135 |
136 |
137 | To run locally:
138 |
139 | chalice local
140 |
141 | Using services such as ngrok, tunnel the relevant port, then use the public domain for Slack.
142 | ### Deploy to AWS Lambda and Gateway
143 |
144 |
145 | chalice deploy
146 |
147 | Use the `REST API URL` for Slack.
148 | ### Configuring Slack - 2
149 |
150 |
151 | * After deployment, you can use the provided `REST API URL`.
152 |
153 | 1. Go to [Slack API Apps Page](https://api.slack.com/apps) and choose relevant app:
154 | * Go to Slash Commands:
155 | * Create New Command:
156 | * Command : `event`
157 | * Request URL : ``
158 | * Configure the rest however you like.
159 |
160 | After these changes, Slack may require reinstalling of the app.
161 |
162 |
163 |
164 |
165 |
--------------------------------------------------------------------------------
/birthday-slackbot/app.py:
--------------------------------------------------------------------------------
1 | from chalice import Chalice, Cron, Rate
2 | import os
3 | import random
4 | from datetime import date
5 | from chalicelib.utils import responseToDict, postToChannel, diffWithTodayFromString, allSlackUsers, sendDm, validateRequest, convertToCorrectMention
6 | from chalicelib.upstash import setHandler, getAllHandler, getEvent, getAllKeys, removeEvent
7 |
8 | app = Chalice(app_name='birthday-slackbot')
9 | NOTIFY_TIME_LIMIT = int(os.getenv("NOTIFY_TIME_LIMIT"))
10 |
11 |
12 | # Sample route for get requests.
13 | @app.route('/', methods=["GET"])
14 | def something():
15 | return {
16 | "Hello": "World"
17 | }
18 |
19 | # Configuring POST request endpoint.
20 | # Command is parsed and handled/directed to handler
21 | @app.route('/', methods=["POST"], content_types=["application/x-www-form-urlencoded"])
22 | def index():
23 |
24 | # Parse the body for ease of use
25 | r = responseToDict(app.current_request.raw_body)
26 | headers = app.current_request.headers
27 |
28 | # Check validity of the request.
29 | if not validateRequest(headers, r):
30 | return {"Status": "Validation failed."}
31 |
32 |
33 | commandArray = r['text'].split()
34 | command = commandArray.pop(0)
35 |
36 | try:
37 | if command == "set":
38 | setHandler(commandArray)
39 | return {
40 | 'response_type': "ephemeral",
41 | 'text': "Set the event."
42 | }
43 |
44 | elif command == "get":
45 | eventType = commandArray[0]
46 | eventName = eventType + "-" + commandArray[1]
47 | resultDict = getEvent(eventName)
48 | return {
49 | 'response_type': "ephemeral",
50 | 'text': "`{}` Details:\n\n Date: {}\nRemaining: {} days!".format(eventName, resultDict[0], resultDict[1])
51 | }
52 |
53 | elif command == "get-all":
54 |
55 | stringResult = getAllHandler(commandArray)
56 | return {
57 | 'response_type': "ephemeral",
58 | 'text': "{}".format(stringResult)
59 | }
60 |
61 | elif command == "remove":
62 | eventName = "{}-{}".format(commandArray[0], commandArray[1])
63 | removeEvent(eventName)
64 | return {
65 | 'response_type': "ephemeral",
66 | 'text': "Removed the event."
67 | }
68 | else:
69 | return {
70 | 'response_type': "ephemeral",
71 | 'text': "Wrong usage of the command."
72 | }
73 | except:
74 | print("some stuff")
75 | return {
76 | 'response_type': "ephemeral",
77 | 'text': "Some problem occured. Please check your command."
78 | }
79 |
80 |
81 | # Run at 10:00 am (UTC) every day.
82 | @app.schedule(Cron(0, 10, '*', '*', '?', '*'))
83 | def periodicCheck(event):
84 | allKeys = getAllKeys()
85 | for key in allKeys:
86 | handleEvent(key)
87 |
88 |
89 | # Generic event is parsed and directed to relevant handlers.
90 | def handleEvent(eventName):
91 | eventSplitted = eventName.split('-')
92 |
93 | eventType = eventSplitted[0]
94 |
95 | # discard @ or ! as a first character
96 | personName = eventSplitted[1][1:]
97 | personMention = convertToCorrectMention(personName)
98 |
99 | eventDict = getEvent(eventName)
100 | remainingDays = eventDict[1]
101 | totalTime = eventDict[2]
102 |
103 |
104 | if eventType == "birthday":
105 | birthdayHandler(personMention, personName, remainingDays)
106 |
107 | elif eventType == "anniversary":
108 | anniversaryHandler(personMention, personName, remainingDays, totalTime)
109 |
110 | elif eventType == "custom":
111 | eventMessage = "Not specified"
112 | if len(eventSplitted) == 3:
113 | eventMessage = eventSplitted[2]
114 | customHandler(eventMessage, personMention, personName, remainingDays)
115 |
116 | # Handles birthday events.
117 | def birthdayHandler(personMention, personName, remainingDays):
118 | if remainingDays == 0:
119 | sendRandomBirthdayToChannel('general', personMention)
120 | if remainingDays <= NOTIFY_TIME_LIMIT:
121 | dmEveryoneExcept("{} day(s) until {}'s birthday!".format(remainingDays, personMention), personName)
122 |
123 | # Handles anniversary events.
124 | def anniversaryHandler(personMention, personName, remainingDays, totalTime):
125 | if remainingDays == 0:
126 | sendRandomAnniversaryToChannel('general', personMention, totalTime)
127 | if remainingDays <= NOTIFY_TIME_LIMIT:
128 | dmEveryoneExcept("{} day(s) until {}'s anniversary! It will be {} year(s) since they joined!".format(remainingDays, personMention, totalTime), personName)
129 |
130 | # Handles custom events.
131 | def customHandler(eventMessage, personMention, personName, remainingDays):
132 | if remainingDays == 0:
133 | postToChannel('general', "`{}` is here {}!".format(eventMessage, personMention))
134 | elif remainingDays <= NOTIFY_TIME_LIMIT:
135 | dmEveryoneExcept("{} day(s) until {} `{}`!".format(remainingDays, personMention, eventMessage), personName)
136 |
137 |
138 | # Sends private message to everyone except for the person given.
139 | def dmEveryoneExcept(message, person):
140 | usersAndIds = allSlackUsers()
141 | for user in usersAndIds:
142 | if user[0] != person:
143 | sendDm(user[1], message)
144 |
145 |
146 | # Sends randomly chosen birthday message to specified channel.
147 | def sendRandomBirthdayToChannel(channel, personMention):
148 | messageList = [
149 | "Happy Birthday {}! Wishing you the best!".format(personMention),
150 | "Happy Birthday {}! Wishing you a happy age!".format(personMention),
151 | "Happy Birthday {}! Wishing you a healthy, happy life!".format(personMention),
152 | ]
153 | message = random.choice(messageList)
154 | return postToChannel('general', message)
155 |
156 | # Sends randomly chosen anniversary message to specified channel.
157 | def sendRandomAnniversaryToChannel(channel, personMention, totalTime):
158 | messageList = [
159 | "Today is the anniversary of {} joining! It has been {} years since they joined!".format(personMention, totalTime - 1),
160 | "Celebrating the anniversary of {} joining! It has been {} years!".format(personMention, totalTime - 1),
161 | "Congratulating {} for entering {}(th) year here!".format(personMention, totalTime),
162 | ]
163 | message = random.choice(messageList)
164 | return postToChannel('general', message)
165 |
166 |
167 | # We want to run our event handlers when the project is deployed/redeployed.
168 | allKeys = getAllKeys()
169 | for key in allKeys:
170 | handleEvent(key)
--------------------------------------------------------------------------------