roboherd 0.1.2__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.

Potentially problematic release.


This version of roboherd might be problematic. Click here for more details.

roboherd/__init__.py ADDED
File without changes
roboherd/__main__.py ADDED
@@ -0,0 +1,106 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+
5
+ import click
6
+ import dynaconf
7
+ import watchfiles
8
+
9
+ from roboherd.herd import RoboHerd
10
+ from roboherd.herd.manager import HerdManager
11
+ from roboherd.util import create_connection
12
+ from roboherd.register import register as run_register
13
+
14
+ logging.basicConfig(level=logging.INFO)
15
+
16
+
17
+ @click.group()
18
+ @click.option(
19
+ "--connection_string",
20
+ default=None,
21
+ help="Connection string to the websocket mqtt broker",
22
+ )
23
+ @click.option(
24
+ "--base_url",
25
+ default=None,
26
+ help="Base url to create cows with",
27
+ )
28
+ @click.option("--config_file", default="roboherd.toml", help="Configuration file")
29
+ @click.pass_context
30
+ def main(ctx, connection_string, base_url, config_file):
31
+ """Configuration is usually loaded from the config_file. These options can be overwritten by passing as a command line argument."""
32
+ settings = dynaconf.Dynaconf(
33
+ settings_files=[config_file],
34
+ envvar_prefix="ROBOHERD",
35
+ )
36
+ ctx.ensure_object(dict)
37
+
38
+ ctx.obj["config_file"] = config_file
39
+ ctx.obj["settings"] = settings
40
+
41
+ if connection_string:
42
+ ctx.obj["connection_string"] = connection_string
43
+ else:
44
+ ctx.obj["connection_string"] = settings.get("connection_string")
45
+
46
+ if base_url:
47
+ ctx.obj["base_url"] = base_url
48
+ else:
49
+ ctx.obj["base_url"] = settings.get("base_url")
50
+
51
+
52
+ @main.command()
53
+ @click.pass_context
54
+ def run(ctx):
55
+ """Runs the roboherd by connecting to the server."""
56
+
57
+ create_connection(ctx)
58
+
59
+ herd = RoboHerd(base_url=ctx.obj["base_url"])
60
+
61
+ settings = ctx.obj["settings"]
62
+
63
+ if settings.get("cow"):
64
+ herd.manager = HerdManager.from_settings(settings)
65
+ asyncio.run(herd.run(ctx.obj["connection"]))
66
+ else:
67
+ click.echo("No cows specified")
68
+ exit(1)
69
+
70
+
71
+ @main.command()
72
+ @click.pass_context
73
+ def watch(ctx):
74
+ """Watches the file the module is in for changes and then restarts roboherd.
75
+
76
+ Note: Options for roboherd are currently ignored (FIXME)."""
77
+
78
+ watchfiles.run_process("roboherd", target="roboherd run")
79
+
80
+
81
+ @main.command()
82
+ @click.pass_context
83
+ @click.option("--name", help="Name for the account to be created", prompt=True)
84
+ @click.option(
85
+ "--password",
86
+ help="Password for the account to be created",
87
+ hide_input=True,
88
+ prompt=True,
89
+ )
90
+ @click.option("--fediverse", help="Fediverse handle", prompt=True)
91
+ def register(ctx, name, password, fediverse):
92
+ """Registers a new account on dev.bovine.social. All three options are required. If not provided, you will be prompted for them."""
93
+
94
+ if os.path.exists(ctx.obj["config_file"]):
95
+ click.echo("Config file already exists")
96
+ exit(1)
97
+
98
+ if len(password) < 6:
99
+ click.echo("Password should have at least 6 characters")
100
+ exit(1)
101
+
102
+ asyncio.run(run_register(ctx.obj["config_file"], name, password, fediverse))
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,50 @@
1
+ from fast_depends import Depends
2
+ from typing import Annotated, Callable, Awaitable
3
+
4
+ from almabtrieb import Almabtrieb
5
+
6
+
7
+ def get_raw(data: dict) -> dict:
8
+ return data.get("data").get("raw")
9
+
10
+
11
+ def get_parsed(data: dict) -> dict:
12
+ result = data.get("data").get("parsed")
13
+ if result is None:
14
+ raise ValueError("No parsed data found")
15
+ return result
16
+
17
+
18
+ RawData = Annotated[dict, Depends(get_raw)]
19
+ """The raw data as received by cattle_grid"""
20
+
21
+ ParsedData = Annotated[dict, Depends(get_parsed)]
22
+ """The parsed data as transformed by muck_out"""
23
+
24
+
25
+ def get_activity(parsed: ParsedData) -> dict:
26
+ return parsed.get("activity")
27
+
28
+
29
+ def get_embedded_object(parsed: ParsedData) -> dict:
30
+ return parsed.get("embeddedObject")
31
+
32
+
33
+ Activity = Annotated[dict, Depends(get_activity)]
34
+ """The activity parsed by muck_out"""
35
+
36
+ EmbeddedObject = Annotated[dict, Depends(get_embedded_object)]
37
+ """The embedded object in the activity as parsed by muck_out"""
38
+
39
+ Publisher = Callable[[dict], Awaitable[None]]
40
+
41
+
42
+ def construct_publish_object(connection: Almabtrieb, actor_id: str) -> Publisher:
43
+ async def publish(data: dict):
44
+ await connection.trigger("publish_object", {"actor": actor_id, "data": data})
45
+
46
+ return publish
47
+
48
+
49
+ PublishObject = Annotated[Publisher, Depends(construct_publish_object)]
50
+ """Allows one to publish an object as the actor. Assumes cattle_grid has the extension `simple_object_storage` or equivalent"""
@@ -0,0 +1,41 @@
1
+ """Test documentation"""
2
+
3
+ from typing import Annotated
4
+ from fast_depends import Depends
5
+ from .common import Profile
6
+
7
+ try:
8
+ from bovine.activitystreams import factories_for_actor_object
9
+ from bovine.activitystreams.activity_factory import (
10
+ ActivityFactory as BovineActivityFactory,
11
+ )
12
+ from bovine.activitystreams.object_factory import (
13
+ ObjectFactory as BovineObjectFactory,
14
+ )
15
+
16
+ def get_activity_factory(profile: Profile) -> BovineActivityFactory:
17
+ activity_factory, _ = factories_for_actor_object(profile)
18
+ return activity_factory
19
+
20
+ def get_object_factory(profile: Profile) -> BovineObjectFactory:
21
+ _, object_factory = factories_for_actor_object(profile)
22
+ return object_factory
23
+
24
+ except ImportError:
25
+
26
+ class BovineActivityFactory: ...
27
+
28
+ class BovineObjectFactory: ...
29
+
30
+ def get_activity_factory() -> None:
31
+ raise ImportError("bovine not installed")
32
+
33
+ def get_object_factory() -> None:
34
+ raise ImportError("bovine not installed")
35
+
36
+
37
+ ActivityFactory = Annotated[BovineActivityFactory, Depends(get_activity_factory)]
38
+ """The activity factory of type [bovine.activitystreams.activity_factory.ActivityFactory][]"""
39
+
40
+ ObjectFactory = Annotated[BovineObjectFactory, Depends(get_object_factory)]
41
+ """The object factory of type [bovine.activitystreams.object_factory.ObjectFactory][]"""
@@ -0,0 +1,12 @@
1
+ from fast_depends import Depends
2
+ from typing import Annotated
3
+
4
+ from roboherd.cow import RoboCow
5
+
6
+
7
+ def get_profile(cow: RoboCow) -> dict:
8
+ return cow.profile
9
+
10
+
11
+ Profile = Annotated[dict, Depends(get_profile)]
12
+ """The profile of the cow"""
@@ -0,0 +1,211 @@
1
+ import logging
2
+
3
+ from typing import Callable, List
4
+
5
+ from dataclasses import dataclass, field
6
+ from fast_depends import inject
7
+
8
+ from cron_descriptor import get_description
9
+ from almabtrieb import Almabtrieb
10
+
11
+ from .types import Information
12
+ from .handlers import Handlers, HandlerConfiguration
13
+ from .profile import determine_profile_update
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class CronEntry:
20
+ """A cron entry"""
21
+
22
+ crontab: str = field(metadata=dict(description="""The cron expression"""))
23
+
24
+ func: Callable = field(metadata=dict(description="""The function to be called"""))
25
+
26
+
27
+ @dataclass
28
+ class RoboCow:
29
+ information: Information = field(
30
+ metadata=dict(description="Information about the cow")
31
+ )
32
+
33
+ auto_follow: bool = field(
34
+ default=True,
35
+ metadata=dict(
36
+ description="""Whether to automatically accept follow requests"""
37
+ ),
38
+ )
39
+
40
+ profile: dict | None = field(
41
+ default=None,
42
+ metadata=dict(
43
+ description="""The profile of the cow, aka as the actor object in ActivityPub"""
44
+ ),
45
+ )
46
+
47
+ actor_id: str | None = field(
48
+ default=None,
49
+ metadata=dict(description="""Actor Id of the cow; loaded automatically"""),
50
+ )
51
+
52
+ handlers: Handlers = field(
53
+ default_factory=Handlers,
54
+ metadata=dict(
55
+ description="""Handlers for incoming and outgoing messages, added through annotations"""
56
+ ),
57
+ )
58
+ handler_configuration: List[HandlerConfiguration] = field(
59
+ default_factory=list,
60
+ metadata=dict(
61
+ description="""Handler configurations, added through annotations"""
62
+ ),
63
+ )
64
+
65
+ cron_entries: List[CronEntry] = field(
66
+ default_factory=list,
67
+ metadata=dict(description="""Cron entries, created through annotations"""),
68
+ )
69
+
70
+ startup_routine: Callable | None = None
71
+
72
+ def action(self, action: str = "*", activity_type: str = "*"):
73
+ """Adds a handler for an event. Use "*" as a wildcard.
74
+
75
+ Usage:
76
+
77
+ ```python
78
+ cow = Robocow(information=Information(handle="example"))
79
+
80
+ @cow.action(action="outgoing", activity_type="Follow")
81
+ async def handle_outgoing_follow(data):
82
+ ...
83
+ ```
84
+ """
85
+
86
+ config = HandlerConfiguration(
87
+ action=action,
88
+ activity_type=activity_type,
89
+ )
90
+
91
+ def inner(func):
92
+ config.func = func
93
+ self.handlers.add_handler(config, func)
94
+ self.handler_configuration.append(config)
95
+ return func
96
+
97
+ return inner
98
+
99
+ def cron(self, crontab):
100
+ def inner(func):
101
+ self.cron_entries.append(CronEntry(crontab, func))
102
+
103
+ return func
104
+
105
+ return inner
106
+
107
+ def incoming(self, func):
108
+ """Adds a handler for an incoming message. Usage:
109
+
110
+ ```python
111
+ cow = Robocow("example")
112
+
113
+ @cow.incoming
114
+ async def handle_incoming(data):
115
+ ...
116
+ ```
117
+ """
118
+ config = HandlerConfiguration(
119
+ action="incoming",
120
+ activity_type="*",
121
+ )
122
+ self.handlers.add_handler(config, func)
123
+ return func
124
+
125
+ def incoming_create(self, func):
126
+ """Adds a handler for an incoming activity if the
127
+ activity is of type_create
128
+
129
+ ```python
130
+ cow = Robocow("example")
131
+
132
+ @cow.incoming_create
133
+ async def handle_incoming(data):
134
+ ...
135
+ ```
136
+ """
137
+ config = HandlerConfiguration(
138
+ action="incoming", activity_type="Create", func=func
139
+ )
140
+ self.handler_configuration.append(config)
141
+ self.handlers.add_handler(config, func)
142
+ return func
143
+
144
+ def startup(self, func):
145
+ """Adds a startup routine to be run when the cow is started."""
146
+
147
+ self.startup_routine = func
148
+
149
+ def needs_update(self):
150
+ """Checks if the cow needs to be updated"""
151
+ if self.profile is None:
152
+ return True
153
+
154
+ if self.information.name != self.profile.get("name"):
155
+ return True
156
+
157
+ if self.information.description != self.profile.get("summary"):
158
+ return True
159
+
160
+ return False
161
+
162
+ def update_data(self):
163
+ """
164
+ Returns the update_actor message to send to cattle_grid
165
+
166
+ ```pycon
167
+ >>> info = Information(handle="moocow", name="name", description="description")
168
+ >>> cow = RoboCow(information=info, actor_id="http://host.example/actor/1")
169
+ >>> cow.update_data()
170
+ {'actor': 'http://host.example/actor/1',
171
+ 'profile': {'name': 'name',
172
+ 'summary': 'description'},
173
+ 'automaticallyUpdateFollowers': True}
174
+
175
+ ```
176
+ """
177
+ return {
178
+ "actor": self.actor_id,
179
+ "profile": {
180
+ "name": self.information.name,
181
+ "summary": self.information.description,
182
+ },
183
+ "automaticallyUpdateFollowers": self.auto_follow,
184
+ }
185
+
186
+ async def run_startup(self, connection: Almabtrieb):
187
+ """Runs when the cow is birthed"""
188
+
189
+ if self.profile is None:
190
+ result = await connection.fetch(self.actor_id, self.actor_id)
191
+ self.profile = result.data
192
+
193
+ if self.cron_entries:
194
+ frequency = ", ".join(
195
+ get_description(entry.crontab) for entry in self.cron_entries
196
+ )
197
+ self.information.frequency = frequency
198
+
199
+ update = determine_profile_update(self.information, self.profile)
200
+
201
+ if update:
202
+ logger.info("Updating profile for %s", self.information.handle)
203
+
204
+ await connection.trigger("update_actor", update)
205
+
206
+ if self.startup_routine:
207
+ await inject(self.startup_routine)(
208
+ cow=self,
209
+ connection=connection,
210
+ actor_id=self.actor_id,
211
+ )
@@ -0,0 +1,54 @@
1
+ import logging
2
+
3
+ from typing import Callable
4
+ from dataclasses import dataclass
5
+ from collections import defaultdict
6
+ from almabtrieb import Almabtrieb
7
+
8
+ from .util import call_handler, HandlerInformation
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class HandlerConfiguration:
15
+ action: str
16
+ activity_type: str
17
+ func: Callable | None = None
18
+
19
+
20
+ class Handlers:
21
+ def __init__(self):
22
+ self.handler_map = defaultdict(lambda: defaultdict(list))
23
+
24
+ @property
25
+ def has_handlers(self):
26
+ return len(self.handler_map) > 0
27
+
28
+ def add_handler(self, config: HandlerConfiguration, func):
29
+ self.handler_map[config.action][config.activity_type].append(
30
+ HandlerInformation(func=func)
31
+ )
32
+
33
+ async def handle(
34
+ self,
35
+ data: dict,
36
+ event_type: str,
37
+ connection: Almabtrieb,
38
+ actor_id: str | None = None,
39
+ cow=None,
40
+ ):
41
+ activity = data.get("data", {}).get("raw", {})
42
+ data_activity_type = activity.get("type")
43
+
44
+ if actor_id is None:
45
+ logger.warning("Skipping handlers due to missing actor_id")
46
+ return
47
+
48
+ for action in [event_type, "*"]:
49
+ for activity_type in [data_activity_type, "*"]:
50
+ handlers = self.handler_map[action][activity_type]
51
+ for handler_info in handlers:
52
+ await call_handler(
53
+ handler_info, data, connection, actor_id=actor_id, cow=cow
54
+ )
@@ -0,0 +1,113 @@
1
+ from urllib.parse import urlparse
2
+ from bovine.activitystreams.utils import as_list
3
+
4
+ from .types import Information
5
+
6
+
7
+ def profile_part_needs_update(information: Information, profile: dict) -> bool:
8
+ if information.name != profile.get("name"):
9
+ return True
10
+
11
+ if information.description != profile.get("summary"):
12
+ return True
13
+
14
+ if information.type != profile.get("type"):
15
+ return True
16
+
17
+ return False
18
+
19
+
20
+ def key_index_from_attachment(attachments: list[dict], key: str) -> int | None:
21
+ for idx, attachment in enumerate(attachments):
22
+ if attachment is None:
23
+ continue
24
+ if attachment.get("type") == "PropertyValue" and attachment.get("name") == key:
25
+ return idx
26
+ return None
27
+
28
+
29
+ def determine_action_for_key_and_value(
30
+ attachments: list[dict], key: str, value: str
31
+ ) -> dict | None:
32
+ idx = key_index_from_attachment(attachments, key)
33
+ if idx is None:
34
+ if value:
35
+ return {
36
+ "action": "update_property_value",
37
+ "key": key,
38
+ "value": value,
39
+ }
40
+ return None
41
+
42
+ if value is None:
43
+ return {
44
+ "action": "remove_property_value",
45
+ "key": key,
46
+ }
47
+ current_value = attachments[idx].get("value")
48
+ if value != current_value:
49
+ return {
50
+ "action": "update_property_value",
51
+ "key": key,
52
+ "value": value,
53
+ }
54
+
55
+ return None
56
+
57
+
58
+ def determine_actions(information: Information, profile: dict) -> list[dict] | None:
59
+ attachments = as_list(profile.get("attachment", []))
60
+ meta_information = information.meta_information
61
+
62
+ actions = [
63
+ determine_action_for_key_and_value(
64
+ attachments, "Author", meta_information.author
65
+ ),
66
+ determine_action_for_key_and_value(
67
+ attachments, "Source", meta_information.source
68
+ ),
69
+ determine_action_for_key_and_value(
70
+ attachments, "Frequency", information.frequency
71
+ ),
72
+ ]
73
+
74
+ if information.handle and profile.get("preferredUsername") is None:
75
+ actions.append(
76
+ {
77
+ "action": "add_identifier",
78
+ "identifier": "acct:"
79
+ + information.handle
80
+ + "@"
81
+ + urlparse(profile.get("id")).netloc,
82
+ "primary": True,
83
+ }
84
+ )
85
+
86
+ actions = list(filter(lambda x: x, actions))
87
+
88
+ if len(actions) == 0:
89
+ return None
90
+ return actions
91
+
92
+
93
+ def determine_profile_update(information: Information, profile: dict) -> dict:
94
+ """Returns the update for the profile"""
95
+
96
+ update = {"actor": profile.get("id")}
97
+
98
+ if profile_part_needs_update(information, profile):
99
+ update["profile"] = {
100
+ "type": information.type,
101
+ "name": information.name,
102
+ "summary": information.description,
103
+ }
104
+
105
+ actions = determine_actions(information, profile)
106
+
107
+ if actions:
108
+ update["actions"] = actions
109
+
110
+ if len(update) == 1:
111
+ return None
112
+
113
+ return update
@@ -0,0 +1,76 @@
1
+ import pytest
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ from almabtrieb.mqtt import MqttConnection
6
+
7
+ from .handlers import Handlers, HandlerConfiguration
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ "action,activity_type",
12
+ [
13
+ ("incoming", "AnimalSound"),
14
+ ("incoming", "*"),
15
+ ("*", "AnimalSound"),
16
+ ("*", "*"),
17
+ ],
18
+ )
19
+ async def test_handlers_should_run(action, activity_type):
20
+ handlers = Handlers()
21
+ connection = AsyncMock(MqttConnection)
22
+
23
+ mock = AsyncMock()
24
+
25
+ handlers.add_handler(
26
+ HandlerConfiguration(action=action, activity_type=activity_type), mock
27
+ )
28
+ await handlers.handle(
29
+ {"data": {"raw": {"type": "AnimalSound"}}},
30
+ "incoming",
31
+ connection,
32
+ actor_id="actor_id",
33
+ )
34
+
35
+ mock.assert_awaited_once()
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "action,activity_type",
40
+ [
41
+ ("outgoing", "AnimalSound"),
42
+ ("outgoing", "*"),
43
+ ("*", "Create"),
44
+ ("incoming", "Create"),
45
+ ],
46
+ )
47
+ async def test_handlers_should_nod_run(action, activity_type):
48
+ handlers = Handlers()
49
+ connection = AsyncMock(MqttConnection)
50
+
51
+ mock = AsyncMock()
52
+
53
+ handlers.add_handler(
54
+ HandlerConfiguration(action=action, activity_type=activity_type), mock
55
+ )
56
+
57
+ await handlers.handle(
58
+ {"data": {"raw": {"type": "AnimalSound"}}},
59
+ "incoming",
60
+ connection,
61
+ actor_id="actor_id",
62
+ )
63
+
64
+ mock.assert_not_awaited()
65
+
66
+
67
+ def test_has_handlers():
68
+ handlers = Handlers()
69
+
70
+ assert not handlers.has_handlers
71
+
72
+ handlers.add_handler(
73
+ HandlerConfiguration(action="outgoing", activity_type="Create"), AsyncMock()
74
+ )
75
+
76
+ assert handlers.has_handlers