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.
@@ -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,10 @@
1
+ rid_cache
2
+ identity.json
3
+ events_queue.json
4
+ venv
5
+ .env
6
+ *.json
7
+ __pycache__
8
+ config.yaml
9
+ priv_key.pem
10
+ *.ndjson
@@ -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,2 @@
1
+ # koi-net-slack-sensor-node
2
+ 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/"
@@ -0,0 +1,3 @@
1
+ from .core import SlackSensorNode
2
+
3
+ SlackSensorNode().run()
@@ -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