koi-net-ask-topic-groups-node 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,3 @@
1
+ from .core import AskTopicGroupsNode
2
+
3
+ AskTopicGroupsNode().run()
@@ -0,0 +1,37 @@
1
+ from pydantic import BaseModel, Field
2
+ from rid_lib.types import KoiNetNode
3
+ from koi_net.config import (
4
+ FullNodeConfig,
5
+ KoiNetConfig,
6
+ ServerConfig,
7
+ FullNodeProfile,
8
+ NodeProvides,
9
+ EnvConfig
10
+ )
11
+
12
+ from .rid_types import AskCoreThread, AskTopicGroup, SlackUserGroup
13
+
14
+
15
+ class SlackEnvConfig(EnvConfig):
16
+ ask_tg_slack_bot_token: str
17
+ ask_tg_slack_signing_secret: str
18
+ ask_tg_slack_app_token: str
19
+ ask_tg_slack_user_token: str
20
+
21
+
22
+ class SlackConfig(BaseModel):
23
+ team_id: str | None = None
24
+
25
+ class AskTopicGroupsConfig(FullNodeConfig):
26
+ env: SlackEnvConfig = Field(default_factory=SlackEnvConfig)
27
+ koi_net: KoiNetConfig = KoiNetConfig(
28
+ node_name="ask-topic-groups", # human readable name for your node
29
+ node_profile=FullNodeProfile(
30
+ provides=NodeProvides(
31
+ event=[AskTopicGroup, SlackUserGroup],
32
+ state=[AskTopicGroup, SlackUserGroup]
33
+ )
34
+ ),
35
+ rid_types_of_interest=[KoiNetNode, AskCoreThread] # RID types this node should subscribe to
36
+ )
37
+ slack: SlackConfig = SlackConfig()
@@ -0,0 +1,35 @@
1
+ from koi_net.core import FullNode
2
+ from slack_bolt import App as SlackApp
3
+ from slack_sdk import WebClient
4
+
5
+ from .slack_event_handler import SlackEventHandler
6
+
7
+ from .knowledge_handlers.slack_usergroup_handler import SlackUserGroupHandler
8
+ from .knowledge_handlers.ask_thread_handler import AskThreadHandler
9
+ from .user_group_sensor import UserGroupSensor
10
+ from .slack_config_manager import SlackConfigManager
11
+ from .slack_command_handler import SlackCommandHandler
12
+ from .socket_mode import SlackSocketMode
13
+ from .config import AskTopicGroupsConfig
14
+
15
+
16
+ class AskTopicGroupsNode(FullNode):
17
+ config_schema = AskTopicGroupsConfig
18
+
19
+ slack_app: SlackApp = lambda config: SlackApp(
20
+ token=config.env.ask_tg_slack_bot_token,
21
+ signing_secret=config.env.ask_tg_slack_signing_secret)
22
+
23
+ slack_admin_client: WebClient = lambda config: WebClient(
24
+ token=config.env.ask_tg_slack_user_token)
25
+
26
+ socket_mode = SlackSocketMode
27
+ slack_command_handler = SlackCommandHandler
28
+ slack_event_handler = SlackEventHandler
29
+ slack_config_manager = SlackConfigManager
30
+
31
+ user_group_sensor = UserGroupSensor
32
+
33
+ # knowledge handlers
34
+ ask_thread_handler = AskThreadHandler
35
+ slack_usergroup_handler = SlackUserGroupHandler
@@ -0,0 +1,65 @@
1
+ from dataclasses import dataclass
2
+
3
+ from koi_net.components import KobjQueue
4
+ from koi_net.components.interfaces import KnowledgeHandler, HandlerType
5
+ from koi_net.protocol.knowledge_object import KnowledgeObject
6
+ from rid_lib.ext import Bundle
7
+ from rid_lib.types import SlackMessage
8
+ from slack_bolt import App
9
+
10
+ from ..models import ThreadLinkModel
11
+ from ..rid_types import AskCoreThread, ThreadLink
12
+
13
+
14
+ @dataclass
15
+ class AskThreadHandler(KnowledgeHandler):
16
+ slack_app: App
17
+ kobj_queue: KobjQueue
18
+
19
+ handler_type = HandlerType.Network
20
+ rid_types = (AskCoreThread,)
21
+
22
+ def ensure_bot_in_channel(self, channel_id: str):
23
+ try:
24
+ result = self.slack_app.client.conversations_info(channel=channel_id)
25
+ channel = result["channel"]
26
+
27
+ if not channel.get("is_member", False):
28
+ self.slack_app.client.conversations_join(channel=channel_id)
29
+ self.log.info(f"Joined channel #{channel_id}")
30
+
31
+ except Exception as e:
32
+ self.log.error(f"Unhandled exception joining channel: {e}")
33
+
34
+ def handle(self, kobj: KnowledgeObject):
35
+ thread: AskCoreThread = kobj.rid
36
+
37
+ self.ensure_bot_in_channel(channel_id=thread.channel_id)
38
+
39
+ result = self.slack_app.client.chat_postMessage(
40
+ channel=thread.channel_id,
41
+ thread_ts=thread.ts,
42
+ blocks=[
43
+ {
44
+ "type": "section",
45
+ "text": {
46
+ "type": "mrkdwn",
47
+ "text": "React to this message to tag a topic group!"
48
+ }
49
+ }
50
+ ]
51
+ )
52
+ slack_msg = SlackMessage(
53
+ team_id=thread.team_id,
54
+ channel_id=thread.channel_id,
55
+ ts=result["ts"]
56
+ )
57
+
58
+ thread_link = ThreadLinkModel(thread=thread, message=slack_msg)
59
+ self.kobj_queue.push(bundle=Bundle.generate(
60
+ rid=ThreadLink(slack_msg.team_id, slack_msg.channel_id, slack_msg.ts),
61
+ contents=thread_link.model_dump()
62
+ ))
63
+
64
+
65
+
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+
3
+ from koi_net.components import Cache, KobjQueue
4
+ from koi_net.components.interfaces import KnowledgeHandler, HandlerType
5
+ from koi_net.protocol.knowledge_object import KnowledgeObject
6
+ from rid_lib.ext import Bundle
7
+ from rid_lib.types import SlackUser, SlackUserGroup
8
+ from slack_bolt import App
9
+
10
+ from ..models import TopicGroupModel
11
+ from ..rid_types import AskTopicGroup
12
+
13
+
14
+ @dataclass
15
+ class SlackUserGroupHandler(KnowledgeHandler):
16
+ slack_app: App
17
+ cache: Cache
18
+ kobj_queue: KobjQueue
19
+
20
+ handler_type = HandlerType.Network
21
+ rid_types = (SlackUserGroup,)
22
+
23
+ def handle(self, kobj: KnowledgeObject):
24
+ ug_rid: SlackUserGroup = kobj.rid
25
+ ug_handle: str = kobj.contents["handle"]
26
+ ug_name: str = kobj.contents["name"]
27
+ ug_users: list[str] = kobj.contents["users"]
28
+ ug_description: str = kobj.contents["description"]
29
+
30
+ if not ug_handle.startswith("tg-"):
31
+ return
32
+
33
+ tg_rid = AskTopicGroup(ug_rid.team_id, ug_rid.subteam_id)
34
+
35
+ emoji_pattern = "emoji:"
36
+ emoji_index = ug_description.find(emoji_pattern)
37
+
38
+ emoji_str = None
39
+ if emoji_index >= 0:
40
+ emoji_str = ug_description[emoji_index + len(emoji_pattern):].split()[0]
41
+
42
+ user_rids = [SlackUser(ug_rid.team_id, user_id) for user_id in ug_users]
43
+
44
+ bundle = self.cache.read(tg_rid)
45
+ if bundle:
46
+ topic_group = bundle.validate_contents(TopicGroupModel)
47
+ else:
48
+ topic_group = TopicGroupModel.model_construct()
49
+
50
+ topic_group.usergroup = ug_rid
51
+ topic_group.handle = ug_handle
52
+ topic_group.name = ug_name
53
+ topic_group.emoji = emoji_str
54
+ topic_group.users = user_rids
55
+
56
+ tg_bundle = Bundle.generate(
57
+ rid=tg_rid,
58
+ contents=topic_group.model_dump()
59
+ )
60
+ self.kobj_queue.push(bundle=tg_bundle)
61
+
62
+
@@ -0,0 +1,18 @@
1
+ from pydantic import BaseModel
2
+ from rid_lib.types import SlackMessage, SlackUser
3
+
4
+ from .rid_types import AskCoreThread, SlackUserGroup
5
+
6
+
7
+ class TopicGroupModel(BaseModel):
8
+ usergroup: SlackUserGroup
9
+ handle: str
10
+ name: str
11
+ emoji: str | None = None
12
+ users: list[SlackUser] = []
13
+ threads: list[AskCoreThread] = []
14
+
15
+ class ThreadLinkModel(BaseModel):
16
+ thread: AskCoreThread
17
+ message: SlackMessage
18
+
@@ -0,0 +1,14 @@
1
+ from rid_lib.types import SlackMessage, SlackUserGroup
2
+
3
+
4
+ class AskCoreThread(SlackMessage):
5
+ namespace = "ask-core.thread"
6
+
7
+ class AskCoreResponse(SlackMessage):
8
+ namespace = "ask-core.response"
9
+
10
+ class ThreadLink(SlackMessage):
11
+ namespace = "ask-tg.thread-link"
12
+
13
+ class AskTopicGroup(SlackUserGroup):
14
+ namespace = "ask-tg.topic-group"
@@ -0,0 +1,88 @@
1
+ from dataclasses import dataclass
2
+ from logging import Logger
3
+ import re
4
+
5
+ from slack_bolt import App, Respond
6
+ from slack_sdk import WebClient
7
+
8
+ from .config import AskTopicGroupsConfig
9
+
10
+
11
+ @dataclass
12
+ class SlackCommandHandler:
13
+ log: Logger
14
+ slack_app: App
15
+ slack_admin_client: WebClient
16
+ config: AskTopicGroupsConfig
17
+
18
+ def __post_init__(self):
19
+ self.register_handlers()
20
+
21
+ def register_handlers(self):
22
+ self.slack_app.command("/join-topic")(self.topic_group_join)
23
+ self.slack_app.command("/leave-topic")(self.topic_group_leave)
24
+
25
+ def get_user_group(self, ug_id: str):
26
+ resp = self.slack_app.client.usergroups_list()
27
+ usergroups = resp["usergroups"]
28
+
29
+ for usergroup in usergroups:
30
+ if usergroup["id"] == ug_id:
31
+ return usergroup
32
+
33
+ def topic_group_join(self, ack, respond: Respond, command):
34
+ ack()
35
+
36
+ user_id = command["user_id"]
37
+ text = command['text']
38
+
39
+ capture_pattern = re.compile(r'<!subteam\^(S[A-Z0-9]+)\|@[^>]+>')
40
+ ug_id = capture_pattern.findall(text)[0]
41
+
42
+ usergroup = self.get_user_group(ug_id)
43
+ ug_name: str = usergroup["name"]
44
+ ug_handle: str = usergroup["handle"]
45
+
46
+ if ug_handle.startswith("tg-"):
47
+ ug_users: list[str] = self.slack_app.client.usergroups_users_list(usergroup=ug_id)['users']
48
+
49
+ if user_id not in ug_users:
50
+ ug_users.append(user_id)
51
+ self.slack_admin_client.usergroups_users_update(usergroup=ug_id, users=ug_users)
52
+ respond(f"Joined {ug_name}!")
53
+
54
+ else:
55
+ respond(f"You are already a member of {ug_name}")
56
+ else:
57
+ respond(f"{ug_handle} is not a topic group")
58
+
59
+ def topic_group_leave(self, ack, respond: Respond, command):
60
+ ack()
61
+
62
+ user_id = command["user_id"]
63
+ text = command['text']
64
+
65
+ capture_pattern = re.compile(r'<!subteam\^(S[A-Z0-9]+)\|@[^>]+>')
66
+ ug_id = capture_pattern.findall(text)[0]
67
+
68
+ usergroup = self.get_user_group(ug_id)
69
+ ug_name: str = usergroup["name"]
70
+ ug_handle: str = usergroup["handle"]
71
+
72
+ if ug_handle.startswith("tg-"):
73
+ ug_users: list[str] = self.slack_app.client.usergroups_users_list(usergroup=ug_id)['users']
74
+
75
+ if user_id in ug_users:
76
+ ug_users.remove(user_id)
77
+
78
+ if len(ug_users) > 0:
79
+ self.slack_admin_client.usergroups_users_update(usergroup=ug_id, users=ug_users or "")
80
+ respond(f"Left {ug_name}")
81
+ else:
82
+ respond("Cannot leave group, you are the only user!")
83
+ else:
84
+ respond(f"You are not a member of {ug_name}")
85
+ else:
86
+ respond(f"{ug_handle} is not a valid topic group")
87
+
88
+
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from logging import Logger
3
+
4
+ from slack_bolt import App
5
+
6
+ from .config import AskTopicGroupsConfig
7
+
8
+
9
+ @dataclass
10
+ class SlackConfigManager:
11
+ slack_app: App
12
+ config: AskTopicGroupsConfig
13
+ log: Logger
14
+
15
+ def start(self):
16
+ resp = self.slack_app.client.auth_test()
17
+ team_id = resp.get("team_id")
18
+ user_id = resp.get("user_id")
19
+
20
+ if not team_id or not user_id:
21
+ raise RuntimeError("Slack auth test failed")
22
+
23
+ self.config.slack.team_id = team_id
24
+ self.config.save_to_yaml()
25
+
26
+ self.log.info(f"Set team id to {team_id}")
27
+
@@ -0,0 +1,84 @@
1
+ from dataclasses import dataclass
2
+ from logging import Logger
3
+
4
+ from rid_lib.ext import Bundle
5
+ from slack_bolt import App
6
+ from slack_sdk import WebClient
7
+ from koi_net.components import Cache, KobjQueue
8
+
9
+ from .config import AskTopicGroupsConfig
10
+ from .models import ThreadLinkModel, TopicGroupModel
11
+ from .rid_types import AskTopicGroup, ThreadLink
12
+
13
+
14
+ @dataclass
15
+ class SlackEventHandler:
16
+ log: Logger
17
+ slack_app: App
18
+ slack_admin_client: WebClient
19
+ kobj_queue: KobjQueue
20
+ config: AskTopicGroupsConfig
21
+ cache: Cache
22
+
23
+ def __post_init__(self):
24
+ self.register_handlers()
25
+
26
+ def register_handlers(self):
27
+ self.slack_app.event("reaction_added")(self.handle_reaction_added)
28
+ self.slack_app.event("reaction_removed")(self.handle_reaction_removed)
29
+
30
+ def handle_reaction_added(self, event: dict):
31
+ self.log.info(f"reactions added: {event}")
32
+
33
+ item = event["item"]
34
+ if item["type"] != "message":
35
+ return
36
+
37
+ # reaction emoji -> user group -> thread link -> thread -> topic group model
38
+
39
+ found_match = False
40
+ for topic_group_rid in self.cache.list_rids(rid_types=[AskTopicGroup]):
41
+ bundle = self.cache.read(topic_group_rid)
42
+ if not bundle:
43
+ continue
44
+
45
+ topic_group = bundle.validate_contents(TopicGroupModel)
46
+
47
+ if topic_group.emoji == event["reaction"]:
48
+ found_match = True
49
+ break
50
+
51
+ if not found_match:
52
+ return
53
+
54
+ self.log.info(f"Matched reaction to {topic_group_rid}")
55
+
56
+ thread_link_rid = ThreadLink(
57
+ team_id=self.config.slack.team_id,
58
+ channel_id=item["channel"],
59
+ ts=item["ts"]
60
+ )
61
+
62
+ bundle = self.cache.read(thread_link_rid)
63
+ if not bundle:
64
+ return
65
+
66
+ thread_link = bundle.validate_contents(ThreadLinkModel)
67
+
68
+ topic_group.threads.append(thread_link.thread)
69
+
70
+ self.log.info(f"Appending thread {thread_link.thread}")
71
+
72
+ self.kobj_queue.push(bundle=Bundle.generate(
73
+ rid=topic_group_rid,
74
+ contents=topic_group.model_dump()
75
+ ))
76
+
77
+ self.slack_app.client.chat_postMessage(
78
+ channel=thread_link.thread.channel_id,
79
+ thread_ts=thread_link.thread.ts,
80
+ text=f"Inviting {topic_group.usergroup.mention} to participate!"
81
+ )
82
+
83
+ def handle_reaction_removed(self, event: dict):
84
+ self.log.info(f"reactions removed: {event}")
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass, field
2
+ from slack_bolt import App
3
+ from slack_bolt.adapter.socket_mode import SocketModeHandler
4
+
5
+ from .config import AskTopicGroupsConfig
6
+
7
+
8
+ @dataclass
9
+ class SlackSocketMode:
10
+ slack_app: App
11
+ config: AskTopicGroupsConfig
12
+ handler: SocketModeHandler | None = field(init=False, default=None)
13
+
14
+ def start(self):
15
+ self.handler = SocketModeHandler(
16
+ app=self.slack_app,
17
+ app_token=self.config.env.ask_tg_slack_app_token)
18
+ self.handler.connect()
19
+
20
+ def stop(self):
21
+ if self.handler:
22
+ self.handler.close()
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+ from logging import Logger
3
+ from rid_lib.ext import Bundle
4
+ from slack_bolt import App
5
+ from koi_net.components import KobjQueue
6
+
7
+ from .rid_types import SlackUserGroup
8
+
9
+
10
+ @dataclass
11
+ class UserGroupSensor:
12
+ log: Logger
13
+ slack_app: App
14
+ kobj_queue: KobjQueue
15
+
16
+ def __post_init__(self):
17
+ self.register_handlers()
18
+
19
+ def register_handlers(self):
20
+ self.slack_app.event("subteam_updated")(self.handle_usergroup_update)
21
+
22
+ def handle_usergroup_update(self, event: dict):
23
+ usergroup = event["subteam"]
24
+ ug_rid = SlackUserGroup(usergroup["team_id"], usergroup["id"])
25
+ self.log.info(f"Detected update for {ug_rid}")
26
+
27
+ self.kobj_queue.push(bundle=Bundle.generate(
28
+ rid=ug_rid,
29
+ contents=usergroup
30
+ ))
31
+
32
+ def backfill_usergroups(self):
33
+ resp = self.slack_app.client.usergroups_list()
34
+ usergroups = resp.data["usergroups"]
35
+
36
+ for usergroup in usergroups:
37
+ ug_rid = SlackUserGroup(usergroup["team_id"], usergroup["id"])
38
+ self.log.info(f"Processing {ug_rid}")
39
+
40
+ resp = self.slack_app.client.usergroups_users_list(usergroup=usergroup["id"])
41
+ usergroup["users"] = resp.data["users"]
42
+
43
+ self.kobj_queue.push(bundle=Bundle.generate(
44
+ rid=ug_rid,
45
+ contents=usergroup
46
+ ))
47
+
48
+ def start(self):
49
+ self.backfill_usergroups()
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: koi-net-ask-topic-groups-node
3
+ Version: 0.1.0
4
+ Summary: Topic groups KOI-net node for the ask network application.
5
+ Author-email: Luke Miller <luke@metagov.org>
6
+ Project-URL: Homepage, https://github.com/metagov/koi-net-ask-topic-groups-node
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: koi-net~=2.0
10
+ Requires-Dist: rid-lib>=3.2.16
11
+ Requires-Dist: slack-bolt>=1.27.0
12
+
13
+ # Ask Topic Groups Node
14
+
15
+ This is the topic groups node in the Ask KOI network application. It's responsible for the topic groups feature, allowing users to leave and join groups of their choice. It prompts thread participants to tag related topic groups to link them, and invite members to participate.
@@ -0,0 +1,17 @@
1
+ koi_net_ask_topic_groups_node/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ koi_net_ask_topic_groups_node/__main__.py,sha256=BVN9ou4zhiMwSEpt9pJgkorAGm1bQHI5U2vsNPfdPIA,64
3
+ koi_net_ask_topic_groups_node/config.py,sha256=9RjeheFACEFSMCOfukaciWOOcB_fytRaXfsB9ij1xtk,1099
4
+ koi_net_ask_topic_groups_node/core.py,sha256=Es7r7uZLhNeGNdJ6QjlRTkdhXM0XqwrITYe3Pv50O14,1250
5
+ koi_net_ask_topic_groups_node/models.py,sha256=RtMO7HFDofTDo--fDirnNL35sCfmmrQz5F5TRJ3pkZ8,422
6
+ koi_net_ask_topic_groups_node/rid_types.py,sha256=EZuTDHmr5UpepY54odqjJrLed-dPrUEW-bJjV4-s7MI,348
7
+ koi_net_ask_topic_groups_node/slack_command_handler.py,sha256=01CM1xwp9plthNDgD9CwdX_g5Mp7sAyE-nvlxPvZk6A,2968
8
+ koi_net_ask_topic_groups_node/slack_config_manager.py,sha256=z5yqmILfVvJow7K-rNrQGqYD8C46uQXxMoUJYLzOc3M,650
9
+ koi_net_ask_topic_groups_node/slack_event_handler.py,sha256=JXpIyMoudUIGOUNeGyNOhI_8QdeqBE_EsSt8iC6bai0,2630
10
+ koi_net_ask_topic_groups_node/socket_mode.py,sha256=hnfzN0oaq057IUn3rszlTFig7Ede0TadSeVcp9eHgBA,611
11
+ koi_net_ask_topic_groups_node/user_group_sensor.py,sha256=1LEdrFNgr47irUEOSPnPaXP5F0By5bU9Mho_mEECIaQ,1501
12
+ koi_net_ask_topic_groups_node/knowledge_handlers/ask_thread_handler.py,sha256=Ubo9TBl7F87Pd-k95RUgQXL_coVQ1lkMeNAUpGi-2-o,2120
13
+ koi_net_ask_topic_groups_node/knowledge_handlers/slack_usergroup_handler.py,sha256=yZcFDHFIzFgeUonOjfPpMXIiOFj5nesUUtL4ckKyET8,1976
14
+ koi_net_ask_topic_groups_node-0.1.0.dist-info/METADATA,sha256=TnjwP6ZLewigE90B6XULGrqaMpofIDAUBcaqrVfzHcE,727
15
+ koi_net_ask_topic_groups_node-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ koi_net_ask_topic_groups_node-0.1.0.dist-info/top_level.txt,sha256=tA93Vvix2p2StfY2LEaHNE5MJHsn7q6csUaiauvHVfU,30
17
+ koi_net_ask_topic_groups_node-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ koi_net_ask_topic_groups_node