koi-net-slack-sensor-node 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- koi_net_slack_sensor_node-0.1.0/.github/workflows/publish-to-pypi.yml +81 -0
- koi_net_slack_sensor_node-0.1.0/.gitignore +10 -0
- koi_net_slack_sensor_node-0.1.0/LICENSE +21 -0
- koi_net_slack_sensor_node-0.1.0/PKG-INFO +39 -0
- koi_net_slack_sensor_node-0.1.0/README.md +2 -0
- koi_net_slack_sensor_node-0.1.0/koi-net-slack-sensor-node.service +11 -0
- koi_net_slack_sensor_node-0.1.0/pyproject.toml +28 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/__init__.py +0 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/__main__.py +3 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/backfiller.py +157 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/config.py +44 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/core.py +23 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/dereference.py +65 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/last_processed_ts_handler.py +24 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/server.py +18 -0
- koi_net_slack_sensor_node-0.1.0/src/koi_net_slack_sensor_node/slack_event_handler.py +87 -0
- koi_net_slack_sensor_node-0.1.0/uv.lock +1842 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: Publish Python package to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags:
|
|
5
|
+
- 'v*.*.*'
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build distro
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v6
|
|
14
|
+
with:
|
|
15
|
+
persist-credentials: false
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.x"
|
|
20
|
+
- name: Install pypa/build
|
|
21
|
+
run: python3 -m pip install build --user
|
|
22
|
+
- name: Build a binary wheel and a source tarball
|
|
23
|
+
run: python3 -m build
|
|
24
|
+
- name: Store the distribution packages
|
|
25
|
+
uses: actions/upload-artifact@v5
|
|
26
|
+
with:
|
|
27
|
+
name: python-package-distributions
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish-to-pypi:
|
|
31
|
+
name: Publish Python distribution to PyPI
|
|
32
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
33
|
+
needs:
|
|
34
|
+
- build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/p/koi-net-slack-sensor-node
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write
|
|
41
|
+
|
|
42
|
+
steps:
|
|
43
|
+
- name: Download all the dists
|
|
44
|
+
uses: actions/download-artifact@v6
|
|
45
|
+
with:
|
|
46
|
+
name: python-package-distributions
|
|
47
|
+
path: dist/
|
|
48
|
+
- name: Publish distro to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
50
|
+
|
|
51
|
+
github-release:
|
|
52
|
+
name: >-
|
|
53
|
+
Sign the Python distribution with Sigstore
|
|
54
|
+
and upload them to GitHub Release
|
|
55
|
+
needs:
|
|
56
|
+
- publish-to-pypi
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
|
|
59
|
+
permissions:
|
|
60
|
+
contents: write
|
|
61
|
+
id-token: write
|
|
62
|
+
|
|
63
|
+
steps:
|
|
64
|
+
- name: Download all the dists
|
|
65
|
+
uses: actions/download-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: python-package-distributions
|
|
68
|
+
path: dist/
|
|
69
|
+
- name: Sign the dists with Sigstore
|
|
70
|
+
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
|
71
|
+
with:
|
|
72
|
+
inputs: >-
|
|
73
|
+
./dist/*.tar.gz
|
|
74
|
+
./dist/*.whl
|
|
75
|
+
- name: Upload artifact signatures to GitHub Release
|
|
76
|
+
env:
|
|
77
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
78
|
+
run: >-
|
|
79
|
+
gh release upload
|
|
80
|
+
"$GITHUB_REF_NAME" dist/**
|
|
81
|
+
--repo "$GITHUB_REPOSITORY"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BlockScience
|
|
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.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: koi-net-slack-sensor-node
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: KOI-net Slack sensor node
|
|
5
|
+
Project-URL: Homepage, https://github.com/BlockScience/koi-net-slack-sensor-node/
|
|
6
|
+
Author-email: Luke Miller <luke@block.science>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 BlockScience
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: aiohttp>=3.13.2
|
|
31
|
+
Requires-Dist: koi-net~=2.0
|
|
32
|
+
Requires-Dist: slack-bolt>=1.26.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: build; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# koi-net-slack-sensor-node
|
|
39
|
+
Slack sensor node implementation for BlockScience's KOI-net
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=KOI-net Slack Sensor Node Service
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
WorkingDirectory=/home/dev/koi-net-slack-sensor-node
|
|
7
|
+
ExecStart=/home/dev/koi-net-slack-sensor-node/.venv/bin/python3 -m koi_net_slack_sensor_node
|
|
8
|
+
Restart=always
|
|
9
|
+
|
|
10
|
+
[Install]
|
|
11
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "koi-net-slack-sensor-node"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "KOI-net Slack sensor node"
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "Luke Miller", email = "luke@block.science"}
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
license = {file = "LICENSE"}
|
|
15
|
+
dependencies = [
|
|
16
|
+
"koi-net~=2.0",
|
|
17
|
+
"aiohttp>=3.13.2",
|
|
18
|
+
"slack_bolt>=1.26.0"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["twine>=6.0", "build"]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."koi_net.node"]
|
|
25
|
+
slack_sensor = "koi_net_slack_sensor_node"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/BlockScience/koi-net-slack-sensor-node/"
|
|
File without changes
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from logging import Logger
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from koi_net.core import KobjQueue
|
|
7
|
+
from koi_net.components.interfaces import ThreadedComponent
|
|
8
|
+
from slack_bolt.async_app import AsyncApp
|
|
9
|
+
from slack_sdk.errors import SlackApiError
|
|
10
|
+
from rid_lib.ext import Bundle
|
|
11
|
+
from rid_lib.types import SlackMessage
|
|
12
|
+
|
|
13
|
+
from .config import SlackSensorNodeConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Backfiller(ThreadedComponent):
|
|
18
|
+
log: Logger
|
|
19
|
+
slack_app: AsyncApp
|
|
20
|
+
config: SlackSensorNodeConfig
|
|
21
|
+
kobj_queue: KobjQueue
|
|
22
|
+
should_exit: threading.Event = field(init=False, default_factory=threading.Event)
|
|
23
|
+
|
|
24
|
+
def start(self):
|
|
25
|
+
self.should_exit.clear()
|
|
26
|
+
super().start()
|
|
27
|
+
|
|
28
|
+
def stop(self):
|
|
29
|
+
self.should_exit.set()
|
|
30
|
+
super().stop()
|
|
31
|
+
|
|
32
|
+
async def auto_retry(self, function, **kwargs):
|
|
33
|
+
try:
|
|
34
|
+
return await function(**kwargs)
|
|
35
|
+
except SlackApiError as e:
|
|
36
|
+
if e.response["error"] == "ratelimited":
|
|
37
|
+
retry_after = int(e.response.headers["Retry-After"])
|
|
38
|
+
self.log.info(f"timed out, waiting {retry_after} seconds")
|
|
39
|
+
await asyncio.sleep(retry_after)
|
|
40
|
+
return await function(**kwargs)
|
|
41
|
+
elif e.response["error"] == "not_in_channel":
|
|
42
|
+
self.log.info(f"not in channel {kwargs['channel']}, attempting to join")
|
|
43
|
+
await self.slack_app.client.conversations_join(channel=kwargs["channel"])
|
|
44
|
+
return await function(**kwargs)
|
|
45
|
+
else:
|
|
46
|
+
self.log.warning("unknown error", e)
|
|
47
|
+
|
|
48
|
+
def run(self):
|
|
49
|
+
asyncio.run(self.backfill_messages())
|
|
50
|
+
|
|
51
|
+
async def backfill_messages(self):
|
|
52
|
+
resp = await self.slack_app.client.team_info()
|
|
53
|
+
team = resp.data["team"]
|
|
54
|
+
team_id = team["id"]
|
|
55
|
+
|
|
56
|
+
channels = [{"id": cid} for cid in self.config.slack.allowed_channels]
|
|
57
|
+
|
|
58
|
+
self.log.info("Scanning for channels")
|
|
59
|
+
|
|
60
|
+
# get list of channels
|
|
61
|
+
channel_cursor = None
|
|
62
|
+
while (not channels or channel_cursor) and not self.should_exit.is_set():
|
|
63
|
+
resp = await self.slack_app.client.conversations_list(cursor=channel_cursor)
|
|
64
|
+
result = resp.data
|
|
65
|
+
channels.extend(result["channels"])
|
|
66
|
+
self.log.info(f"Found {len(result['channels'])} channels")
|
|
67
|
+
channel_cursor = result.get("response_metadata", {}).get("next_cursor")
|
|
68
|
+
|
|
69
|
+
self.log.info(f"Scanning {len(channels)} channels for messages")
|
|
70
|
+
for channel in channels:
|
|
71
|
+
channel_id = channel["id"]
|
|
72
|
+
|
|
73
|
+
self.log.info(f"Scanning {channel_id}...")
|
|
74
|
+
|
|
75
|
+
# get list of messages in channel
|
|
76
|
+
message_cursor = None
|
|
77
|
+
messages = []
|
|
78
|
+
while (not messages or message_cursor) and not self.should_exit.is_set():
|
|
79
|
+
result = await self.auto_retry(self.slack_app.client.conversations_history,
|
|
80
|
+
channel=channel_id,
|
|
81
|
+
limit=500,
|
|
82
|
+
cursor=message_cursor,
|
|
83
|
+
oldest=self.config.slack.last_processed_ts
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if not result["messages"]: break
|
|
87
|
+
|
|
88
|
+
messages.extend(result["messages"])
|
|
89
|
+
self.log.info(f"Found {len(result['messages'])} messages")
|
|
90
|
+
if result["has_more"]:
|
|
91
|
+
message_cursor = result["response_metadata"]["next_cursor"]
|
|
92
|
+
else:
|
|
93
|
+
message_cursor = None
|
|
94
|
+
|
|
95
|
+
self.log.info(f"Scanning {len(messages)} messages")
|
|
96
|
+
messages.reverse()
|
|
97
|
+
for message in messages:
|
|
98
|
+
if self.should_exit.is_set():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
message_rid = SlackMessage(team_id, channel_id, message["ts"])
|
|
102
|
+
|
|
103
|
+
if message.get("subtype") is None:
|
|
104
|
+
|
|
105
|
+
message_bundle = Bundle.generate(
|
|
106
|
+
rid=message_rid,
|
|
107
|
+
contents=message
|
|
108
|
+
)
|
|
109
|
+
self.log.info(f"{message_rid}")
|
|
110
|
+
self.kobj_queue.push(bundle=message_bundle)
|
|
111
|
+
|
|
112
|
+
thread_ts = message.get("thread_ts")
|
|
113
|
+
|
|
114
|
+
# ignore threaded messages sent to channel (double counted within thread)
|
|
115
|
+
if thread_ts and (thread_ts != message["ts"]):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if thread_ts:
|
|
119
|
+
threaded_message_cursor = None
|
|
120
|
+
threaded_messages = []
|
|
121
|
+
while (not threaded_messages or threaded_message_cursor) and not self.should_exit.is_set():
|
|
122
|
+
result = await self.auto_retry(self.slack_app.client.conversations_replies,
|
|
123
|
+
channel=channel_id,
|
|
124
|
+
ts=thread_ts,
|
|
125
|
+
limit=500,
|
|
126
|
+
cursor=threaded_message_cursor
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
threaded_messages.extend(result["messages"])
|
|
130
|
+
|
|
131
|
+
if result["has_more"]:
|
|
132
|
+
threaded_message_cursor = result["response_metadata"]["next_cursor"]
|
|
133
|
+
else:
|
|
134
|
+
threaded_message_cursor = None
|
|
135
|
+
|
|
136
|
+
self.log.info(f"{message_rid} thread with {len(threaded_messages)} messages")
|
|
137
|
+
|
|
138
|
+
# don't double count thread parent message
|
|
139
|
+
for threaded_message in threaded_messages[1:]:
|
|
140
|
+
if self.should_exit.is_set():
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
threaded_message_rid = SlackMessage(
|
|
144
|
+
team_id,
|
|
145
|
+
channel_id,
|
|
146
|
+
threaded_message["ts"]
|
|
147
|
+
)
|
|
148
|
+
if threaded_message.get("subtype") is None:
|
|
149
|
+
threaded_message_bundle = Bundle.generate(
|
|
150
|
+
rid=threaded_message_rid,
|
|
151
|
+
contents=threaded_message
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self.log.info(f"{threaded_message_rid}")
|
|
155
|
+
self.kobj_queue.push(bundle=threaded_message_bundle)
|
|
156
|
+
|
|
157
|
+
self.log.info("done")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from rid_lib.types import (
|
|
3
|
+
SlackMessage,
|
|
4
|
+
SlackChannel,
|
|
5
|
+
SlackUser,
|
|
6
|
+
SlackWorkspace
|
|
7
|
+
)
|
|
8
|
+
from koi_net.config import (
|
|
9
|
+
FullNodeConfig,
|
|
10
|
+
KoiNetConfig,
|
|
11
|
+
FullNodeProfile,
|
|
12
|
+
NodeProvides,
|
|
13
|
+
EnvConfig
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SlackEnvConfig(EnvConfig):
|
|
18
|
+
slack_bot_token: str
|
|
19
|
+
slack_signing_secret: str
|
|
20
|
+
slack_app_token: str
|
|
21
|
+
|
|
22
|
+
class SlackConfig(BaseModel):
|
|
23
|
+
allowed_channels: list[str] = []
|
|
24
|
+
last_processed_ts: str = "0"
|
|
25
|
+
|
|
26
|
+
class SlackSensorNodeConfig(FullNodeConfig):
|
|
27
|
+
koi_net: KoiNetConfig = KoiNetConfig(
|
|
28
|
+
node_name="slack-sensor",
|
|
29
|
+
node_profile=FullNodeProfile(
|
|
30
|
+
provides=NodeProvides(
|
|
31
|
+
event=[
|
|
32
|
+
SlackMessage
|
|
33
|
+
],
|
|
34
|
+
state=[
|
|
35
|
+
SlackMessage,
|
|
36
|
+
SlackUser,
|
|
37
|
+
SlackChannel,
|
|
38
|
+
SlackWorkspace
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
env: SlackEnvConfig = Field(default_factory=SlackEnvConfig)
|
|
44
|
+
slack: SlackConfig = SlackConfig()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from koi_net.core import FullNode
|
|
2
|
+
from slack_bolt.async_app import AsyncApp
|
|
3
|
+
|
|
4
|
+
from .backfiller import Backfiller
|
|
5
|
+
from .server import SlackSensorNodeServer
|
|
6
|
+
from .config import SlackSensorNodeConfig
|
|
7
|
+
from .last_processed_ts_handler import LastProcessedTSHandler
|
|
8
|
+
from .slack_event_handler import SlackEventHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SlackSensorNode(FullNode):
|
|
12
|
+
config_schema = SlackSensorNodeConfig
|
|
13
|
+
|
|
14
|
+
slack_app = lambda config: AsyncApp(
|
|
15
|
+
token=config.env.slack_bot_token,
|
|
16
|
+
signing_secret=config.env.slack_signing_secret
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
server = SlackSensorNodeServer
|
|
20
|
+
backfiller = Backfiller
|
|
21
|
+
slack_event_handler = SlackEventHandler
|
|
22
|
+
|
|
23
|
+
last_processed_ts_handler = LastProcessedTSHandler
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from rid_lib import RID
|
|
2
|
+
from rid_lib.types import SlackMessage, SlackChannel, SlackUser, SlackWorkspace
|
|
3
|
+
from rid_lib.ext import Bundle
|
|
4
|
+
from koi_net.protocol.api.models import BundlesPayload
|
|
5
|
+
from slack_bolt.async_app import AsyncApp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Dereferencer:
|
|
9
|
+
def __init__(self, slack_app: AsyncApp):
|
|
10
|
+
self.slack_app = slack_app
|
|
11
|
+
|
|
12
|
+
async def fetch_missing(self, payload: BundlesPayload):
|
|
13
|
+
found_bundles: list[Bundle] = []
|
|
14
|
+
for rid in payload.not_found:
|
|
15
|
+
if type(rid) not in (
|
|
16
|
+
SlackMessage, SlackChannel, SlackUser, SlackWorkspace
|
|
17
|
+
): continue
|
|
18
|
+
|
|
19
|
+
bundle = await self.dereference(rid)
|
|
20
|
+
if bundle:
|
|
21
|
+
found_bundles.append(bundle)
|
|
22
|
+
|
|
23
|
+
for bundle in found_bundles:
|
|
24
|
+
payload.not_found.remove(bundle.rid)
|
|
25
|
+
payload.bundles.append(bundle)
|
|
26
|
+
|
|
27
|
+
return payload
|
|
28
|
+
|
|
29
|
+
async def dereference(self, rid: RID):
|
|
30
|
+
if type(rid) == SlackMessage:
|
|
31
|
+
resp = await self.slack_app.client.conversations_replies(
|
|
32
|
+
channel=rid.channel_id,
|
|
33
|
+
ts=rid.ts
|
|
34
|
+
)
|
|
35
|
+
message = resp["messages"][0]
|
|
36
|
+
|
|
37
|
+
return Bundle.generate(rid, message)
|
|
38
|
+
|
|
39
|
+
elif type(rid) == SlackChannel:
|
|
40
|
+
resp = await self.slack_app.client.conversations_info(
|
|
41
|
+
channel=rid.channel_id
|
|
42
|
+
)
|
|
43
|
+
channel = resp["channel"]
|
|
44
|
+
|
|
45
|
+
return Bundle.generate(rid, channel)
|
|
46
|
+
|
|
47
|
+
elif type(rid) == SlackUser:
|
|
48
|
+
profile_resp = await self.slack_app.client.users_profile_get(user=rid.user_id)
|
|
49
|
+
profile = profile_resp["profile"]
|
|
50
|
+
|
|
51
|
+
user_resp = await self.slack_app.client.users_info(user=rid.user_id)
|
|
52
|
+
user = user_resp["user"]
|
|
53
|
+
|
|
54
|
+
user["profile"] = profile
|
|
55
|
+
|
|
56
|
+
return Bundle.generate(rid, user)
|
|
57
|
+
|
|
58
|
+
elif type(rid) == SlackWorkspace:
|
|
59
|
+
resp = await self.slack_app.client.team_info(team=rid.team_id)
|
|
60
|
+
workspace = resp["team"]
|
|
61
|
+
|
|
62
|
+
return Bundle.generate(rid, workspace)
|
|
63
|
+
|
|
64
|
+
else:
|
|
65
|
+
raise TypeError(f"RID of type {type(rid)!r} is not allowed")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from rid_lib.types import SlackMessage
|
|
5
|
+
from koi_net.components.interfaces import HandlerType, KnowledgeHandler
|
|
6
|
+
from koi_net.protocol import KnowledgeObject
|
|
7
|
+
|
|
8
|
+
from .config import SlackSensorNodeConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LastProcessedTSHandler(KnowledgeHandler):
|
|
13
|
+
config: SlackSensorNodeConfig
|
|
14
|
+
|
|
15
|
+
handler_type = HandlerType.RID
|
|
16
|
+
rid_types = (SlackMessage,)
|
|
17
|
+
|
|
18
|
+
def handle(self, kobj: KnowledgeObject):
|
|
19
|
+
msg_rid = cast(SlackMessage, kobj.rid)
|
|
20
|
+
if float(msg_rid.ts) < float(self.config.slack.last_processed_ts):
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
self.config.slack.last_processed_ts = msg_rid.ts
|
|
24
|
+
self.config.save_to_yaml()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from slack_bolt.async_app import AsyncApp
|
|
5
|
+
from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
|
|
6
|
+
from koi_net.components import NodeServer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SlackSensorNodeServer(NodeServer):
|
|
11
|
+
slack_app: AsyncApp
|
|
12
|
+
|
|
13
|
+
def __post_init__(self):
|
|
14
|
+
super().__post_init__()
|
|
15
|
+
|
|
16
|
+
@self.app.post("/slack-event-listener")
|
|
17
|
+
async def slack_listener(request: Request):
|
|
18
|
+
return await AsyncSlackRequestHandler(self.slack_app).handle(request)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from logging import Logger
|
|
3
|
+
|
|
4
|
+
from slack_bolt.async_app import AsyncApp
|
|
5
|
+
from rid_lib.ext import Bundle
|
|
6
|
+
from rid_lib.types import SlackMessage
|
|
7
|
+
from koi_net.core import KobjQueue
|
|
8
|
+
from koi_net.protocol.event import EventType
|
|
9
|
+
|
|
10
|
+
from .config import SlackSensorNodeConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SlackEventHandler:
|
|
15
|
+
log: Logger
|
|
16
|
+
slack_app: AsyncApp
|
|
17
|
+
config: SlackSensorNodeConfig
|
|
18
|
+
kobj_queue: KobjQueue
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
self.register_handlers()
|
|
22
|
+
|
|
23
|
+
def register_handlers(self):
|
|
24
|
+
@self.slack_app.event("message")
|
|
25
|
+
async def handle_message_event(event):
|
|
26
|
+
subtype = event.get("subtype")
|
|
27
|
+
# new message
|
|
28
|
+
if not subtype:
|
|
29
|
+
message_rid = SlackMessage(
|
|
30
|
+
team_id=event["team"],
|
|
31
|
+
channel_id=event["channel"],
|
|
32
|
+
ts=event["ts"]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if message_rid.channel_id not in self.config.slack.allowed_channels:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# normalize to non event message structure
|
|
39
|
+
data = event
|
|
40
|
+
del data["channel"]
|
|
41
|
+
del data["event_ts"]
|
|
42
|
+
del data["channel_type"]
|
|
43
|
+
|
|
44
|
+
msg_bundle = Bundle.generate(
|
|
45
|
+
rid=message_rid,
|
|
46
|
+
contents=data
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.log.info(f"Handling new Slack message {message_rid!r}")
|
|
50
|
+
|
|
51
|
+
self.kobj_queue.push(bundle=msg_bundle)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
elif subtype == "message_changed":
|
|
55
|
+
message_rid = SlackMessage(
|
|
56
|
+
team_id=event["message"]["team"],
|
|
57
|
+
channel_id=event["channel"],
|
|
58
|
+
ts=event["message"]["ts"]
|
|
59
|
+
)
|
|
60
|
+
# normalize to non event message structure
|
|
61
|
+
data = event["message"]
|
|
62
|
+
data.pop("source_team", None)
|
|
63
|
+
data.pop("user_team", None)
|
|
64
|
+
|
|
65
|
+
msg_bundle = Bundle.generate(
|
|
66
|
+
rid=message_rid,
|
|
67
|
+
contents=data
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.log.info(f"Handling updated Slack message {message_rid!r}")
|
|
71
|
+
|
|
72
|
+
self.kobj_queue.push(bundle=msg_bundle)
|
|
73
|
+
|
|
74
|
+
elif subtype == "message_deleted":
|
|
75
|
+
message_rid = SlackMessage(
|
|
76
|
+
team_id=event["previous_message"]["team"],
|
|
77
|
+
channel_id=event["channel"],
|
|
78
|
+
ts=event["previous_message"]["ts"]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.log.info(f"Handling deleted Slack message {message_rid!r}")
|
|
82
|
+
|
|
83
|
+
self.kobj_queue.push(rid=message_rid, event_type=EventType.FORGET)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
self.log.info(f"Ignoring unsupported Slack message subtype {subtype}")
|
|
87
|
+
return
|