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 +0 -0
- roboherd/__main__.py +106 -0
- roboherd/annotations/__init__.py +50 -0
- roboherd/annotations/bovine.py +41 -0
- roboherd/annotations/common.py +12 -0
- roboherd/cow/__init__.py +211 -0
- roboherd/cow/handlers.py +54 -0
- roboherd/cow/profile.py +113 -0
- roboherd/cow/test_handlers.py +76 -0
- roboherd/cow/test_init.py +56 -0
- roboherd/cow/test_profile.py +49 -0
- roboherd/cow/test_util.py +17 -0
- roboherd/cow/types.py +58 -0
- roboherd/cow/util.py +23 -0
- roboherd/examples/__init__.py +0 -0
- roboherd/examples/dev_null.py +13 -0
- roboherd/examples/json_echo.py +56 -0
- roboherd/examples/meta.py +5 -0
- roboherd/examples/moocow.py +39 -0
- roboherd/examples/number.py +72 -0
- roboherd/examples/rooster.py +22 -0
- roboherd/examples/scarecrow.py +21 -0
- roboherd/herd/__init__.py +94 -0
- roboherd/herd/builder.py +21 -0
- roboherd/herd/manager/__init__.py +46 -0
- roboherd/herd/manager/config.py +54 -0
- roboherd/herd/manager/test_config.py +65 -0
- roboherd/herd/manager/test_manager.py +42 -0
- roboherd/herd/processor.py +39 -0
- roboherd/herd/scheduler.py +50 -0
- roboherd/herd/test_herd.py +37 -0
- roboherd/herd/test_scheduler.py +17 -0
- roboherd/herd/types.py +12 -0
- roboherd/register.py +38 -0
- roboherd/test_util.py +24 -0
- roboherd/util.py +116 -0
- roboherd-0.1.2.dist-info/METADATA +23 -0
- roboherd-0.1.2.dist-info/RECORD +40 -0
- roboherd-0.1.2.dist-info/WHEEL +4 -0
- roboherd-0.1.2.dist-info/entry_points.txt +2 -0
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][]"""
|
roboherd/cow/__init__.py
ADDED
|
@@ -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
|
+
)
|
roboherd/cow/handlers.py
ADDED
|
@@ -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
|
+
)
|
roboherd/cow/profile.py
ADDED
|
@@ -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
|