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,172 @@
|
|
|
1
|
+
"""Thread models.
|
|
2
|
+
|
|
3
|
+
Thread models for email conversation grouping.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime # noqa: TC003 - needed at runtime for Pydantic
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nornweave.models.attachment import AttachmentMeta
|
|
13
|
+
from nornweave.models.message import Message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThreadItem(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
Thread summary for list views.
|
|
19
|
+
|
|
20
|
+
Summary model for thread list views.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
inbox_id: str = Field(..., description="ID of inbox")
|
|
24
|
+
thread_id: str = Field(..., alias="id", description="ID of thread")
|
|
25
|
+
labels: list[str] = Field(default_factory=list, description="Labels of thread")
|
|
26
|
+
timestamp: datetime | None = Field(
|
|
27
|
+
None, alias="last_message_at", description="Timestamp of last sent or received message"
|
|
28
|
+
)
|
|
29
|
+
received_timestamp: datetime | None = Field(
|
|
30
|
+
None, description="Timestamp of last received message"
|
|
31
|
+
)
|
|
32
|
+
sent_timestamp: datetime | None = Field(None, description="Timestamp of last sent message")
|
|
33
|
+
senders: list[str] = Field(default_factory=list, description="Senders in thread")
|
|
34
|
+
recipients: list[str] = Field(default_factory=list, description="Recipients in thread")
|
|
35
|
+
subject: str | None = Field(None, description="Subject of thread")
|
|
36
|
+
preview: str | None = Field(None, description="Text preview of last message in thread")
|
|
37
|
+
attachments: list[AttachmentMeta] | None = Field(None, description="Attachments in thread")
|
|
38
|
+
last_message_id: str | None = Field(None, description="ID of last message in thread")
|
|
39
|
+
message_count: int = Field(0, description="Number of messages in thread")
|
|
40
|
+
size: int = Field(0, description="Size of thread in bytes")
|
|
41
|
+
updated_at: datetime | None = Field(None, description="Time at which thread was last updated")
|
|
42
|
+
created_at: datetime | None = Field(None, description="Time at which thread was created")
|
|
43
|
+
|
|
44
|
+
model_config = {"populate_by_name": True}
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def id(self) -> str:
|
|
48
|
+
"""Alias for thread_id for compatibility."""
|
|
49
|
+
return self.thread_id
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def last_message_at(self) -> datetime | None:
|
|
53
|
+
"""Alias for timestamp for backwards compatibility."""
|
|
54
|
+
return self.timestamp
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Thread(BaseModel):
|
|
58
|
+
"""
|
|
59
|
+
Full thread model with messages.
|
|
60
|
+
|
|
61
|
+
Complete thread model with optional message list.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
inbox_id: str = Field(..., description="ID of inbox")
|
|
65
|
+
thread_id: str = Field(..., alias="id", description="ID of thread")
|
|
66
|
+
labels: list[str] = Field(default_factory=list, description="Labels of thread")
|
|
67
|
+
timestamp: datetime | None = Field(
|
|
68
|
+
None, alias="last_message_at", description="Timestamp of last sent or received message"
|
|
69
|
+
)
|
|
70
|
+
received_timestamp: datetime | None = Field(
|
|
71
|
+
None, description="Timestamp of last received message"
|
|
72
|
+
)
|
|
73
|
+
sent_timestamp: datetime | None = Field(None, description="Timestamp of last sent message")
|
|
74
|
+
senders: list[str] = Field(default_factory=list, description="Senders in thread")
|
|
75
|
+
recipients: list[str] = Field(default_factory=list, description="Recipients in thread")
|
|
76
|
+
subject: str | None = Field(None, description="Subject of thread")
|
|
77
|
+
preview: str | None = Field(None, description="Text preview of last message in thread")
|
|
78
|
+
attachments: list[AttachmentMeta] | None = Field(None, description="All attachments in thread")
|
|
79
|
+
last_message_id: str | None = Field(None, description="ID of last message in thread")
|
|
80
|
+
message_count: int = Field(0, description="Number of messages in thread")
|
|
81
|
+
size: int = Field(0, description="Size of thread in bytes")
|
|
82
|
+
updated_at: datetime | None = Field(None, description="Time at which thread was last updated")
|
|
83
|
+
created_at: datetime | None = Field(None, description="Time at which thread was created")
|
|
84
|
+
# Optional for full thread response - messages ordered by timestamp ascending
|
|
85
|
+
messages: list[Message] | None = Field(
|
|
86
|
+
None, description="Messages in thread, ordered by timestamp ascending"
|
|
87
|
+
)
|
|
88
|
+
# Internal fields for threading
|
|
89
|
+
participant_hash: str | None = Field(None, description="Hash of participants for grouping")
|
|
90
|
+
normalized_subject: str | None = Field(
|
|
91
|
+
None, description="Normalized subject for subject-based threading"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
model_config = {"populate_by_name": True}
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def id(self) -> str:
|
|
98
|
+
"""Alias for thread_id for compatibility."""
|
|
99
|
+
return self.thread_id
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def last_message_at(self) -> datetime | None:
|
|
103
|
+
"""Alias for timestamp for backwards compatibility."""
|
|
104
|
+
return self.timestamp
|
|
105
|
+
|
|
106
|
+
@last_message_at.setter
|
|
107
|
+
def last_message_at(self, value: datetime | None) -> None:
|
|
108
|
+
"""Setter for last_message_at (maps to timestamp)."""
|
|
109
|
+
object.__setattr__(self, "timestamp", value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ListThreadsResponse(BaseModel):
|
|
113
|
+
"""
|
|
114
|
+
Response for listing threads.
|
|
115
|
+
|
|
116
|
+
Paginated response for thread listing.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
count: int = Field(..., description="Total count of threads")
|
|
120
|
+
limit: int | None = Field(None, description="Limit applied to results")
|
|
121
|
+
next_page_token: str | None = Field(None, description="Token for next page")
|
|
122
|
+
threads: list[ThreadItem] = Field(..., description="Threads ordered by timestamp descending")
|
|
123
|
+
|
|
124
|
+
model_config = {"extra": "forbid"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class UpdateThreadRequest(BaseModel):
|
|
128
|
+
"""Request to update thread labels."""
|
|
129
|
+
|
|
130
|
+
add_labels: list[str] | None = Field(None, description="Labels to add to thread")
|
|
131
|
+
remove_labels: list[str] | None = Field(None, description="Labels to remove from thread")
|
|
132
|
+
|
|
133
|
+
model_config = {"extra": "forbid"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Legacy compatibility models
|
|
137
|
+
class ThreadBase(BaseModel):
|
|
138
|
+
"""Shared thread fields (legacy compatibility)."""
|
|
139
|
+
|
|
140
|
+
inbox_id: str = Field(..., description="Owning inbox id")
|
|
141
|
+
subject: str = Field(..., description="Thread subject")
|
|
142
|
+
last_message_at: datetime | None = Field(None, description="Last message timestamp")
|
|
143
|
+
participant_hash: str | None = Field(None, description="Hash of participants for grouping")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ThreadCreate(BaseModel):
|
|
147
|
+
"""Payload to create a thread (used on ingestion)."""
|
|
148
|
+
|
|
149
|
+
inbox_id: str = Field(..., description="Owning inbox id")
|
|
150
|
+
subject: str = Field(..., description="Thread subject")
|
|
151
|
+
last_message_at: datetime | None = Field(None, description="Last message timestamp")
|
|
152
|
+
participant_hash: str | None = Field(None, description="Hash of participants for grouping")
|
|
153
|
+
senders: list[str] = Field(default_factory=list, description="Senders in thread")
|
|
154
|
+
recipients: list[str] = Field(default_factory=list, description="Recipients in thread")
|
|
155
|
+
normalized_subject: str | None = Field(None, description="Normalized subject for threading")
|
|
156
|
+
|
|
157
|
+
model_config = {"extra": "forbid"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ThreadSummary(BaseModel):
|
|
161
|
+
"""Summary for list views (e.g. recent threads). Legacy compatibility."""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
subject: str
|
|
165
|
+
last_message_at: datetime | None
|
|
166
|
+
message_count: int = 0
|
|
167
|
+
|
|
168
|
+
model_config = {"extra": "forbid"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Note: Forward reference resolution (model_rebuild) is done in __init__.py
|
|
172
|
+
# after all models are imported to avoid circular import issues.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Muninn: MCP write tools (Memory).
|
|
2
|
+
|
|
3
|
+
This module provides MCP tools (actions) for AI agents.
|
|
4
|
+
Named after Odin's raven of memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from nornweave.muninn.tools import (
|
|
8
|
+
create_inbox,
|
|
9
|
+
search_email,
|
|
10
|
+
send_email,
|
|
11
|
+
wait_for_reply,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["create_inbox", "search_email", "send_email", "wait_for_reply"]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""MCP tools: create_inbox, send_email, search_email, wait_for_reply.
|
|
2
|
+
|
|
3
|
+
Tools provide actions that AI agents can perform.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nornweave.huginn.client import NornWeaveClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def create_inbox(
|
|
16
|
+
client: NornWeaveClient,
|
|
17
|
+
name: str,
|
|
18
|
+
username: str,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Create a new inbox.
|
|
21
|
+
|
|
22
|
+
Provision a new email address for the agent.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
client: NornWeave API client.
|
|
26
|
+
name: Display name for the inbox.
|
|
27
|
+
username: Local part of email address (before @).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Created inbox with id, email_address, name.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
Exception: If inbox creation fails.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
result = await client.create_inbox(name=name, email_username=username)
|
|
37
|
+
return {
|
|
38
|
+
"id": result["id"],
|
|
39
|
+
"email_address": result["email_address"],
|
|
40
|
+
"name": result.get("name"),
|
|
41
|
+
}
|
|
42
|
+
except httpx.HTTPStatusError as e:
|
|
43
|
+
if e.response.status_code == 409:
|
|
44
|
+
raise Exception(f"Email address with username '{username}' already exists") from e
|
|
45
|
+
if e.response.status_code == 422:
|
|
46
|
+
raise Exception(f"Invalid username: {username}") from e
|
|
47
|
+
raise Exception(f"Failed to create inbox: {e.response.status_code}") from e
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def send_email(
|
|
51
|
+
client: NornWeaveClient,
|
|
52
|
+
inbox_id: str,
|
|
53
|
+
recipient: str,
|
|
54
|
+
subject: str,
|
|
55
|
+
body: str,
|
|
56
|
+
thread_id: str | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Send an email.
|
|
59
|
+
|
|
60
|
+
Send an email from an inbox, automatically converting Markdown to HTML.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
client: NornWeave API client.
|
|
64
|
+
inbox_id: The inbox to send from.
|
|
65
|
+
recipient: Email address to send to.
|
|
66
|
+
subject: Email subject.
|
|
67
|
+
body: Markdown content for the email body.
|
|
68
|
+
thread_id: Optional thread ID for replies.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Send response with message_id, thread_id, status.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Exception: If sending fails.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
result = await client.send_message(
|
|
78
|
+
inbox_id=inbox_id,
|
|
79
|
+
to=[recipient],
|
|
80
|
+
subject=subject,
|
|
81
|
+
body=body,
|
|
82
|
+
reply_to_thread_id=thread_id,
|
|
83
|
+
)
|
|
84
|
+
return {
|
|
85
|
+
"message_id": result["id"],
|
|
86
|
+
"thread_id": result["thread_id"],
|
|
87
|
+
"status": result.get("status", "sent"),
|
|
88
|
+
}
|
|
89
|
+
except httpx.HTTPStatusError as e:
|
|
90
|
+
if e.response.status_code == 404:
|
|
91
|
+
raise Exception(f"Inbox '{inbox_id}' not found") from e
|
|
92
|
+
raise Exception(f"Failed to send email: {e.response.status_code}") from e
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def search_email(
|
|
96
|
+
client: NornWeaveClient,
|
|
97
|
+
query: str,
|
|
98
|
+
inbox_id: str,
|
|
99
|
+
limit: int = 10,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Search for emails.
|
|
102
|
+
|
|
103
|
+
Find relevant messages in an inbox by query text.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
client: NornWeave API client.
|
|
107
|
+
query: Search query.
|
|
108
|
+
inbox_id: Inbox to search in.
|
|
109
|
+
limit: Maximum number of results (default: 10).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Search results with matching messages.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
Exception: If search fails.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
result = await client.search_messages(
|
|
119
|
+
query=query,
|
|
120
|
+
inbox_id=inbox_id,
|
|
121
|
+
limit=limit,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Format results for MCP
|
|
125
|
+
messages = []
|
|
126
|
+
for item in result.get("items", []):
|
|
127
|
+
messages.append(
|
|
128
|
+
{
|
|
129
|
+
"id": item["id"],
|
|
130
|
+
"thread_id": item["thread_id"],
|
|
131
|
+
"content": item.get("content_clean", ""),
|
|
132
|
+
"created_at": item.get("created_at"),
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"query": query,
|
|
138
|
+
"count": len(messages),
|
|
139
|
+
"messages": messages,
|
|
140
|
+
}
|
|
141
|
+
except httpx.HTTPStatusError as e:
|
|
142
|
+
if e.response.status_code == 404:
|
|
143
|
+
raise Exception(f"Inbox '{inbox_id}' not found") from e
|
|
144
|
+
raise Exception(f"Search failed: {e.response.status_code}") from e
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def wait_for_reply(
|
|
148
|
+
client: NornWeaveClient,
|
|
149
|
+
thread_id: str,
|
|
150
|
+
timeout_seconds: int = 300,
|
|
151
|
+
poll_interval: int = 5,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""Wait for a reply in a thread (experimental).
|
|
154
|
+
|
|
155
|
+
Block execution until a new email arrives in a thread.
|
|
156
|
+
Uses polling to check for new messages.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
client: NornWeave API client.
|
|
160
|
+
thread_id: Thread to wait on.
|
|
161
|
+
timeout_seconds: Maximum wait time (default: 300 seconds / 5 minutes).
|
|
162
|
+
poll_interval: Seconds between polls (default: 5).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The new message content if received, or timeout indicator.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
Exception: If thread not found.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
# Get initial message count
|
|
172
|
+
initial_count = await client.get_thread_message_count(thread_id)
|
|
173
|
+
except httpx.HTTPStatusError as e:
|
|
174
|
+
if e.response.status_code == 404:
|
|
175
|
+
raise Exception(f"Thread '{thread_id}' not found") from e
|
|
176
|
+
raise Exception(f"Failed to access thread: {e.response.status_code}") from e
|
|
177
|
+
|
|
178
|
+
elapsed = 0
|
|
179
|
+
while elapsed < timeout_seconds:
|
|
180
|
+
await asyncio.sleep(poll_interval)
|
|
181
|
+
elapsed += poll_interval
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
current_count = await client.get_thread_message_count(thread_id)
|
|
185
|
+
|
|
186
|
+
if current_count > initial_count:
|
|
187
|
+
# New message arrived
|
|
188
|
+
latest = await client.get_latest_message(thread_id)
|
|
189
|
+
if latest:
|
|
190
|
+
return {
|
|
191
|
+
"received": True,
|
|
192
|
+
"message": {
|
|
193
|
+
"author": latest.get("author"),
|
|
194
|
+
"content": latest.get("content", ""),
|
|
195
|
+
"timestamp": latest.get("timestamp"),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
except httpx.HTTPStatusError:
|
|
199
|
+
# Ignore transient errors during polling
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
# Timeout reached
|
|
203
|
+
return {
|
|
204
|
+
"received": False,
|
|
205
|
+
"timeout": True,
|
|
206
|
+
"waited_seconds": timeout_seconds,
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Semantic search (Phase 3)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vector embedding generation. Phase 3. Placeholder."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pgvector integration. Phase 3. Placeholder."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Skuld (The Prophecy): API and outbound sending."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rate limiting (token bucket). Phase 3. Placeholder."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scheduled replies. Phase 3. Placeholder."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Email sending via configured provider. Placeholder."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from nornweave.core.interfaces import EmailProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def send_via_provider(
|
|
10
|
+
provider: EmailProvider,
|
|
11
|
+
to: list[str],
|
|
12
|
+
subject: str,
|
|
13
|
+
body: str,
|
|
14
|
+
*,
|
|
15
|
+
from_address: str,
|
|
16
|
+
reply_to: str | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Send email through provider. Returns provider message id."""
|
|
19
|
+
return await provider.send_email(
|
|
20
|
+
to=to,
|
|
21
|
+
subject=subject,
|
|
22
|
+
body=body,
|
|
23
|
+
from_address=from_address,
|
|
24
|
+
reply_to=reply_to,
|
|
25
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Outbound webhook delivery. Phase 3. Placeholder."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Attachment storage backends.
|
|
2
|
+
|
|
3
|
+
NornWeave supports multiple storage backends:
|
|
4
|
+
- LocalFilesystemStorage: Store on local disk (development)
|
|
5
|
+
- S3Storage: Store in AWS S3 (production)
|
|
6
|
+
- GCSStorage: Store in Google Cloud Storage (production)
|
|
7
|
+
- DatabaseBlobStorage: Store as database BLOBs (simple deployment)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from nornweave.storage.database import DatabaseBlobStorage
|
|
11
|
+
from nornweave.storage.gcs import GCSStorage
|
|
12
|
+
from nornweave.storage.local import LocalFilesystemStorage
|
|
13
|
+
from nornweave.storage.s3 import S3Storage
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DatabaseBlobStorage",
|
|
17
|
+
"GCSStorage",
|
|
18
|
+
"LocalFilesystemStorage",
|
|
19
|
+
"S3Storage",
|
|
20
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Database blob storage backend for attachments.
|
|
2
|
+
|
|
3
|
+
Stores attachment content directly in the database as BLOBs.
|
|
4
|
+
Suitable for simple deployments with small attachments.
|
|
5
|
+
Not recommended for large files or high-volume scenarios.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import time
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
from nornweave.core.storage import AttachmentMetadata, AttachmentStorageBackend, StorageResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseBlobStorage(AttachmentStorageBackend):
|
|
18
|
+
"""Store attachments as BLOBs in database.
|
|
19
|
+
|
|
20
|
+
This backend stores attachment content directly in the attachments
|
|
21
|
+
table's 'content' column. Download URLs point to an API endpoint
|
|
22
|
+
that retrieves content from the database.
|
|
23
|
+
|
|
24
|
+
Good for:
|
|
25
|
+
- Simple deployments without external storage
|
|
26
|
+
- Small attachments
|
|
27
|
+
- Development/testing
|
|
28
|
+
|
|
29
|
+
Not recommended for:
|
|
30
|
+
- Large files (>1MB)
|
|
31
|
+
- High-volume production systems
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
serve_url_prefix: str = "/v1/attachments",
|
|
37
|
+
signing_secret: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Initialize database storage.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
serve_url_prefix: URL prefix for download URLs
|
|
44
|
+
signing_secret: Secret for signing download URLs
|
|
45
|
+
"""
|
|
46
|
+
self.serve_url_prefix = serve_url_prefix.rstrip("/")
|
|
47
|
+
self._signing_secret = signing_secret or "default-signing-secret"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def backend_name(self) -> str:
|
|
51
|
+
return "database"
|
|
52
|
+
|
|
53
|
+
async def store(
|
|
54
|
+
self,
|
|
55
|
+
attachment_id: str,
|
|
56
|
+
content: bytes,
|
|
57
|
+
_metadata: AttachmentMetadata,
|
|
58
|
+
) -> StorageResult:
|
|
59
|
+
"""
|
|
60
|
+
Store attachment in database.
|
|
61
|
+
|
|
62
|
+
Note: The actual database write is handled by the storage layer
|
|
63
|
+
that creates the AttachmentORM record. This method just returns
|
|
64
|
+
the storage result with the attachment_id as the key.
|
|
65
|
+
|
|
66
|
+
The caller is responsible for setting the 'content' column
|
|
67
|
+
on the AttachmentORM model before committing.
|
|
68
|
+
"""
|
|
69
|
+
return StorageResult(
|
|
70
|
+
storage_key=attachment_id, # Use attachment_id as the key
|
|
71
|
+
size_bytes=len(content),
|
|
72
|
+
content_hash=self.compute_hash(content),
|
|
73
|
+
backend=self.backend_name,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def retrieve(self, storage_key: str) -> bytes:
|
|
77
|
+
"""
|
|
78
|
+
Retrieve attachment from database.
|
|
79
|
+
|
|
80
|
+
Note: This method would normally query the database, but since
|
|
81
|
+
we want to avoid direct database access in the storage backend,
|
|
82
|
+
the actual retrieval should be done through the storage layer.
|
|
83
|
+
|
|
84
|
+
This implementation is a placeholder that should be overridden
|
|
85
|
+
by the application code that has access to the database session.
|
|
86
|
+
"""
|
|
87
|
+
raise NotImplementedError(
|
|
88
|
+
"Database storage retrieval should be done through the storage layer"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def delete(self, storage_key: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Delete attachment from database.
|
|
94
|
+
|
|
95
|
+
Note: The actual database delete is handled by cascade when
|
|
96
|
+
the message is deleted, or explicitly through the storage layer.
|
|
97
|
+
"""
|
|
98
|
+
raise NotImplementedError(
|
|
99
|
+
"Database storage deletion should be done through the storage layer"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def get_download_url(
|
|
103
|
+
self,
|
|
104
|
+
storage_key: str,
|
|
105
|
+
expires_in: timedelta = timedelta(hours=1),
|
|
106
|
+
filename: str | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Generate a signed download URL for API-based retrieval."""
|
|
109
|
+
attachment_id = storage_key
|
|
110
|
+
|
|
111
|
+
# Create signed token
|
|
112
|
+
expiry = int(time.time() + expires_in.total_seconds())
|
|
113
|
+
signature = self._sign_url(attachment_id, expiry)
|
|
114
|
+
|
|
115
|
+
# Build URL with query params
|
|
116
|
+
params = {"token": signature, "expires": str(expiry)}
|
|
117
|
+
if filename:
|
|
118
|
+
params["filename"] = filename
|
|
119
|
+
|
|
120
|
+
return f"{self.serve_url_prefix}/{attachment_id}/download?{urlencode(params)}"
|
|
121
|
+
|
|
122
|
+
async def exists(self, storage_key: str) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
Check if attachment exists in database.
|
|
125
|
+
|
|
126
|
+
Note: Should be done through the storage layer with database access.
|
|
127
|
+
"""
|
|
128
|
+
raise NotImplementedError(
|
|
129
|
+
"Database storage existence check should be done through the storage layer"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _sign_url(self, attachment_id: str, expiry: int) -> str:
|
|
133
|
+
"""Create HMAC signature for URL."""
|
|
134
|
+
message = f"{attachment_id}:{expiry}"
|
|
135
|
+
signature = hmac.new(
|
|
136
|
+
self._signing_secret.encode(),
|
|
137
|
+
message.encode(),
|
|
138
|
+
hashlib.sha256,
|
|
139
|
+
).hexdigest()[:32]
|
|
140
|
+
return signature
|
|
141
|
+
|
|
142
|
+
def verify_signed_url(
|
|
143
|
+
self,
|
|
144
|
+
attachment_id: str,
|
|
145
|
+
token: str,
|
|
146
|
+
expires: int,
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Verify a signed download URL.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
attachment_id: The attachment ID from the URL
|
|
153
|
+
token: The signature token from the URL
|
|
154
|
+
expires: The expiry timestamp from the URL
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True if signature is valid and not expired
|
|
158
|
+
"""
|
|
159
|
+
# Check expiry
|
|
160
|
+
if expires < time.time():
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# Verify signature
|
|
164
|
+
expected = self._sign_url(attachment_id, expires)
|
|
165
|
+
return hmac.compare_digest(token, expected)
|