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.

@@ -0,0 +1,56 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock
3
+
4
+ from . import RoboCow
5
+ from .types import Information
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ "name,summary,profile,expected",
10
+ [
11
+ ("moocow", None, None, True),
12
+ ("moocow", None, {"id": "123"}, True),
13
+ ("moocow", None, {"id": "123", "name": "moocow"}, False),
14
+ ("moocow", None, {"id": "123", "name": "other"}, True),
15
+ ("moocow", "description", {"id": "123", "name": "moocow"}, True),
16
+ (
17
+ "moocow",
18
+ "description",
19
+ {"id": "123", "name": "moocow", "summary": "description"},
20
+ False,
21
+ ),
22
+ ],
23
+ )
24
+ def test_needs_update(name, summary, profile, expected):
25
+ info = Information(
26
+ handle="testcow",
27
+ name=name,
28
+ description=summary,
29
+ )
30
+ cow = RoboCow(information=info, profile=profile)
31
+
32
+ assert cow.needs_update() == expected
33
+
34
+
35
+ def test_cron():
36
+ info = Information(handle="testcow")
37
+ cow = RoboCow(information=info)
38
+
39
+ @cow.cron("* * * * *")
40
+ async def test_func():
41
+ pass
42
+
43
+ assert len(cow.cron_entries) == 1
44
+
45
+
46
+ async def test_startup():
47
+ info = Information(handle="testcow")
48
+ cow = RoboCow(information=info)
49
+ cow.profile = {"id": "http://host.test/actor/cow"}
50
+ mock = AsyncMock()
51
+
52
+ cow.startup(mock)
53
+
54
+ await cow.run_startup(AsyncMock())
55
+
56
+ mock.assert_called_once()
@@ -0,0 +1,49 @@
1
+ import pytest
2
+
3
+ from .types import Information, MetaInformation
4
+
5
+ from .profile import determine_profile_update
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ "info_params, profile",
10
+ [
11
+ ({}, {"type": "Service"}),
12
+ ({"name": "name"}, {"type": "Service", "name": "name"}),
13
+ ({"description": "description"}, {"type": "Service", "summary": "description"}),
14
+ ],
15
+ )
16
+ def test_determine_profile_update_no_update(info_params, profile):
17
+ info = Information(**info_params)
18
+
19
+ assert determine_profile_update(info, profile) is None
20
+
21
+
22
+ def test_determine_profile_update():
23
+ info = Information(name="name", description="description")
24
+ profile = {"id": "http://host.test/actor/1"}
25
+
26
+ result = determine_profile_update(info, profile)
27
+
28
+ assert result == {
29
+ "actor": "http://host.test/actor/1",
30
+ "profile": {"type": "Service", "name": "name", "summary": "description"},
31
+ }
32
+
33
+
34
+ def test_determine_profile_update_author():
35
+ info = Information(meta_information=MetaInformation(author="acct:author@host.test"))
36
+ profile = {"id": "http://host.test/actor/1", "type": "Service"}
37
+
38
+ result = determine_profile_update(info, profile)
39
+
40
+ assert result == {
41
+ "actor": "http://host.test/actor/1",
42
+ "actions": [
43
+ {
44
+ "action": "update_property_value",
45
+ "key": "Author",
46
+ "value": "acct:author@host.test",
47
+ }
48
+ ],
49
+ }
@@ -0,0 +1,17 @@
1
+ from unittest.mock import AsyncMock
2
+ from almabtrieb.mqtt import MqttConnection
3
+
4
+ from .util import HandlerInformation, call_handler
5
+
6
+
7
+ async def test_call_handler():
8
+ test_data = {"test": "data"}
9
+
10
+ async def func(data: dict):
11
+ assert data == test_data
12
+
13
+ handler_info = HandlerInformation(func=func)
14
+
15
+ connection = AsyncMock(MqttConnection)
16
+
17
+ await call_handler(handler_info, test_data, connection, "actor_id")
roboherd/cow/types.py ADDED
@@ -0,0 +1,58 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class MetaInformation(BaseModel):
5
+ """Meta Information about the bot. This includes
6
+ information such as the author and the source repository"""
7
+
8
+ source: str | None = Field(
9
+ None,
10
+ examples=["https://forge.example/repo"],
11
+ description="The source repository",
12
+ )
13
+
14
+ author: str | None = Field(
15
+ None,
16
+ examples=["acct:author@domain.example"],
17
+ description="The author, often a Fediverse handle",
18
+ )
19
+
20
+
21
+ class Information(BaseModel):
22
+ """Information about the cow"""
23
+
24
+ type: str = Field(
25
+ "Service", examples=["Service"], description="ActivityPub type of the actor."
26
+ )
27
+
28
+ handle: str | None = Field(
29
+ None,
30
+ examples=["moocow"],
31
+ description="Used as the handle in `acct:handle@domain.example`",
32
+ )
33
+
34
+ name: str | None = Field(
35
+ None,
36
+ examples=["The mooing cow 🐮"],
37
+ description="The display name of the cow",
38
+ )
39
+
40
+ description: str | None = Field(
41
+ None,
42
+ examples=[
43
+ "I'm a cow that moos.",
44
+ """<p>An example bot to illustrate Roboherd</p><p>For more information on RoboHerd, see <a href="https://codeberg.org/bovine/roboherd">its repository</a>.</p>""",
45
+ ],
46
+ description="The description of the cow, used as summary of the actor",
47
+ )
48
+
49
+ frequency: str | None = Field(
50
+ None,
51
+ examples=["daily"],
52
+ description="Frequency of posting. Is set automatically if cron expressions are used.",
53
+ )
54
+
55
+ meta_information: MetaInformation = Field(
56
+ MetaInformation(),
57
+ description="Meta information about the cow, such as the source repository",
58
+ )
roboherd/cow/util.py ADDED
@@ -0,0 +1,23 @@
1
+ from fast_depends import inject
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Dict, Awaitable
5
+
6
+ from almabtrieb import Almabtrieb
7
+
8
+
9
+ @dataclass
10
+ class HandlerInformation:
11
+ func: Callable[[Dict], Awaitable[None]]
12
+
13
+
14
+ async def call_handler(
15
+ handler_info: HandlerInformation,
16
+ data: dict,
17
+ connection: Almabtrieb,
18
+ actor_id: str,
19
+ cow=None,
20
+ ):
21
+ return await inject(handler_info.func)(
22
+ data=data, connection=connection, actor_id=actor_id, cow=cow
23
+ )
File without changes
@@ -0,0 +1,13 @@
1
+ from roboherd.cow import RoboCow
2
+ from roboherd.cow.types import Information
3
+
4
+ from .meta import meta_information
5
+
6
+ bot = RoboCow(
7
+ information=Information(
8
+ name="/dev/null",
9
+ description="""I don't do anything.""",
10
+ handle="devnull",
11
+ meta_information=meta_information,
12
+ )
13
+ )
@@ -0,0 +1,56 @@
1
+ import json
2
+
3
+ from roboherd.cow import RoboCow
4
+ from roboherd.annotations import RawData, PublishObject
5
+ from roboherd.annotations.bovine import ObjectFactory
6
+
7
+ from roboherd.cow.types import Information
8
+ from .meta import meta_information
9
+
10
+ bot = RoboCow(
11
+ information=Information(
12
+ handle="jsonecho",
13
+ name="JSON Echo {}",
14
+ description="""<p>I'm a silly bot that replies to
15
+ you with the JSON as received through a HTTP
16
+ post request by
17
+ <a href="https://codeberg.org/helge/cattle_grid">cattle_grid</a>.</p>
18
+
19
+ """,
20
+ meta_information=meta_information,
21
+ )
22
+ )
23
+
24
+
25
+ def reply_content(raw: dict) -> str:
26
+ """Formats and escapes the JSON data:
27
+
28
+ ```pycon
29
+ >>> reply_content({"html": "<b>bold</b>"})
30
+ '<pre><code>{\\n "html": "&lt;b&gt;bold&lt;/b&gt;"\\n}</code></re>'
31
+
32
+ ```
33
+ """
34
+ json_formatted = (
35
+ json.dumps(raw, indent=2)
36
+ .replace("&", "&amp;")
37
+ .replace("<", "&lt;")
38
+ .replace(">", "&gt;")
39
+ )
40
+ return f"<pre><code>{json_formatted}</code></re>"
41
+
42
+
43
+ @bot.incoming_create
44
+ async def create(
45
+ raw: RawData, publish_object: PublishObject, object_factory: ObjectFactory
46
+ ):
47
+ note = (
48
+ object_factory.reply(
49
+ raw.get("object"),
50
+ content=reply_content(raw),
51
+ )
52
+ .as_public()
53
+ .build()
54
+ )
55
+
56
+ await publish_object(note)
@@ -0,0 +1,5 @@
1
+ from roboherd.cow.types import MetaInformation
2
+
3
+ meta_information = MetaInformation(
4
+ author="acct:helge@mymath.rocks", source="https://codeberg.org/bovine/roboherd"
5
+ )
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from roboherd.cow import RoboCow
4
+ from roboherd.annotations import EmbeddedObject, PublishObject
5
+ from roboherd.cow.types import Information
6
+ from .meta import meta_information
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ moocow = RoboCow(
11
+ information=Information(
12
+ handle="moocow",
13
+ name="The mooing cow 🐮",
14
+ description="""I'm a cow that likes to moo.
15
+
16
+ I also serve as an example for the RoboHerd python tool.
17
+ See <a href="https://codeberg.org/helge/roboherd">codeberg.org</a>.""",
18
+ meta_information=meta_information,
19
+ )
20
+ )
21
+
22
+
23
+ @moocow.incoming_create
24
+ async def on_incoming_create(obj: EmbeddedObject, publisher: PublishObject):
25
+ recipient = obj.get("attributedTo")
26
+
27
+ logger.info("Replying to %s", recipient)
28
+
29
+ obj = {
30
+ "@context": "https://www.w3.org/ns/activitystreams",
31
+ "type": "Note",
32
+ "attributedTo": moocow.actor_id,
33
+ "to": [recipient],
34
+ "cc": ["https://www.w3.org/ns/activitystreams#Public"],
35
+ "content": "moo",
36
+ "inReplyTo": obj.get("id"),
37
+ }
38
+
39
+ await publisher(obj)
@@ -0,0 +1,72 @@
1
+ import random
2
+ from urllib.parse import urlparse
3
+
4
+ from almabtrieb import Almabtrieb
5
+
6
+ from roboherd.cow import RoboCow
7
+ from roboherd.annotations import PublishObject
8
+ from roboherd.annotations.bovine import ObjectFactory
9
+ from roboherd.cow.types import Information
10
+ from .meta import meta_information
11
+
12
+
13
+ def hostname(actor_id):
14
+ return urlparse(actor_id).netloc
15
+
16
+
17
+ bot = RoboCow(
18
+ information=Information(
19
+ handle="even",
20
+ description="""<p>I'm a bot by Helge. I post a random number every hour. When posting an even number, I change my Fediverse handle to even. When posting an odd one, I use odd.</p>
21
+
22
+ <p>I also update my name. I'm not sure how you should display my handle with your Fediverse application. Please write a FEP explaining it.</p>""",
23
+ meta_information=meta_information,
24
+ )
25
+ )
26
+
27
+
28
+ @bot.startup
29
+ async def startup(connection: Almabtrieb, actor_id: str):
30
+ await connection.trigger(
31
+ "update_actor",
32
+ {
33
+ "actor": actor_id,
34
+ "actions": [
35
+ {
36
+ "action": "add_identifier",
37
+ "identifier": f"acct:odd@{hostname(actor_id)}",
38
+ "primary": False,
39
+ }
40
+ ],
41
+ },
42
+ )
43
+
44
+
45
+ @bot.cron("* * * * *")
46
+ async def post_number(
47
+ connection: Almabtrieb,
48
+ publisher: PublishObject,
49
+ factory: ObjectFactory,
50
+ actor_id: str,
51
+ ):
52
+ number = random.randint(0, 1000)
53
+
54
+ note = factory.note(content=f"Number: {number}").as_public().build()
55
+ await publisher(note)
56
+
57
+ handle = "even" if number % 2 == 0 else "odd"
58
+
59
+ await connection.trigger(
60
+ "update_actor",
61
+ {
62
+ "actor": actor_id,
63
+ "actions": [
64
+ {
65
+ "action": "update_identifier",
66
+ "identifier": f"acct:{handle}@{hostname(actor_id)}",
67
+ "primary": True,
68
+ }
69
+ ],
70
+ "profile": {"name": f"Posted an {handle} number"},
71
+ },
72
+ )
@@ -0,0 +1,22 @@
1
+ from roboherd.cow import RoboCow
2
+ from roboherd.cow.types import Information
3
+
4
+ from roboherd.annotations import PublishObject
5
+ from roboherd.annotations.bovine import ObjectFactory
6
+
7
+ from .meta import meta_information
8
+
9
+ bot = RoboCow(
10
+ information=Information(
11
+ handle="rooster",
12
+ name="The crowing rooster 🐓",
13
+ meta_information=meta_information,
14
+ )
15
+ )
16
+
17
+
18
+ @bot.cron("42 * * * *")
19
+ async def crow(publisher: PublishObject, object_factory: ObjectFactory):
20
+ await publisher(
21
+ object_factory.note(content="cock-a-doodle-doo").as_public().build()
22
+ )
@@ -0,0 +1,21 @@
1
+ from roboherd.cow import RoboCow
2
+ from roboherd.cow.types import Information
3
+
4
+ from roboherd.annotations import PublishObject
5
+ from roboherd.annotations.bovine import ObjectFactory
6
+
7
+ from .meta import meta_information
8
+
9
+ bot = RoboCow(
10
+ information=Information(
11
+ name="The scare crow 👩‍🌾",
12
+ description="""On startup I scare crows""",
13
+ handle="scarecrow",
14
+ meta_information=meta_information,
15
+ )
16
+ )
17
+
18
+
19
+ @bot.startup
20
+ async def startup(publish_object: PublishObject, object_factory: ObjectFactory):
21
+ await publish_object(object_factory.note(content="Booo! 🐦").as_public().build())
@@ -0,0 +1,94 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from typing import List, Tuple
5
+ from dataclasses import dataclass, field
6
+
7
+ from roboherd.cow import RoboCow, CronEntry
8
+ from almabtrieb import Almabtrieb
9
+
10
+ from .manager import HerdManager
11
+ from .scheduler import HerdScheduler
12
+ from .processor import HerdProcessor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class RoboHerd:
19
+ name: str = "RoboHerd"
20
+ base_url: str = "http://abel"
21
+
22
+ manager: HerdManager = field(default_factory=HerdManager)
23
+ cows: List[RoboCow] = field(default_factory=list)
24
+
25
+ async def run(self, connection: Almabtrieb):
26
+ async with connection:
27
+ self.validate(connection)
28
+ await self.startup(connection)
29
+ await self.process(connection)
30
+
31
+ async def startup(self, connection: Almabtrieb):
32
+ self.cows = self.manager.existing_cows(connection.information.actors)
33
+
34
+ cows_to_create = self.manager.cows_to_create(connection.information.actors)
35
+
36
+ for cow_config in cows_to_create:
37
+ logger.info("Creating cow with name %s", cow_config.name)
38
+ cow = cow_config.load()
39
+ result = await connection.create_actor(
40
+ name=f"{self.manager.prefix}{cow_config.name}",
41
+ base_url=self.base_url,
42
+ preferred_username=cow.information.handle,
43
+ profile={"type": "Service"},
44
+ automatically_accept_followers=True,
45
+ )
46
+ cow.actor_id = result.get("id")
47
+
48
+ self.cows.append(cow)
49
+
50
+ for cow in self.cows:
51
+ await cow.run_startup(connection)
52
+
53
+ async def process(self, connection: Almabtrieb):
54
+ async with asyncio.TaskGroup() as tg:
55
+ logger.info("Starting processing tasks")
56
+
57
+ processor = HerdProcessor(connection, self.incoming_handlers())
58
+ processor.create_tasks(tg)
59
+
60
+ scheduler = HerdScheduler(self.cron_entries(), connection)
61
+ scheduler.create_task(tg)
62
+
63
+ def introduce(self, cow: RoboCow):
64
+ self.manager.add_to_herd(cow)
65
+
66
+ def validate(self, connection):
67
+ result = connection.information
68
+
69
+ logger.info("Got base urls: %s", ",".join(result.base_urls))
70
+
71
+ if self.base_url not in result.base_urls:
72
+ logger.error(
73
+ "Configure base url %s not in base urls %s of server",
74
+ self.base_url,
75
+ ", ".join(result.base_urls),
76
+ )
77
+ raise ValueError("Incorrectly configured base url")
78
+
79
+ def cron_entries(self) -> List[Tuple[RoboCow, CronEntry]]:
80
+ """Returns the cron entries of all cows"""
81
+
82
+ result = []
83
+ for cow in self.cows:
84
+ for cron_entry in cow.cron_entries:
85
+ result.append((cow, cron_entry))
86
+
87
+ return result
88
+
89
+ def incoming_handlers(self) -> List[RoboCow]:
90
+ result = []
91
+ for cow in self.cows:
92
+ if cow.handlers.has_handlers:
93
+ result.append(cow)
94
+ return result
@@ -0,0 +1,21 @@
1
+ from roboherd.cow import RoboCow
2
+ from roboherd.examples.moocow import moocow # noqa
3
+
4
+
5
+ def create_actor_message_for_cow(cow: RoboCow, base_url):
6
+ """
7
+ ```pycon
8
+ >>> create_actor_message_for_cow(moocow, "http://domain.example/")
9
+ {'baseUrl': 'http://domain.example/',
10
+ 'preferredUsername': 'moocow',
11
+ 'automaticallyAcceptFollowers': True,
12
+ 'profile': {'type': 'Service'}}
13
+
14
+ ```
15
+ """
16
+ return {
17
+ "baseUrl": base_url,
18
+ "preferredUsername": cow.information.handle,
19
+ "automaticallyAcceptFollowers": cow.auto_follow,
20
+ "profile": {"type": "Service"},
21
+ }
@@ -0,0 +1,46 @@
1
+ import logging
2
+
3
+ from typing import List
4
+ from dataclasses import dataclass, field
5
+
6
+ from almabtrieb.model import ActorInformation
7
+
8
+ from roboherd.cow import RoboCow
9
+
10
+ from .config import HerdConfig, CowConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class HerdManager:
17
+ prefix: str = "bot:"
18
+ herd_config: HerdConfig = field(default_factory=HerdConfig)
19
+
20
+ @staticmethod
21
+ def from_settings(settings):
22
+ return HerdManager(herd_config=HerdConfig.from_settings(settings))
23
+
24
+ def existing_cows(self, actors: List[ActorInformation]) -> List[RoboCow]:
25
+ existing_cows = []
26
+
27
+ for info in actors:
28
+ if info.name.startswith(self.prefix):
29
+ cow_name = info.name.removeprefix(self.prefix)
30
+ cow_config = self.herd_config.for_name(cow_name)
31
+ if cow_config:
32
+ cow = cow_config.load()
33
+ cow.actor_id = info.id
34
+ existing_cows.append(cow)
35
+
36
+ return existing_cows
37
+
38
+ def cows_to_create(self, existing_actors: list[ActorInformation]) -> set[CowConfig]:
39
+ existing_names = {
40
+ actor.name.removeprefix(self.prefix)
41
+ for actor in existing_actors
42
+ if actor.name.startswith(self.prefix)
43
+ }
44
+ names_to_create = self.herd_config.names - existing_names
45
+
46
+ return {self.herd_config.for_name(name) for name in names_to_create}
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from roboherd.cow import RoboCow
4
+ from roboherd.util import load_cow
5
+
6
+
7
+ @dataclass
8
+ class CowConfig:
9
+ name: str = field(metadata={"description": "Name of the cow, must be unique"})
10
+ module: str
11
+ attribute: str
12
+
13
+ @staticmethod
14
+ def from_name_and_dict(name, cow: dict) -> "CowConfig":
15
+ module, attribute = cow["bot"].split(":")
16
+
17
+ return CowConfig(
18
+ name=name,
19
+ module=module,
20
+ attribute=attribute,
21
+ )
22
+
23
+ def load(self) -> RoboCow:
24
+ return load_cow(self.module, self.attribute)
25
+
26
+ def __hash__(self):
27
+ return hash(self.name)
28
+
29
+
30
+ @dataclass
31
+ class HerdConfig:
32
+ cows: list[CowConfig] = field(default_factory=list)
33
+
34
+ def for_name(self, name: str) -> CowConfig | None:
35
+ for cow in self.cows:
36
+ if cow.name == name:
37
+ return cow
38
+ return None
39
+
40
+ @property
41
+ def names(self) -> set[str]:
42
+ return {cow.name for cow in self.cows}
43
+
44
+ @staticmethod
45
+ def from_settings(settings):
46
+ cows = [
47
+ CowConfig.from_name_and_dict(name, config)
48
+ for name, config in settings.cow.items()
49
+ ]
50
+
51
+ return HerdConfig(cows=cows)
52
+
53
+ def load_herd(self) -> list[RoboCow]:
54
+ return [cow.load() for cow in self.cows]