Skip to content

Commit d2a01c1

Browse files
committed
Sirbot
1 parent a839b10 commit d2a01c1

File tree

18 files changed

+1054
-14
lines changed

18 files changed

+1054
-14
lines changed

.env.sample

Lines changed: 0 additions & 1 deletion
This file was deleted.

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ RUN apk add --no-cache tzdata gcc g++ make postgresql-dev build-base git && \
1111
echo "UTC" >> /etc/timezone && \
1212
apk del tzdata
1313

14+
RUN apk add --no-cache libffi-dev git
15+
1416
COPY requirements requirements
1517
RUN pip install -r requirements/development.txt
1618

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ services:
1212
DATABASE_URL: "postgresql://${USER}:@postgresql:5432/pyslackers_dev"
1313
SLACK_INVITE_TOKEN: "${SLACK_INVITE_TOKEN}"
1414
SLACK_TOKEN: "${SLACK_TOKEN}"
15+
SLACK_SIGNING_SECRET: "${SLACK_SIGNING_SECRET}"
1516
ports:
1617
- "8000:8000"
1718
volumes:

pyslackersweb/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ class Source(Enum):
5050
"slack_users",
5151
metadata,
5252
sa.Column("id", sa.Text, primary_key=True),
53+
sa.Column("name", sa.Text),
5354
sa.Column("deleted", sa.Boolean),
5455
sa.Column("admin", sa.Boolean),
5556
sa.Column("bot", sa.Boolean),
5657
sa.Column("timezone", sa.Text),
5758
sa.Column("first_seen", sa.DateTime(timezone=True), default=datetime.now),
5859
sa.Index("ix_slack_users_id", "id"),
59-
sa.Index("ix_slack_users_admin", "id", "admin"),
60-
sa.Index("ix_slack_users_timezone", "id", "timezone"),
60+
sa.Index("ix_slack_users_name", "name"),
61+
sa.Index("ix_slack_users_id_admin", "id", "admin"),
62+
sa.Index("ix_slack_users_id_timezone", "id", "timezone"),
6163
)

pyslackersweb/sirbot/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import logging
2+
13
from aiohttp import web
24

35
from .views import routes
46
from .context import background_jobs
57

8+
logger = logging.getLogger(__name__)
9+
610

711
async def app_factory() -> web.Application:
812
sirbot = web.Application()

pyslackersweb/sirbot/database.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
22

33
import asyncpg
4+
import sqlalchemy as sa
45

6+
from pyslackersweb import models
57
from pyslackersweb.util.log import ContextAwareLoggerAdapter
68

79

@@ -15,3 +17,6 @@ async def get_challenge(conn: asyncpg.connection.Connection) -> str:
1517
SELECT id FROM codewars_challenge WHERE posted_at IS NULL ORDER BY RANDOM() LIMIT 1
1618
) RETURNING id""",
1719
)
20+
21+
async def is_admin(conn: asyncpg.connection.Connection, user: str) -> bool:
22+
return await conn.fetchval(sa.select([models.SlackUsers.c.admin]).where(id=user))

pyslackersweb/sirbot/models.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import sqlalchemy as sa
2+
3+
import dataclasses
4+
15
from datetime import datetime
6+
from typing import Optional
7+
from decimal import Decimal
28

3-
import sqlalchemy as sa
9+
from sqlalchemy.dialects.postgresql import JSONB
410

511
from pyslackersweb.models import metadata
612

@@ -17,3 +23,35 @@
1723
nullable=True,
1824
),
1925
)
26+
27+
@dataclasses.dataclass(frozen=True)
28+
class StockQuote:
29+
# pylint: disable=too-many-instance-attributes
30+
31+
symbol: str
32+
company: str
33+
price: Decimal
34+
change: Decimal
35+
change_percent: Decimal
36+
market_open: Decimal
37+
market_close: Decimal
38+
high: Decimal
39+
low: Decimal
40+
volume: Decimal
41+
time: datetime
42+
logo: Optional[str] = None
43+
44+
45+
SlackMessage = sa.Table(
46+
"slack_messages",
47+
metadata,
48+
sa.Column("id", sa.Text, primary_key=True),
49+
sa.Column("send_at", sa.DateTime),
50+
sa.Column("user", sa.Text),
51+
sa.Column("channel", sa.Text),
52+
sa.Column("message", sa.Text),
53+
sa.Column("raw", JSONB),
54+
sa.Index("ix_slack_messages_user", "user", "send_at"),
55+
sa.Index("ix_slack_messages_channel", "channel", "send_at"),
56+
sa.Index("ix_slack_messages_user_channel", "user", "channel", "send_at"),
57+
)

pyslackersweb/sirbot/settings.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import os
22

3-
# production
3+
IS_PRODUCTION = os.environ.get("PLATFORM_BRANCH") == "master"
4+
5+
# production settings
46
READTHEDOCS_NOTIFICATION_CHANNEL = "community_projects"
7+
SLACK_TEAM_ID = os.environ.get("SLACK_TEAM_ID")
8+
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
9+
SLACK_ADMIN_CHANNEL = os.environ.get("SLACK_ADMIN_CHANNEL", "")
10+
SLACK_INTRODUCTION_CHANNEL = "introductions"
511

6-
# Development
7-
if os.environ.get("PLATFORM_BRANCH") != "master":
12+
# Development settings
13+
if not IS_PRODUCTION:
814
READTHEDOCS_NOTIFICATION_CHANNEL = "general"
15+
SLACK_ADMIN_CHANNEL = "CJ1BWMBDX" # general
16+
SLACK_INTRODUCTION_CHANNEL = "general"

pyslackersweb/sirbot/slack/actions.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import json
2+
import logging
3+
import asyncio
4+
5+
from aiohttp import web
6+
from slack import methods
7+
from slack import actions
8+
from slack.events import Message
9+
from slack.exceptions import SlackAPIError, RateLimited
10+
11+
from pyslackersweb.sirbot import settings, models
12+
from pyslackersweb.util.log import ContextAwareLoggerAdapter
13+
14+
logger = ContextAwareLoggerAdapter(logging.getLogger(__name__))
15+
16+
17+
async def topic_change_revert(request: web.Request, action: actions.Action) -> None:
18+
response = Message()
19+
response["channel"] = action["channel"]["id"]
20+
response["ts"] = action["message_ts"]
21+
response["attachments"] = action["original_message"]["attachments"]
22+
response["attachments"][0]["color"] = "danger"
23+
response["attachments"][0]["text"] = f'Change reverted by <@{action["user"]["id"]}>'
24+
del response["attachments"][0]["actions"]
25+
26+
data = json.loads(action["actions"][0]["value"])
27+
await request.app["slack_client"].query(
28+
url=methods.CHANNELS_SET_TOPIC,
29+
data={"channel": data["channel"], "topic": data["old_topic"]},
30+
)
31+
32+
await request.app["slack_client"].query(url=action["response_url"], data=response)
33+
34+
35+
async def topic_change_validate(request: web.Request, action: actions.Action) -> None:
36+
response = Message()
37+
response["channel"] = action["channel"]["id"]
38+
response["ts"] = action["message_ts"]
39+
response["attachments"] = action["original_message"]["attachments"]
40+
response["attachments"][0]["color"] = "good"
41+
response["attachments"][0]["text"] = f'Change validated by <@{action["user"]["id"]}>'
42+
del response["attachments"][0]["actions"]
43+
44+
await request.app["slack_client"].query(url=action["response_url"], data=response)
45+
46+
47+
async def purpose_change_revert(request: web.Request, action: actions.Action) -> None:
48+
response = Message()
49+
response["channel"] = action["channel"]["id"]
50+
response["ts"] = action["message_ts"]
51+
response["attachments"] = action["original_message"]["attachments"]
52+
response["attachments"][0]["color"] = "danger"
53+
response["attachments"][0]["text"] = f'Change reverted by <@{action["user"]["id"]}>'
54+
del response["attachments"][0]["actions"]
55+
56+
data = json.loads(action["actions"][0]["value"])
57+
await request.app["slack_client"].query(
58+
url=methods.CHANNELS_SET_PURPOSE,
59+
data={"channel": data["channel"], "purpose": data["old_purpose"]},
60+
)
61+
62+
await request.app["slack_client"].query(url=action["response_url"], data=response)
63+
64+
65+
async def purpose_change_validate(request: web.Request, action: actions.Action) -> None:
66+
response = Message()
67+
response["channel"] = action["channel"]["id"]
68+
response["ts"] = action["message_ts"]
69+
response["attachments"] = action["original_message"]["attachments"]
70+
response["attachments"][0]["color"] = "good"
71+
response["attachments"][0]["text"] = f'Change validated by <@{action["user"]["id"]}>'
72+
del response["attachments"][0]["actions"]
73+
74+
await request.app["slack_client"].query(url=action["response_url"], data=response)
75+
76+
77+
async def pin_added_validate(request: web.Request, action: actions.Action) -> None:
78+
response = Message()
79+
response["channel"] = action["channel"]["id"]
80+
response["ts"] = action["message_ts"]
81+
response["attachments"] = action["original_message"]["attachments"]
82+
response["attachments"][0]["color"] = "good"
83+
response["attachments"][0]["pretext"] = f'Pin validated by <@{action["user"]["id"]}>'
84+
del response["attachments"][0]["actions"]
85+
86+
await request.app["slack_client"].query(url=action["response_url"], data=response)
87+
88+
89+
async def pin_added_revert(request: web.Request, action: actions.Action) -> None:
90+
response = Message()
91+
92+
response["channel"] = action["channel"]["id"]
93+
response["ts"] = action["message_ts"]
94+
response["attachments"] = action["original_message"]["attachments"]
95+
response["attachments"][0]["color"] = "danger"
96+
response["attachments"][0]["pretext"] = f'Pin reverted by <@{action["user"]["id"]}>'
97+
del response["attachments"][0]["actions"]
98+
99+
action_data = json.loads(action["actions"][0]["value"])
100+
remove_data = {"channel": action_data["channel"]}
101+
102+
if action_data["item_type"] == "message":
103+
remove_data["timestamp"] = action_data["item_id"]
104+
elif action_data["item_type"] == "file":
105+
remove_data["file"] = action_data["item_id"]
106+
elif action_data["item_type"] == "file_comment":
107+
remove_data["file_comment"] = action_data["item_id"]
108+
else:
109+
raise TypeError(f'Unknown pin type: {action_data["type"]}')
110+
111+
try:
112+
await request.app["slack_client"].query(url=methods.PINS_REMOVE, data=remove_data)
113+
except SlackAPIError as e:
114+
if e.error != "no_pin":
115+
raise
116+
117+
await request.app["slack_client"].query(url=action["response_url"], data=response)
118+
119+
120+
async def admin_msg(request: web.Request, action: actions.Action) -> None:
121+
admin_msg = Message()
122+
admin_msg["channel"] = settings.SLACK_ADMIN_CHANNEL
123+
admin_msg["attachments"] = [
124+
{
125+
"fallback": f'Message from {action["user"]["name"]}',
126+
"title": f'Message from <@{action["user"]["id"]}>',
127+
"color": "good",
128+
"text": action["submission"]["message"],
129+
}
130+
]
131+
132+
await request.app["slack_client"].query(url=methods.CHAT_POST_MESSAGE, data=admin_msg)
133+
134+
response = Message()
135+
response["response_type"] = "ephemeral"
136+
response["text"] = "Thank you for your message."
137+
138+
await request.app["slack_client"].query(url=action["response_url"], data=response)
139+
140+
141+
async def user_cleanup(request: web.Request, action: actions.Action) -> None:
142+
user_id = action["actions"][0]["value"]
143+
144+
response = Message()
145+
response["text"] = f"Cleanup of <@{user_id}> triggered by <@{action['user']['id']}>"
146+
147+
await request.app["slack_client"].query(url=action["response_url"], data=response)
148+
149+
asyncio.create_task(_cleanup_user(request.app, user_id))
150+
151+
152+
async def _cleanup_user(app: web.Application, user: str) -> None:
153+
try:
154+
async with app["pg"].acquire() as conn:
155+
messages = await conn.fetch(
156+
"""SELECT id, channel FROM slack_messages WHERE "user" = $1 ORDER BY send_at DESC""",
157+
user,
158+
)
159+
160+
for message in messages:
161+
await _delete_message(app["slack_client"], message)
162+
except Exception:
163+
logger.exception("Unexpected exception cleaning up user %s", user)
164+
165+
166+
async def _delete_message(slack, message: dict) -> None:
167+
data = {"channel": message["channel"], "ts": message["id"]}
168+
try:
169+
await slack.query(url=methods.CHAT_DELETE, data=data)
170+
except RateLimited:
171+
logger.debug("sleeping")
172+
await asyncio.sleep(20)
173+
await _delete_message(slack, message)
174+
except SlackAPIError as e:
175+
if e.error == "message_not_found":
176+
return
177+
else:
178+
logger.exception(
179+
"Failed to cleanup message %s in channel %s", message["id"], message["channel"],
180+
)
181+
except Exception:
182+
logger.exception(
183+
"Failed to cleanup message %s in channel %s", message["id"], message["channel"],
184+
)

0 commit comments

Comments
 (0)