roomkit 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.
Files changed (114) hide show
  1. roomkit/AGENTS.md +362 -0
  2. roomkit/__init__.py +372 -0
  3. roomkit/_version.py +1 -0
  4. roomkit/ai_docs.py +93 -0
  5. roomkit/channels/__init__.py +194 -0
  6. roomkit/channels/ai.py +238 -0
  7. roomkit/channels/base.py +66 -0
  8. roomkit/channels/transport.py +115 -0
  9. roomkit/channels/websocket.py +85 -0
  10. roomkit/core/__init__.py +0 -0
  11. roomkit/core/_channel_ops.py +252 -0
  12. roomkit/core/_helpers.py +296 -0
  13. roomkit/core/_inbound.py +435 -0
  14. roomkit/core/_room_lifecycle.py +275 -0
  15. roomkit/core/circuit_breaker.py +84 -0
  16. roomkit/core/event_router.py +401 -0
  17. roomkit/core/framework.py +793 -0
  18. roomkit/core/hooks.py +232 -0
  19. roomkit/core/inbound_router.py +57 -0
  20. roomkit/core/locks.py +66 -0
  21. roomkit/core/rate_limiter.py +67 -0
  22. roomkit/core/retry.py +49 -0
  23. roomkit/core/router.py +24 -0
  24. roomkit/core/transcoder.py +85 -0
  25. roomkit/identity/__init__.py +0 -0
  26. roomkit/identity/base.py +27 -0
  27. roomkit/identity/mock.py +49 -0
  28. roomkit/llms.txt +52 -0
  29. roomkit/models/__init__.py +104 -0
  30. roomkit/models/channel.py +99 -0
  31. roomkit/models/context.py +35 -0
  32. roomkit/models/delivery.py +76 -0
  33. roomkit/models/enums.py +170 -0
  34. roomkit/models/event.py +203 -0
  35. roomkit/models/framework_event.py +19 -0
  36. roomkit/models/hook.py +68 -0
  37. roomkit/models/identity.py +81 -0
  38. roomkit/models/participant.py +34 -0
  39. roomkit/models/room.py +33 -0
  40. roomkit/models/task.py +36 -0
  41. roomkit/providers/__init__.py +0 -0
  42. roomkit/providers/ai/__init__.py +0 -0
  43. roomkit/providers/ai/base.py +140 -0
  44. roomkit/providers/ai/mock.py +33 -0
  45. roomkit/providers/anthropic/__init__.py +6 -0
  46. roomkit/providers/anthropic/ai.py +145 -0
  47. roomkit/providers/anthropic/config.py +14 -0
  48. roomkit/providers/elasticemail/__init__.py +6 -0
  49. roomkit/providers/elasticemail/config.py +16 -0
  50. roomkit/providers/elasticemail/email.py +97 -0
  51. roomkit/providers/email/__init__.py +0 -0
  52. roomkit/providers/email/base.py +46 -0
  53. roomkit/providers/email/mock.py +34 -0
  54. roomkit/providers/gemini/__init__.py +6 -0
  55. roomkit/providers/gemini/ai.py +153 -0
  56. roomkit/providers/gemini/config.py +14 -0
  57. roomkit/providers/http/__init__.py +15 -0
  58. roomkit/providers/http/base.py +33 -0
  59. roomkit/providers/http/config.py +14 -0
  60. roomkit/providers/http/mock.py +21 -0
  61. roomkit/providers/http/provider.py +105 -0
  62. roomkit/providers/http/webhook.py +33 -0
  63. roomkit/providers/messenger/__init__.py +15 -0
  64. roomkit/providers/messenger/base.py +33 -0
  65. roomkit/providers/messenger/config.py +17 -0
  66. roomkit/providers/messenger/facebook.py +95 -0
  67. roomkit/providers/messenger/mock.py +21 -0
  68. roomkit/providers/messenger/webhook.py +42 -0
  69. roomkit/providers/openai/__init__.py +6 -0
  70. roomkit/providers/openai/ai.py +155 -0
  71. roomkit/providers/openai/config.py +24 -0
  72. roomkit/providers/pydantic_ai/__init__.py +5 -0
  73. roomkit/providers/pydantic_ai/config.py +14 -0
  74. roomkit/providers/rcs/__init__.py +9 -0
  75. roomkit/providers/rcs/base.py +95 -0
  76. roomkit/providers/rcs/mock.py +78 -0
  77. roomkit/providers/sendgrid/__init__.py +5 -0
  78. roomkit/providers/sendgrid/config.py +13 -0
  79. roomkit/providers/sinch/__init__.py +6 -0
  80. roomkit/providers/sinch/config.py +22 -0
  81. roomkit/providers/sinch/sms.py +192 -0
  82. roomkit/providers/sms/__init__.py +15 -0
  83. roomkit/providers/sms/base.py +67 -0
  84. roomkit/providers/sms/meta.py +401 -0
  85. roomkit/providers/sms/mock.py +24 -0
  86. roomkit/providers/sms/phone.py +77 -0
  87. roomkit/providers/telnyx/__init__.py +21 -0
  88. roomkit/providers/telnyx/config.py +14 -0
  89. roomkit/providers/telnyx/rcs.py +352 -0
  90. roomkit/providers/telnyx/sms.py +231 -0
  91. roomkit/providers/twilio/__init__.py +18 -0
  92. roomkit/providers/twilio/config.py +19 -0
  93. roomkit/providers/twilio/rcs.py +183 -0
  94. roomkit/providers/twilio/sms.py +200 -0
  95. roomkit/providers/voicemeup/__init__.py +15 -0
  96. roomkit/providers/voicemeup/config.py +21 -0
  97. roomkit/providers/voicemeup/sms.py +374 -0
  98. roomkit/providers/whatsapp/__init__.py +0 -0
  99. roomkit/providers/whatsapp/base.py +44 -0
  100. roomkit/providers/whatsapp/mock.py +21 -0
  101. roomkit/py.typed +0 -0
  102. roomkit/realtime/__init__.py +17 -0
  103. roomkit/realtime/base.py +111 -0
  104. roomkit/realtime/memory.py +158 -0
  105. roomkit/sources/__init__.py +35 -0
  106. roomkit/sources/base.py +207 -0
  107. roomkit/sources/websocket.py +260 -0
  108. roomkit/store/__init__.py +0 -0
  109. roomkit/store/base.py +230 -0
  110. roomkit/store/memory.py +293 -0
  111. roomkit-0.1.0.dist-info/METADATA +567 -0
  112. roomkit-0.1.0.dist-info/RECORD +114 -0
  113. roomkit-0.1.0.dist-info/WHEEL +4 -0
  114. roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
roomkit/store/base.py ADDED
@@ -0,0 +1,230 @@
1
+ """Abstract base class for conversation storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from roomkit.models.channel import ChannelBinding
9
+ from roomkit.models.event import RoomEvent
10
+ from roomkit.models.identity import Identity
11
+ from roomkit.models.participant import Participant
12
+ from roomkit.models.room import Room
13
+ from roomkit.models.task import Observation, Task
14
+
15
+
16
+ class ConversationStore(ABC):
17
+ """Persistent storage for rooms, events, bindings, and participants.
18
+
19
+ Implement this ABC to plug in any storage backend (SQL, Redis, etc.).
20
+ The library ships with `InMemoryStore` for development and testing.
21
+ """
22
+
23
+ # Room operations
24
+
25
+ @abstractmethod
26
+ async def create_room(self, room: Room) -> Room:
27
+ """Persist a new room."""
28
+ ...
29
+
30
+ @abstractmethod
31
+ async def get_room(self, room_id: str) -> Room | None:
32
+ """Get a room by ID, or ``None`` if it doesn't exist."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ async def update_room(self, room: Room) -> Room:
37
+ """Update an existing room."""
38
+ ...
39
+
40
+ @abstractmethod
41
+ async def delete_room(self, room_id: str) -> bool:
42
+ """Delete a room. Returns ``True`` if the room existed."""
43
+ ...
44
+
45
+ @abstractmethod
46
+ async def list_rooms(self, offset: int = 0, limit: int = 50) -> list[Room]:
47
+ """List rooms with pagination."""
48
+ ...
49
+
50
+ @abstractmethod
51
+ async def find_rooms(
52
+ self,
53
+ organization_id: str | None = None,
54
+ status: str | None = None,
55
+ metadata_filter: dict[str, Any] | None = None,
56
+ ) -> list[Room]:
57
+ """Find rooms matching the given filters."""
58
+ ...
59
+
60
+ @abstractmethod
61
+ async def find_latest_room(
62
+ self,
63
+ participant_id: str,
64
+ channel_type: str | None = None,
65
+ status: str | None = None,
66
+ ) -> Room | None:
67
+ """Find the most recent room for a participant."""
68
+ ...
69
+
70
+ @abstractmethod
71
+ async def find_room_id_by_channel(
72
+ self, channel_id: str, status: str | None = None
73
+ ) -> str | None:
74
+ """Find a room ID that has a binding for the given channel_id."""
75
+ ...
76
+
77
+ # Event operations
78
+
79
+ @abstractmethod
80
+ async def add_event(self, event: RoomEvent) -> RoomEvent:
81
+ """Store a new event."""
82
+ ...
83
+
84
+ @abstractmethod
85
+ async def get_event(self, event_id: str) -> RoomEvent | None:
86
+ """Get an event by ID."""
87
+ ...
88
+
89
+ @abstractmethod
90
+ async def list_events(
91
+ self,
92
+ room_id: str,
93
+ offset: int = 0,
94
+ limit: int = 50,
95
+ visibility_filter: str | None = None,
96
+ ) -> list[RoomEvent]:
97
+ """List events in a room with pagination and optional visibility filter."""
98
+ ...
99
+
100
+ @abstractmethod
101
+ async def check_idempotency(self, room_id: str, key: str) -> bool:
102
+ """Check if an idempotency key has been seen. Returns ``True`` if duplicate."""
103
+ ...
104
+
105
+ @abstractmethod
106
+ async def get_event_count(self, room_id: str) -> int:
107
+ """Return the total number of events in a room."""
108
+ ...
109
+
110
+ # Binding operations
111
+
112
+ @abstractmethod
113
+ async def add_binding(self, binding: ChannelBinding) -> ChannelBinding:
114
+ """Attach a channel binding to a room."""
115
+ ...
116
+
117
+ @abstractmethod
118
+ async def get_binding(self, room_id: str, channel_id: str) -> ChannelBinding | None:
119
+ """Get a channel binding, or ``None`` if not attached."""
120
+ ...
121
+
122
+ @abstractmethod
123
+ async def update_binding(self, binding: ChannelBinding) -> ChannelBinding:
124
+ """Update an existing channel binding."""
125
+ ...
126
+
127
+ @abstractmethod
128
+ async def remove_binding(self, room_id: str, channel_id: str) -> bool:
129
+ """Detach a channel from a room. Returns ``True`` if it was attached."""
130
+ ...
131
+
132
+ @abstractmethod
133
+ async def list_bindings(self, room_id: str) -> list[ChannelBinding]:
134
+ """List all channel bindings for a room."""
135
+ ...
136
+
137
+ # Participant operations
138
+
139
+ @abstractmethod
140
+ async def add_participant(self, participant: Participant) -> Participant:
141
+ """Add a participant to a room."""
142
+ ...
143
+
144
+ @abstractmethod
145
+ async def get_participant(self, room_id: str, participant_id: str) -> Participant | None:
146
+ """Get a participant by ID within a room."""
147
+ ...
148
+
149
+ @abstractmethod
150
+ async def update_participant(self, participant: Participant) -> Participant:
151
+ """Update a participant."""
152
+ ...
153
+
154
+ @abstractmethod
155
+ async def list_participants(self, room_id: str) -> list[Participant]:
156
+ """List all participants in a room."""
157
+ ...
158
+
159
+ # Identity operations
160
+
161
+ @abstractmethod
162
+ async def create_identity(self, identity: Identity) -> Identity:
163
+ """Create a new identity record."""
164
+ ...
165
+
166
+ @abstractmethod
167
+ async def get_identity(self, identity_id: str) -> Identity | None:
168
+ """Get an identity by ID."""
169
+ ...
170
+
171
+ @abstractmethod
172
+ async def resolve_identity(self, channel_type: str, address: str) -> Identity | None:
173
+ """Look up an identity by channel type and address."""
174
+ ...
175
+
176
+ @abstractmethod
177
+ async def link_address(self, identity_id: str, channel_type: str, address: str) -> None:
178
+ """Link a channel address to an identity."""
179
+ ...
180
+
181
+ # Task operations
182
+
183
+ @abstractmethod
184
+ async def add_task(self, task: Task) -> Task:
185
+ """Store a new task."""
186
+ ...
187
+
188
+ @abstractmethod
189
+ async def get_task(self, task_id: str) -> Task | None:
190
+ """Get a task by ID."""
191
+ ...
192
+
193
+ @abstractmethod
194
+ async def list_tasks(self, room_id: str, status: str | None = None) -> list[Task]:
195
+ """List tasks for a room, optionally filtered by status."""
196
+ ...
197
+
198
+ @abstractmethod
199
+ async def update_task(self, task: Task) -> Task:
200
+ """Update a task."""
201
+ ...
202
+
203
+ # Observation operations
204
+
205
+ @abstractmethod
206
+ async def add_observation(self, observation: Observation) -> Observation:
207
+ """Store a new observation."""
208
+ ...
209
+
210
+ @abstractmethod
211
+ async def list_observations(self, room_id: str) -> list[Observation]:
212
+ """List all observations for a room."""
213
+ ...
214
+
215
+ # Read tracking
216
+
217
+ @abstractmethod
218
+ async def mark_read(self, room_id: str, channel_id: str, event_id: str) -> None:
219
+ """Mark an event as read for a channel."""
220
+ ...
221
+
222
+ @abstractmethod
223
+ async def mark_all_read(self, room_id: str, channel_id: str) -> None:
224
+ """Mark all events as read for a channel."""
225
+ ...
226
+
227
+ @abstractmethod
228
+ async def get_unread_count(self, room_id: str, channel_id: str) -> int:
229
+ """Return the number of unread events for a channel in a room."""
230
+ ...
@@ -0,0 +1,293 @@
1
+ """In-memory implementation of ConversationStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from roomkit.models.channel import ChannelBinding
8
+ from roomkit.models.event import RoomEvent
9
+ from roomkit.models.identity import Identity
10
+ from roomkit.models.participant import Participant
11
+ from roomkit.models.room import Room
12
+ from roomkit.models.task import Observation, Task
13
+ from roomkit.store.base import ConversationStore
14
+
15
+
16
+ class InMemoryStore(ConversationStore):
17
+ """Dict-based in-memory store for development and testing."""
18
+
19
+ def __init__(self) -> None:
20
+ self._rooms: dict[str, Room] = {}
21
+ self._events: dict[str, RoomEvent] = {}
22
+ self._room_events: dict[str, list[str]] = {}
23
+ self._bindings: dict[str, dict[str, ChannelBinding]] = {}
24
+ self._participants: dict[str, dict[str, Participant]] = {}
25
+ self._idempotency: dict[str, set[str]] = {}
26
+ self._read_markers: dict[str, dict[str, str]] = {}
27
+ self._identities: dict[str, Identity] = {}
28
+ self._address_index: dict[tuple[str, str], str] = {}
29
+ self._tasks: dict[str, Task] = {}
30
+ self._room_tasks: dict[str, list[str]] = {}
31
+ self._observations: dict[str, Observation] = {}
32
+ self._room_observations: dict[str, list[str]] = {}
33
+
34
+ # Room operations
35
+
36
+ async def create_room(self, room: Room) -> Room:
37
+ self._rooms[room.id] = room
38
+ self._room_events.setdefault(room.id, [])
39
+ self._bindings.setdefault(room.id, {})
40
+ self._participants.setdefault(room.id, {})
41
+ self._idempotency.setdefault(room.id, set())
42
+ self._read_markers.setdefault(room.id, {})
43
+ return room
44
+
45
+ async def get_room(self, room_id: str) -> Room | None:
46
+ room = self._rooms.get(room_id)
47
+ return room.model_copy() if room is not None else None
48
+
49
+ async def update_room(self, room: Room) -> Room:
50
+ self._rooms[room.id] = room
51
+ return room
52
+
53
+ async def delete_room(self, room_id: str) -> bool:
54
+ if room_id not in self._rooms:
55
+ return False
56
+ del self._rooms[room_id]
57
+ # Clean up events
58
+ event_ids = self._room_events.pop(room_id, [])
59
+ for eid in event_ids:
60
+ self._events.pop(eid, None)
61
+ # Clean up tasks
62
+ task_ids = self._room_tasks.pop(room_id, [])
63
+ for tid in task_ids:
64
+ self._tasks.pop(tid, None)
65
+ # Clean up observations
66
+ obs_ids = self._room_observations.pop(room_id, [])
67
+ for oid in obs_ids:
68
+ self._observations.pop(oid, None)
69
+ self._bindings.pop(room_id, None)
70
+ self._participants.pop(room_id, None)
71
+ self._idempotency.pop(room_id, None)
72
+ self._read_markers.pop(room_id, None)
73
+ return True
74
+
75
+ async def list_rooms(self, offset: int = 0, limit: int = 50) -> list[Room]:
76
+ rooms = list(self._rooms.values())
77
+ return [r.model_copy() for r in rooms[offset : offset + limit]]
78
+
79
+ async def find_rooms(
80
+ self,
81
+ organization_id: str | None = None,
82
+ status: str | None = None,
83
+ metadata_filter: dict[str, Any] | None = None,
84
+ ) -> list[Room]:
85
+ results: list[Room] = []
86
+ for room in self._rooms.values():
87
+ if organization_id is not None and room.organization_id != organization_id:
88
+ continue
89
+ if status is not None and room.status.value != status:
90
+ continue
91
+ if metadata_filter and not all(
92
+ room.metadata.get(k) == v for k, v in metadata_filter.items()
93
+ ):
94
+ continue
95
+ results.append(room.model_copy())
96
+ return results
97
+
98
+ async def find_latest_room(
99
+ self,
100
+ participant_id: str,
101
+ channel_type: str | None = None,
102
+ status: str | None = None,
103
+ ) -> Room | None:
104
+ best: Room | None = None
105
+ for room in self._rooms.values():
106
+ if status is not None and room.status.value != status:
107
+ continue
108
+ # Check if participant is in this room
109
+ if participant_id not in self._participants.get(room.id, {}):
110
+ # Also check bindings for participant_id
111
+ found = False
112
+ for binding in self._bindings.get(room.id, {}).values():
113
+ if binding.participant_id == participant_id and (
114
+ channel_type is None or binding.channel_type.value == channel_type
115
+ ):
116
+ found = True
117
+ break
118
+ if not found:
119
+ continue
120
+ if best is None or room.created_at > best.created_at:
121
+ best = room
122
+ return best.model_copy() if best is not None else None
123
+
124
+ async def find_room_id_by_channel(
125
+ self, channel_id: str, status: str | None = None
126
+ ) -> str | None:
127
+ for room_id, bindings in self._bindings.items():
128
+ if channel_id in bindings:
129
+ if status is not None:
130
+ room = self._rooms.get(room_id)
131
+ if room is None or room.status.value != status:
132
+ continue
133
+ return room_id
134
+ return None
135
+
136
+ # Event operations
137
+
138
+ async def add_event(self, event: RoomEvent) -> RoomEvent:
139
+ self._events[event.id] = event
140
+ self._room_events.setdefault(event.room_id, []).append(event.id)
141
+ if event.idempotency_key:
142
+ self._idempotency.setdefault(event.room_id, set()).add(event.idempotency_key)
143
+ return event
144
+
145
+ async def get_event(self, event_id: str) -> RoomEvent | None:
146
+ event = self._events.get(event_id)
147
+ return event.model_copy() if event is not None else None
148
+
149
+ async def list_events(
150
+ self,
151
+ room_id: str,
152
+ offset: int = 0,
153
+ limit: int = 50,
154
+ visibility_filter: str | None = None,
155
+ ) -> list[RoomEvent]:
156
+ event_ids = self._room_events.get(room_id, [])
157
+ events = [self._events[eid] for eid in event_ids if eid in self._events]
158
+ if visibility_filter is not None:
159
+ events = [e for e in events if e.visibility == visibility_filter]
160
+ return [e.model_copy() for e in events[offset : offset + limit]]
161
+
162
+ async def check_idempotency(self, room_id: str, key: str) -> bool:
163
+ return key in self._idempotency.get(room_id, set())
164
+
165
+ async def get_event_count(self, room_id: str) -> int:
166
+ return len(self._room_events.get(room_id, []))
167
+
168
+ # Binding operations
169
+
170
+ async def add_binding(self, binding: ChannelBinding) -> ChannelBinding:
171
+ self._bindings.setdefault(binding.room_id, {})[binding.channel_id] = binding
172
+ return binding
173
+
174
+ async def get_binding(self, room_id: str, channel_id: str) -> ChannelBinding | None:
175
+ binding = self._bindings.get(room_id, {}).get(channel_id)
176
+ return binding.model_copy() if binding is not None else None
177
+
178
+ async def update_binding(self, binding: ChannelBinding) -> ChannelBinding:
179
+ self._bindings.setdefault(binding.room_id, {})[binding.channel_id] = binding
180
+ return binding
181
+
182
+ async def remove_binding(self, room_id: str, channel_id: str) -> bool:
183
+ room_bindings = self._bindings.get(room_id, {})
184
+ if channel_id not in room_bindings:
185
+ return False
186
+ del room_bindings[channel_id]
187
+ return True
188
+
189
+ async def list_bindings(self, room_id: str) -> list[ChannelBinding]:
190
+ return [b.model_copy() for b in self._bindings.get(room_id, {}).values()]
191
+
192
+ # Participant operations
193
+
194
+ async def add_participant(self, participant: Participant) -> Participant:
195
+ self._participants.setdefault(participant.room_id, {})[participant.id] = participant
196
+ return participant
197
+
198
+ async def get_participant(self, room_id: str, participant_id: str) -> Participant | None:
199
+ participant = self._participants.get(room_id, {}).get(participant_id)
200
+ return participant.model_copy() if participant is not None else None
201
+
202
+ async def update_participant(self, participant: Participant) -> Participant:
203
+ self._participants.setdefault(participant.room_id, {})[participant.id] = participant
204
+ return participant
205
+
206
+ async def list_participants(self, room_id: str) -> list[Participant]:
207
+ return [p.model_copy() for p in self._participants.get(room_id, {}).values()]
208
+
209
+ # Read tracking
210
+
211
+ async def mark_read(self, room_id: str, channel_id: str, event_id: str) -> None:
212
+ self._read_markers.setdefault(room_id, {})[channel_id] = event_id
213
+
214
+ async def mark_all_read(self, room_id: str, channel_id: str) -> None:
215
+ event_ids = self._room_events.get(room_id, [])
216
+ if event_ids:
217
+ self._read_markers.setdefault(room_id, {})[channel_id] = event_ids[-1]
218
+
219
+ async def get_unread_count(self, room_id: str, channel_id: str) -> int:
220
+ event_ids = self._room_events.get(room_id, [])
221
+ last_read = self._read_markers.get(room_id, {}).get(channel_id)
222
+ if last_read is None:
223
+ return len(event_ids)
224
+ try:
225
+ idx = event_ids.index(last_read)
226
+ return len(event_ids) - idx - 1
227
+ except ValueError:
228
+ return len(event_ids)
229
+
230
+ # Identity operations
231
+
232
+ async def create_identity(self, identity: Identity) -> Identity:
233
+ self._identities[identity.id] = identity
234
+ for ch_type, addresses in identity.channel_addresses.items():
235
+ for addr in addresses:
236
+ self._address_index[(ch_type, addr)] = identity.id
237
+ return identity
238
+
239
+ async def get_identity(self, identity_id: str) -> Identity | None:
240
+ identity = self._identities.get(identity_id)
241
+ return identity.model_copy() if identity is not None else None
242
+
243
+ async def resolve_identity(self, channel_type: str, address: str) -> Identity | None:
244
+ identity_id = self._address_index.get((channel_type, address))
245
+ if identity_id is None:
246
+ return None
247
+ return self._identities.get(identity_id)
248
+
249
+ async def link_address(self, identity_id: str, channel_type: str, address: str) -> None:
250
+ identity = self._identities.get(identity_id)
251
+ if identity is None:
252
+ return
253
+ current = identity.channel_addresses.get(channel_type, [])
254
+ if address not in current:
255
+ new_addresses = {**identity.channel_addresses, channel_type: [*current, address]}
256
+ self._identities[identity_id] = identity.model_copy(
257
+ update={"channel_addresses": new_addresses}
258
+ )
259
+ self._address_index[(channel_type, address)] = identity_id
260
+
261
+ # Task operations
262
+
263
+ async def add_task(self, task: Task) -> Task:
264
+ self._tasks[task.id] = task
265
+ self._room_tasks.setdefault(task.room_id, []).append(task.id)
266
+ return task
267
+
268
+ async def get_task(self, task_id: str) -> Task | None:
269
+ return self._tasks.get(task_id)
270
+
271
+ async def list_tasks(self, room_id: str, status: str | None = None) -> list[Task]:
272
+ task_ids = self._room_tasks.get(room_id, [])
273
+ tasks = [self._tasks[tid] for tid in task_ids if tid in self._tasks]
274
+ if status is not None:
275
+ tasks = [t for t in tasks if t.status == status]
276
+ return [t.model_copy() for t in tasks]
277
+
278
+ async def update_task(self, task: Task) -> Task:
279
+ self._tasks[task.id] = task
280
+ return task
281
+
282
+ # Observation operations
283
+
284
+ async def add_observation(self, observation: Observation) -> Observation:
285
+ self._observations[observation.id] = observation
286
+ self._room_observations.setdefault(observation.room_id, []).append(observation.id)
287
+ return observation
288
+
289
+ async def list_observations(self, room_id: str) -> list[Observation]:
290
+ obs_ids = self._room_observations.get(room_id, [])
291
+ return [
292
+ self._observations[oid].model_copy() for oid in obs_ids if oid in self._observations
293
+ ]