nornweave 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.
- nornweave/__init__.py +3 -0
- nornweave/adapters/__init__.py +1 -0
- nornweave/adapters/base.py +5 -0
- nornweave/adapters/mailgun.py +196 -0
- nornweave/adapters/resend.py +510 -0
- nornweave/adapters/sendgrid.py +492 -0
- nornweave/adapters/ses.py +824 -0
- nornweave/cli.py +186 -0
- nornweave/core/__init__.py +26 -0
- nornweave/core/config.py +172 -0
- nornweave/core/exceptions.py +25 -0
- nornweave/core/interfaces.py +390 -0
- nornweave/core/storage.py +192 -0
- nornweave/core/utils.py +23 -0
- nornweave/huginn/__init__.py +10 -0
- nornweave/huginn/client.py +296 -0
- nornweave/huginn/config.py +52 -0
- nornweave/huginn/resources.py +165 -0
- nornweave/huginn/server.py +202 -0
- nornweave/models/__init__.py +113 -0
- nornweave/models/attachment.py +136 -0
- nornweave/models/event.py +275 -0
- nornweave/models/inbox.py +33 -0
- nornweave/models/message.py +284 -0
- nornweave/models/thread.py +172 -0
- nornweave/muninn/__init__.py +14 -0
- nornweave/muninn/tools.py +207 -0
- nornweave/search/__init__.py +1 -0
- nornweave/search/embeddings.py +1 -0
- nornweave/search/vector_store.py +1 -0
- nornweave/skuld/__init__.py +1 -0
- nornweave/skuld/rate_limiter.py +1 -0
- nornweave/skuld/scheduler.py +1 -0
- nornweave/skuld/sender.py +25 -0
- nornweave/skuld/webhooks.py +1 -0
- nornweave/storage/__init__.py +20 -0
- nornweave/storage/database.py +165 -0
- nornweave/storage/gcs.py +144 -0
- nornweave/storage/local.py +152 -0
- nornweave/storage/s3.py +164 -0
- nornweave/urdr/__init__.py +14 -0
- nornweave/urdr/adapters/__init__.py +16 -0
- nornweave/urdr/adapters/base.py +385 -0
- nornweave/urdr/adapters/postgres.py +50 -0
- nornweave/urdr/adapters/sqlite.py +51 -0
- nornweave/urdr/migrations/env.py +94 -0
- nornweave/urdr/migrations/script.py.mako +26 -0
- nornweave/urdr/migrations/versions/.gitkeep +0 -0
- nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
- nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
- nornweave/urdr/orm.py +641 -0
- nornweave/verdandi/__init__.py +45 -0
- nornweave/verdandi/attachments.py +471 -0
- nornweave/verdandi/content.py +420 -0
- nornweave/verdandi/headers.py +404 -0
- nornweave/verdandi/parser.py +25 -0
- nornweave/verdandi/sanitizer.py +9 -0
- nornweave/verdandi/threading.py +359 -0
- nornweave/yggdrasil/__init__.py +1 -0
- nornweave/yggdrasil/app.py +86 -0
- nornweave/yggdrasil/dependencies.py +190 -0
- nornweave/yggdrasil/middleware/__init__.py +1 -0
- nornweave/yggdrasil/middleware/auth.py +1 -0
- nornweave/yggdrasil/middleware/logging.py +1 -0
- nornweave/yggdrasil/routes/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
- nornweave/yggdrasil/routes/v1/messages.py +200 -0
- nornweave/yggdrasil/routes/v1/search.py +84 -0
- nornweave/yggdrasil/routes/v1/threads.py +142 -0
- nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
- nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
- nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
- nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
- nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
- nornweave-0.1.2.dist-info/METADATA +324 -0
- nornweave-0.1.2.dist-info/RECORD +80 -0
- nornweave-0.1.2.dist-info/WHEEL +4 -0
- nornweave-0.1.2.dist-info/entry_points.txt +5 -0
- nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Core abstractions: storage and email provider interfaces."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from nornweave.models.attachment import AttachmentDisposition, SendAttachment
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from nornweave.models.event import Event, EventType
|
|
12
|
+
from nornweave.models.inbox import Inbox
|
|
13
|
+
from nornweave.models.message import Message
|
|
14
|
+
from nornweave.models.thread import Thread
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class InboundAttachment:
|
|
19
|
+
"""
|
|
20
|
+
Attachment parsed from inbound email webhook.
|
|
21
|
+
|
|
22
|
+
Contains full content for storage and processing.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
filename: str
|
|
26
|
+
content_type: str
|
|
27
|
+
content: bytes
|
|
28
|
+
size_bytes: int
|
|
29
|
+
disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT
|
|
30
|
+
content_id: str | None = None
|
|
31
|
+
provider_id: str | None = None
|
|
32
|
+
provider_url: str | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_base64(
|
|
36
|
+
cls,
|
|
37
|
+
filename: str,
|
|
38
|
+
content_type: str,
|
|
39
|
+
content_base64: str,
|
|
40
|
+
*,
|
|
41
|
+
disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT,
|
|
42
|
+
content_id: str | None = None,
|
|
43
|
+
) -> InboundAttachment:
|
|
44
|
+
"""Create attachment from base64-encoded content."""
|
|
45
|
+
import base64
|
|
46
|
+
|
|
47
|
+
content = base64.b64decode(content_base64)
|
|
48
|
+
return cls(
|
|
49
|
+
filename=filename,
|
|
50
|
+
content_type=content_type,
|
|
51
|
+
content=content,
|
|
52
|
+
size_bytes=len(content),
|
|
53
|
+
disposition=disposition,
|
|
54
|
+
content_id=content_id,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class InboundMessage:
|
|
60
|
+
"""
|
|
61
|
+
Standardized representation of an inbound email from a provider webhook.
|
|
62
|
+
|
|
63
|
+
Enhanced with full attachment support, threading headers, and verification results.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Envelope data
|
|
67
|
+
from_address: str
|
|
68
|
+
to_address: str
|
|
69
|
+
subject: str
|
|
70
|
+
|
|
71
|
+
# Body content
|
|
72
|
+
body_plain: str
|
|
73
|
+
body_html: str | None = None
|
|
74
|
+
stripped_text: str | None = None
|
|
75
|
+
stripped_html: str | None = None
|
|
76
|
+
|
|
77
|
+
# Threading headers (RFC 5322)
|
|
78
|
+
message_id: str | None = None
|
|
79
|
+
in_reply_to: str | None = None
|
|
80
|
+
references: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Metadata
|
|
83
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
85
|
+
|
|
86
|
+
# Full attachment support
|
|
87
|
+
attachments: list[InboundAttachment] = field(default_factory=list)
|
|
88
|
+
content_id_map: dict[str, str] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
# Provider verification (for audit/spam detection)
|
|
91
|
+
spf_result: str | None = None
|
|
92
|
+
dkim_result: str | None = None
|
|
93
|
+
dmarc_result: str | None = None
|
|
94
|
+
|
|
95
|
+
# CC/BCC support
|
|
96
|
+
cc_addresses: list[str] = field(default_factory=list)
|
|
97
|
+
bcc_addresses: list[str] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def attachment_count(self) -> int:
|
|
101
|
+
"""Get total number of attachments."""
|
|
102
|
+
return len(self.attachments)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def inline_attachments(self) -> list[InboundAttachment]:
|
|
106
|
+
"""Get only inline attachments (embedded images, etc.)."""
|
|
107
|
+
return [a for a in self.attachments if a.disposition == AttachmentDisposition.INLINE]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def regular_attachments(self) -> list[InboundAttachment]:
|
|
111
|
+
"""Get only regular file attachments."""
|
|
112
|
+
return [a for a in self.attachments if a.disposition == AttachmentDisposition.ATTACHMENT]
|
|
113
|
+
|
|
114
|
+
def get_attachment_by_content_id(self, content_id: str) -> InboundAttachment | None:
|
|
115
|
+
"""Find attachment by Content-ID (for cid: URL resolution)."""
|
|
116
|
+
cid = content_id.strip("<>")
|
|
117
|
+
for attachment in self.attachments:
|
|
118
|
+
if attachment.content_id and attachment.content_id.strip("<>") == cid:
|
|
119
|
+
return attachment
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def total_attachment_size(self) -> int:
|
|
124
|
+
"""Get total size of all attachments in bytes."""
|
|
125
|
+
return sum(a.size_bytes for a in self.attachments)
|
|
126
|
+
|
|
127
|
+
def parse_references_string(self, references_str: str | None) -> list[str]:
|
|
128
|
+
"""Parse space-separated References header into list."""
|
|
129
|
+
if not references_str:
|
|
130
|
+
return []
|
|
131
|
+
return [ref.strip() for ref in references_str.split() if ref.strip()]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class StorageInterface(ABC):
|
|
135
|
+
"""Abstract storage layer (Urdr - The Well). Implementations: Postgres, SQLite."""
|
|
136
|
+
|
|
137
|
+
# -------------------------------------------------------------------------
|
|
138
|
+
# Inbox methods
|
|
139
|
+
# -------------------------------------------------------------------------
|
|
140
|
+
@abstractmethod
|
|
141
|
+
async def create_inbox(self, inbox: Inbox) -> Inbox:
|
|
142
|
+
"""Create an inbox. Returns the created inbox with id set."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
async def get_inbox(self, inbox_id: str) -> Inbox | None:
|
|
147
|
+
"""Get an inbox by id."""
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
async def get_inbox_by_email(self, email_address: str) -> Inbox | None:
|
|
152
|
+
"""Get an inbox by email address."""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
async def delete_inbox(self, inbox_id: str) -> bool:
|
|
157
|
+
"""Delete an inbox. Returns True if deleted."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
async def list_inboxes(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
limit: int = 50,
|
|
165
|
+
offset: int = 0,
|
|
166
|
+
) -> list[Inbox]:
|
|
167
|
+
"""List all inboxes."""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
# -------------------------------------------------------------------------
|
|
171
|
+
# Thread methods
|
|
172
|
+
# -------------------------------------------------------------------------
|
|
173
|
+
@abstractmethod
|
|
174
|
+
async def create_thread(self, thread: Thread) -> Thread:
|
|
175
|
+
"""Create a thread."""
|
|
176
|
+
...
|
|
177
|
+
|
|
178
|
+
@abstractmethod
|
|
179
|
+
async def get_thread(self, thread_id: str) -> Thread | None:
|
|
180
|
+
"""Get a thread by id."""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
async def get_thread_by_participant_hash(
|
|
185
|
+
self,
|
|
186
|
+
inbox_id: str,
|
|
187
|
+
participant_hash: str,
|
|
188
|
+
) -> Thread | None:
|
|
189
|
+
"""Get a thread by inbox and participant hash (Phase 2 threading)."""
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
@abstractmethod
|
|
193
|
+
async def update_thread(self, thread: Thread) -> Thread:
|
|
194
|
+
"""Update a thread (e.g. last_message_at)."""
|
|
195
|
+
...
|
|
196
|
+
|
|
197
|
+
@abstractmethod
|
|
198
|
+
async def list_threads_for_inbox(
|
|
199
|
+
self,
|
|
200
|
+
inbox_id: str,
|
|
201
|
+
*,
|
|
202
|
+
limit: int = 20,
|
|
203
|
+
offset: int = 0,
|
|
204
|
+
) -> list[Thread]:
|
|
205
|
+
"""List threads for an inbox, ordered by last_message_at DESC."""
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
# -------------------------------------------------------------------------
|
|
209
|
+
# Message methods
|
|
210
|
+
# -------------------------------------------------------------------------
|
|
211
|
+
@abstractmethod
|
|
212
|
+
async def create_message(self, message: Message) -> Message:
|
|
213
|
+
"""Create a message."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
@abstractmethod
|
|
217
|
+
async def get_message(self, message_id: str) -> Message | None:
|
|
218
|
+
"""Get a message by id."""
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
@abstractmethod
|
|
222
|
+
async def list_messages_for_inbox(
|
|
223
|
+
self,
|
|
224
|
+
inbox_id: str,
|
|
225
|
+
*,
|
|
226
|
+
limit: int = 50,
|
|
227
|
+
offset: int = 0,
|
|
228
|
+
) -> list[Message]:
|
|
229
|
+
"""List messages for an inbox, ordered by created_at."""
|
|
230
|
+
...
|
|
231
|
+
|
|
232
|
+
@abstractmethod
|
|
233
|
+
async def list_messages_for_thread(
|
|
234
|
+
self,
|
|
235
|
+
thread_id: str,
|
|
236
|
+
*,
|
|
237
|
+
limit: int = 100,
|
|
238
|
+
offset: int = 0,
|
|
239
|
+
) -> list[Message]:
|
|
240
|
+
"""List messages for a thread, ordered by created_at (conversation order)."""
|
|
241
|
+
...
|
|
242
|
+
|
|
243
|
+
@abstractmethod
|
|
244
|
+
async def search_messages(
|
|
245
|
+
self,
|
|
246
|
+
inbox_id: str,
|
|
247
|
+
query: str,
|
|
248
|
+
*,
|
|
249
|
+
limit: int = 50,
|
|
250
|
+
offset: int = 0,
|
|
251
|
+
) -> list[Message]:
|
|
252
|
+
"""Search messages by content (ILIKE/LIKE on content_clean and content_raw)."""
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
# -------------------------------------------------------------------------
|
|
256
|
+
# Event methods (Phase 3 webhooks)
|
|
257
|
+
# -------------------------------------------------------------------------
|
|
258
|
+
@abstractmethod
|
|
259
|
+
async def create_event(self, event: Event) -> Event:
|
|
260
|
+
"""Create an event. Returns the created event with id set."""
|
|
261
|
+
...
|
|
262
|
+
|
|
263
|
+
@abstractmethod
|
|
264
|
+
async def get_event(self, event_id: str) -> Event | None:
|
|
265
|
+
"""Get an event by id."""
|
|
266
|
+
...
|
|
267
|
+
|
|
268
|
+
@abstractmethod
|
|
269
|
+
async def list_events(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
event_type: EventType | None = None,
|
|
273
|
+
limit: int = 50,
|
|
274
|
+
offset: int = 0,
|
|
275
|
+
) -> list[Event]:
|
|
276
|
+
"""List events, optionally filtered by type, ordered by created_at DESC."""
|
|
277
|
+
...
|
|
278
|
+
|
|
279
|
+
# -------------------------------------------------------------------------
|
|
280
|
+
# Attachment methods
|
|
281
|
+
# -------------------------------------------------------------------------
|
|
282
|
+
@abstractmethod
|
|
283
|
+
async def create_attachment(
|
|
284
|
+
self,
|
|
285
|
+
message_id: str,
|
|
286
|
+
filename: str,
|
|
287
|
+
content_type: str,
|
|
288
|
+
size_bytes: int,
|
|
289
|
+
*,
|
|
290
|
+
disposition: str = "attachment",
|
|
291
|
+
content_id: str | None = None,
|
|
292
|
+
storage_path: str | None = None,
|
|
293
|
+
storage_backend: str | None = None,
|
|
294
|
+
) -> str:
|
|
295
|
+
"""Create attachment record. Returns attachment ID."""
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
@abstractmethod
|
|
299
|
+
async def get_attachment(self, attachment_id: str) -> dict[str, Any] | None:
|
|
300
|
+
"""Get attachment metadata by ID."""
|
|
301
|
+
...
|
|
302
|
+
|
|
303
|
+
@abstractmethod
|
|
304
|
+
async def list_attachments_for_message(self, message_id: str) -> list[dict[str, Any]]:
|
|
305
|
+
"""List attachments for a message."""
|
|
306
|
+
...
|
|
307
|
+
|
|
308
|
+
@abstractmethod
|
|
309
|
+
async def delete_attachment(self, attachment_id: str) -> bool:
|
|
310
|
+
"""Delete attachment. Returns True if deleted."""
|
|
311
|
+
...
|
|
312
|
+
|
|
313
|
+
# -------------------------------------------------------------------------
|
|
314
|
+
# Thread lookup methods (for threading algorithm)
|
|
315
|
+
# -------------------------------------------------------------------------
|
|
316
|
+
@abstractmethod
|
|
317
|
+
async def get_message_by_provider_id(
|
|
318
|
+
self, inbox_id: str, provider_message_id: str
|
|
319
|
+
) -> Message | None:
|
|
320
|
+
"""Get message by provider Message-ID header for threading lookups."""
|
|
321
|
+
...
|
|
322
|
+
|
|
323
|
+
@abstractmethod
|
|
324
|
+
async def get_thread_by_subject(
|
|
325
|
+
self,
|
|
326
|
+
inbox_id: str,
|
|
327
|
+
normalized_subject: str,
|
|
328
|
+
*,
|
|
329
|
+
since: datetime | None = None,
|
|
330
|
+
) -> Thread | None:
|
|
331
|
+
"""Get thread by normalized subject within time window (for subject-based threading)."""
|
|
332
|
+
...
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class EmailProvider(ABC):
|
|
336
|
+
"""Abstract email provider (BYOP). Implementations: Mailgun, SES, SendGrid, Resend."""
|
|
337
|
+
|
|
338
|
+
@abstractmethod
|
|
339
|
+
async def send_email(
|
|
340
|
+
self,
|
|
341
|
+
to: list[str],
|
|
342
|
+
subject: str,
|
|
343
|
+
body: str,
|
|
344
|
+
*,
|
|
345
|
+
from_address: str,
|
|
346
|
+
reply_to: str | None = None,
|
|
347
|
+
headers: dict[str, str] | None = None,
|
|
348
|
+
# Threading headers for proper reply threading
|
|
349
|
+
message_id: str | None = None,
|
|
350
|
+
in_reply_to: str | None = None,
|
|
351
|
+
references: list[str] | None = None,
|
|
352
|
+
# CC/BCC support
|
|
353
|
+
cc: list[str] | None = None,
|
|
354
|
+
bcc: list[str] | None = None,
|
|
355
|
+
# Attachment support
|
|
356
|
+
attachments: list[SendAttachment] | None = None,
|
|
357
|
+
# HTML body (alternative to plain text)
|
|
358
|
+
html_body: str | None = None,
|
|
359
|
+
) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Send an email with threading and attachment support.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
to: List of recipient email addresses
|
|
365
|
+
subject: Email subject
|
|
366
|
+
body: Plain text body
|
|
367
|
+
from_address: Sender email address
|
|
368
|
+
reply_to: Reply-to address (optional)
|
|
369
|
+
headers: Custom headers (optional)
|
|
370
|
+
message_id: RFC 5322 Message-ID (generated if None)
|
|
371
|
+
in_reply_to: Parent Message-ID for replies
|
|
372
|
+
references: Chain of ancestor Message-IDs for threading
|
|
373
|
+
cc: Carbon copy recipients
|
|
374
|
+
bcc: Blind carbon copy recipients
|
|
375
|
+
attachments: List of attachments to include
|
|
376
|
+
html_body: HTML body (optional, alternative to plain text)
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Provider message ID
|
|
380
|
+
"""
|
|
381
|
+
...
|
|
382
|
+
|
|
383
|
+
@abstractmethod
|
|
384
|
+
def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
|
|
385
|
+
"""Parse provider webhook payload into a standardized InboundMessage."""
|
|
386
|
+
...
|
|
387
|
+
|
|
388
|
+
async def setup_inbound_route(self, inbox_address: str) -> None: # noqa: B027
|
|
389
|
+
"""Optional: configure provider to route inbound mail to our webhook."""
|
|
390
|
+
pass
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Attachment storage backend interface and factory.
|
|
2
|
+
|
|
3
|
+
NornWeave supports multiple storage backends for attachments:
|
|
4
|
+
- Local filesystem (default, good for development)
|
|
5
|
+
- AWS S3 (production)
|
|
6
|
+
- Google Cloud Storage (production)
|
|
7
|
+
- Database blob (simple deployment)
|
|
8
|
+
|
|
9
|
+
Configure via environment variables:
|
|
10
|
+
NORNWEAVE_ATTACHMENT_STORAGE_BACKEND=local|s3|gcs|database
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from nornweave.core.config import Settings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AttachmentMetadata:
|
|
25
|
+
"""Metadata stored alongside attachment content."""
|
|
26
|
+
|
|
27
|
+
attachment_id: str
|
|
28
|
+
message_id: str
|
|
29
|
+
filename: str
|
|
30
|
+
content_type: str
|
|
31
|
+
content_disposition: str
|
|
32
|
+
content_id: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StorageResult:
|
|
37
|
+
"""Result of storing an attachment."""
|
|
38
|
+
|
|
39
|
+
storage_key: str
|
|
40
|
+
size_bytes: int
|
|
41
|
+
content_hash: str
|
|
42
|
+
backend: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AttachmentStorageBackend(ABC):
|
|
46
|
+
"""Abstract interface for attachment storage backends."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def backend_name(self) -> str:
|
|
51
|
+
"""Return the name of this backend."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def store(
|
|
56
|
+
self,
|
|
57
|
+
attachment_id: str,
|
|
58
|
+
content: bytes,
|
|
59
|
+
metadata: AttachmentMetadata,
|
|
60
|
+
) -> StorageResult:
|
|
61
|
+
"""
|
|
62
|
+
Store attachment content.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
attachment_id: Unique attachment ID
|
|
66
|
+
content: Binary content to store
|
|
67
|
+
metadata: Attachment metadata
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
StorageResult with storage key and metadata
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def retrieve(self, storage_key: str) -> bytes:
|
|
76
|
+
"""
|
|
77
|
+
Retrieve attachment content by storage key.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
storage_key: Key returned from store()
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Binary content
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
FileNotFoundError: If attachment not found
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def delete(self, storage_key: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Delete attachment.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
storage_key: Key returned from store()
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if deleted, False if not found
|
|
100
|
+
"""
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
async def get_download_url(
|
|
105
|
+
self,
|
|
106
|
+
storage_key: str,
|
|
107
|
+
expires_in: timedelta = timedelta(hours=1),
|
|
108
|
+
filename: str | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Generate a download URL for the attachment.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
storage_key: Key returned from store()
|
|
115
|
+
expires_in: How long the URL should be valid
|
|
116
|
+
filename: Optional filename for Content-Disposition header
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
URL string (signed for cloud, API path for local/db)
|
|
120
|
+
"""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
async def exists(self, storage_key: str) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Check if attachment exists in storage.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
storage_key: Key returned from store()
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if exists
|
|
133
|
+
"""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def compute_hash(content: bytes) -> str:
|
|
138
|
+
"""Compute SHA-256 hash of content."""
|
|
139
|
+
return hashlib.sha256(content).hexdigest()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_attachment_storage(settings: Settings) -> AttachmentStorageBackend:
|
|
143
|
+
"""
|
|
144
|
+
Factory function to create configured storage backend.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
settings: Application settings
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Configured AttachmentStorageBackend instance
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If unknown backend specified
|
|
154
|
+
"""
|
|
155
|
+
from nornweave.storage import (
|
|
156
|
+
DatabaseBlobStorage,
|
|
157
|
+
GCSStorage,
|
|
158
|
+
LocalFilesystemStorage,
|
|
159
|
+
S3Storage,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
backend = getattr(settings, "attachment_storage_backend", "local").lower()
|
|
163
|
+
|
|
164
|
+
if backend == "local":
|
|
165
|
+
return LocalFilesystemStorage(
|
|
166
|
+
base_path=getattr(settings, "attachment_local_path", "./data/attachments"),
|
|
167
|
+
serve_url_prefix=getattr(settings, "attachment_serve_url_prefix", "/v1/attachments"),
|
|
168
|
+
)
|
|
169
|
+
elif backend == "s3":
|
|
170
|
+
bucket = getattr(settings, "attachment_s3_bucket", None)
|
|
171
|
+
if not bucket:
|
|
172
|
+
raise ValueError("ATTACHMENT_S3_BUCKET required for S3 backend")
|
|
173
|
+
return S3Storage(
|
|
174
|
+
bucket=bucket,
|
|
175
|
+
prefix=getattr(settings, "attachment_s3_prefix", "attachments"),
|
|
176
|
+
region=getattr(settings, "attachment_s3_region", "us-east-1"),
|
|
177
|
+
access_key=getattr(settings, "attachment_s3_access_key", None),
|
|
178
|
+
secret_key=getattr(settings, "attachment_s3_secret_key", None),
|
|
179
|
+
)
|
|
180
|
+
elif backend == "gcs":
|
|
181
|
+
bucket = getattr(settings, "attachment_gcs_bucket", None)
|
|
182
|
+
if not bucket:
|
|
183
|
+
raise ValueError("ATTACHMENT_GCS_BUCKET required for GCS backend")
|
|
184
|
+
return GCSStorage(
|
|
185
|
+
bucket=bucket,
|
|
186
|
+
prefix=getattr(settings, "attachment_gcs_prefix", "attachments"),
|
|
187
|
+
credentials_path=getattr(settings, "attachment_gcs_credentials_path", None),
|
|
188
|
+
)
|
|
189
|
+
elif backend == "database":
|
|
190
|
+
return DatabaseBlobStorage()
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError(f"Unknown storage backend: {backend}")
|
nornweave/core/utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared utilities."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def slugify(s: str, max_length: int = 64) -> str:
|
|
12
|
+
"""Convert string to a safe slug (lowercase, alphanumeric and hyphens)."""
|
|
13
|
+
s = s.lower().strip()
|
|
14
|
+
s = re.sub(r"[^\w\s-]", "", s)
|
|
15
|
+
s = re.sub(r"[-\s]+", "-", s)
|
|
16
|
+
return s[:max_length].strip("-")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def participant_hash(addresses: Sequence[str]) -> str:
|
|
20
|
+
"""Stable hash of participant addresses for thread grouping."""
|
|
21
|
+
normalized = sorted(a.lower().strip() for a in addresses if a)
|
|
22
|
+
combined = "|".join(normalized)
|
|
23
|
+
return hashlib.sha256(combined.encode()).hexdigest()[:16]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Huginn: MCP read tools (Thought).
|
|
2
|
+
|
|
3
|
+
This module provides MCP resources (read-only data access) for AI agents.
|
|
4
|
+
Named after Odin's raven of thought.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from nornweave.huginn.client import NornWeaveClient
|
|
8
|
+
from nornweave.huginn.config import MCPSettings, get_mcp_settings
|
|
9
|
+
|
|
10
|
+
__all__ = ["MCPSettings", "NornWeaveClient", "get_mcp_settings"]
|