Skip to content

Commit f2cd74a

Browse files
author
Irving Popovetsky
committed
phases 1-4 vendoring
1 parent 1b0d494 commit f2cd74a

23 files changed

Lines changed: 2124 additions & 8 deletions

docs/UPGRADE_PLAN.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,10 @@ This warning is expected and relates to aiohttp's deprecation patterns. It will
519519

520520
---
521521

522-
## Phase 1: Setup and Preparation (Days 5-6)
522+
## Phase 1: Setup and Preparation (Days 5-6) ✅ COMPLETE
523+
524+
> **Status**: Completed January 4, 2026
525+
> **Result**: All source files vendored (22 files total), feature branch created
523526

524527
### 1.1 Create Development Environment
525528

@@ -530,10 +533,7 @@ cd /path/to/operationcode-pybot
530533
pyenv local 3.12.1
531534
532535
# Verify Python version
533-
python --version # Should show 3.12.x
534-
535-
# Install poetry if needed
536-
curl -sSL https://install.python-poetry.org | python3 -
536+
python3 --version # Should show 3.12.x
537537
```
538538

539539
### 1.2 Create Feature Branch
@@ -563,7 +563,10 @@ git clone https://github.com/pyslackers/sir-bot-a-lot-2.git
563563

564564
---
565565

566-
## Phase 2: Vendor slack-sansio (Day 2-3)
566+
## Phase 2: Vendor slack-sansio (Day 2-3) ✅ COMPLETE
567+
568+
> **Status**: Completed January 4, 2026
569+
> **Result**: slack-sansio already Python 3.12 compatible - no loop= parameters, no asyncio.coroutine usage
567570

568571
### 2.1 Copy Required Files
569572

@@ -646,7 +649,10 @@ __all__ = ["methods"]
646649

647650
---
648651

649-
## Phase 3: Vendor sirbot (Day 3-4)
652+
## Phase 3: Vendor sirbot (Day 3-4) ✅ COMPLETE
653+
654+
> **Status**: Completed January 4, 2026
655+
> **Result**: sirbot modernized for Python 3.12+ - removed asyncio.coroutine(), fixed loop= parameters, added type hints
650656

651657
### 3.1 Copy Required Files
652658

@@ -965,7 +971,10 @@ from pybot._vendor.slack.io.aiohttp import SlackAPI
965971

966972
---
967973

968-
## Phase 4: Create Vendor Package Init (Day 4)
974+
## Phase 4: Create Vendor Package Init (Day 4) ✅ COMPLETE
975+
976+
> **Status**: Completed January 4, 2026
977+
> **Result**: All vendored packages verified working - imports, instantiation, and Python 3.12 compatibility confirmed
969978
970979
### 4.1 Create pybot/_vendor/__init__.py
971980

pybot/_vendor/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""
2+
Vendored dependencies for operationcode-pybot.
3+
4+
These are modified copies of abandoned libraries, updated for Python 3.12+:
5+
- sirbot: https://github.com/pyslackers/sir-bot-a-lot-2 (MIT License)
6+
- slack-sansio: https://github.com/pyslackers/slack-sansio (MIT License)
7+
8+
Both libraries were last updated in 2019-2020 and are no longer maintained.
9+
We vendor them here with minimal modifications for modern Python compatibility.
10+
"""
11+
12+
__all__ = ["sirbot", "slack"]

pybot/_vendor/sirbot/LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Original work Copyright (c) 2017-2019 pyslackers
4+
Modified work Copyright (c) 2026 Operation Code
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.

pybot/_vendor/sirbot/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Vendored sirbot library for Python 3.12+
3+
Original: https://github.com/pyslackers/sir-bot-a-lot-2
4+
"""
5+
from pybot._vendor.sirbot.bot import SirBot
6+
7+
__all__ = ["SirBot"]

pybot/_vendor/sirbot/bot.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Vendored sirbot library for Python 3.12+
3+
Original: https://github.com/pyslackers/sir-bot-a-lot-2
4+
"""
5+
6+
import logging
7+
from typing import Any
8+
9+
import aiohttp.web
10+
11+
from . import endpoints
12+
13+
LOG = logging.getLogger(__name__)
14+
15+
16+
class SirBot(aiohttp.web.Application):
17+
def __init__(self, user_agent: str | None = None, **kwargs: Any) -> None:
18+
super().__init__(**kwargs)
19+
20+
self.router.add_route("GET", "/sirbot/plugins", endpoints.plugins)
21+
22+
self["plugins"] = {}
23+
self["http_session"] = None # Created on startup
24+
self["user_agent"] = user_agent or "sir-bot-a-lot"
25+
26+
self.on_startup.append(self._create_session)
27+
self.on_shutdown.append(self.stop)
28+
29+
async def _create_session(self, app: aiohttp.web.Application) -> None:
30+
"""Create HTTP session on startup (when event loop exists)."""
31+
self["http_session"] = aiohttp.ClientSession()
32+
33+
def start(self, **kwargs: Any) -> None:
34+
LOG.info("Starting SirBot")
35+
aiohttp.web.run_app(self, **kwargs)
36+
37+
def load_plugin(self, plugin: Any, name: str | None = None) -> None:
38+
name = name or plugin.__name__
39+
self["plugins"][name] = plugin
40+
plugin.load(self)
41+
42+
async def stop(self, sirbot: "SirBot") -> None:
43+
if self["http_session"]:
44+
await self["http_session"].close()
45+
46+
@property
47+
def plugins(self) -> dict:
48+
return self["plugins"]
49+
50+
@property
51+
def http_session(self) -> aiohttp.ClientSession:
52+
return self["http_session"]
53+
54+
@property
55+
def user_agent(self) -> str:
56+
return self["user_agent"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from aiohttp.web import json_response
2+
3+
4+
async def plugins(request):
5+
data = [k for k in request.app["plugins"].keys()]
6+
return json_response({"plugins": data})

pybot/_vendor/sirbot/plugins/__init__.py

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Slack plugin for sirbot."""
2+
from pybot._vendor.sirbot.plugins.slack.plugin import SlackPlugin
3+
4+
__all__ = ["SlackPlugin"]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import asyncio
2+
import logging
3+
4+
import aiohttp.web
5+
from aiohttp.web import Response
6+
7+
from pybot._vendor.slack.events import Event
8+
from pybot._vendor.slack.sansio import validate_request_signature
9+
from pybot._vendor.slack.actions import Action
10+
from pybot._vendor.slack.commands import Command
11+
from pybot._vendor.slack.exceptions import InvalidTimestamp, FailedVerification, InvalidSlackSignature
12+
13+
LOG = logging.getLogger(__name__)
14+
15+
16+
async def incoming_event(request):
17+
slack = request.app.plugins["slack"]
18+
payload = await request.json()
19+
LOG.log(5, "Incoming event payload: %s", payload)
20+
21+
if payload.get("type") == "url_verification":
22+
if slack.signing_secret:
23+
try:
24+
raw_payload = await request.read()
25+
validate_request_signature(
26+
raw_payload.decode("utf-8"), request.headers, slack.signing_secret
27+
)
28+
return Response(body=payload["challenge"])
29+
except (InvalidSlackSignature, InvalidTimestamp):
30+
return Response(status=500)
31+
elif payload["token"] == slack.verify:
32+
return Response(body=payload["challenge"])
33+
else:
34+
return Response(status=500)
35+
36+
try:
37+
verification_token = await _validate_request(request, slack)
38+
event = Event.from_http(payload, verification_token=verification_token)
39+
except (FailedVerification, InvalidSlackSignature, InvalidTimestamp):
40+
return Response(status=401)
41+
42+
if event["type"] == "message":
43+
return await _incoming_message(event, request)
44+
else:
45+
futures = list(_dispatch(slack.routers["event"], event, request.app))
46+
if futures:
47+
return await _wait_and_check_result(futures)
48+
49+
return Response(status=200)
50+
51+
52+
async def _incoming_message(event, request):
53+
slack = request.app.plugins["slack"]
54+
55+
if slack.bot_id and (
56+
event.get("bot_id") == slack.bot_id
57+
or event.get("message", {}).get("bot_id") == slack.bot_id
58+
):
59+
return Response(status=200)
60+
61+
LOG.debug("Incoming message: %s", event)
62+
text = event.get("text")
63+
if slack.bot_user_id and text:
64+
mention = slack.bot_user_id in event["text"] or event["channel"].startswith("D")
65+
else:
66+
mention = False
67+
68+
if mention and text and text.startswith(f"<@{slack.bot_user_id}>"):
69+
event["text"] = event["text"][len(f"<@{slack.bot_user_id}>") :]
70+
event["text"] = event["text"].strip()
71+
72+
futures = []
73+
for handler, configuration in slack.routers["message"].dispatch(event):
74+
if configuration["mention"] and not mention:
75+
continue
76+
elif configuration["admin"] and event["user"] not in slack.admins:
77+
continue
78+
79+
f = asyncio.ensure_future(handler(event, request.app))
80+
if configuration["wait"]:
81+
futures.append(f)
82+
else:
83+
f.add_done_callback(_callback)
84+
85+
if futures:
86+
return await _wait_and_check_result(futures)
87+
88+
return Response(status=200)
89+
90+
91+
async def incoming_command(request):
92+
slack = request.app.plugins["slack"]
93+
payload = await request.post()
94+
95+
try:
96+
verification_token = await _validate_request(request, slack)
97+
command = Command(payload, verification_token=verification_token)
98+
except (FailedVerification, InvalidSlackSignature, InvalidTimestamp):
99+
return Response(status=401)
100+
101+
LOG.debug("Incoming command: %s", command)
102+
futures = list(_dispatch(slack.routers["command"], command, request.app))
103+
if futures:
104+
return await _wait_and_check_result(futures)
105+
106+
return Response(status=200)
107+
108+
109+
async def incoming_action(request):
110+
slack = request.app.plugins["slack"]
111+
payload = await request.post()
112+
LOG.log(5, "Incoming action payload: %s", payload)
113+
114+
try:
115+
verification_token = await _validate_request(request, slack)
116+
action = Action.from_http(payload, verification_token=verification_token)
117+
except (FailedVerification, InvalidSlackSignature, InvalidTimestamp):
118+
return Response(status=401)
119+
120+
LOG.debug("Incoming action: %s", action)
121+
122+
futures = list(_dispatch(slack.routers["action"], action, request.app))
123+
if futures:
124+
return await _wait_and_check_result(futures)
125+
126+
return Response(status=200)
127+
128+
129+
def _callback(f):
130+
try:
131+
f.result()
132+
except Exception as e:
133+
LOG.exception(e)
134+
135+
136+
def _dispatch(router, event, app):
137+
for handler, configuration in router.dispatch(event):
138+
f = asyncio.ensure_future(handler(event, app))
139+
if configuration["wait"]:
140+
yield f
141+
else:
142+
f.add_done_callback(_callback)
143+
144+
145+
async def _wait_and_check_result(futures):
146+
dones, _ = await asyncio.wait(futures, return_when=asyncio.ALL_COMPLETED)
147+
try:
148+
results = [done.result() for done in dones]
149+
except Exception as e:
150+
LOG.exception(e)
151+
return Response(status=500)
152+
153+
results = [result for result in results if isinstance(result, aiohttp.web.Response)]
154+
if len(results) > 1:
155+
LOG.warning("Multiple web.Response for handler, returning none")
156+
elif results:
157+
return results[0]
158+
159+
return Response(status=200)
160+
161+
162+
async def _validate_request(request, slack):
163+
if slack.signing_secret:
164+
raw_payload = await request.read()
165+
validate_request_signature(
166+
raw_payload.decode("utf-8"), request.headers, slack.signing_secret
167+
)
168+
return None
169+
else:
170+
return slack.verify

0 commit comments

Comments
 (0)