cortexflow-ai 2.0.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.
- cortexflow_ai/__init__.py +8 -0
- cortexflow_ai/agent/__init__.py +1 -0
- cortexflow_ai/agent/pipeline.py +194 -0
- cortexflow_ai/agent/runtime.py +467 -0
- cortexflow_ai/agent/session.py +168 -0
- cortexflow_ai/channels/__init__.py +1 -0
- cortexflow_ai/channels/base.py +99 -0
- cortexflow_ai/channels/discord_.py +145 -0
- cortexflow_ai/channels/email_.py +256 -0
- cortexflow_ai/channels/irc.py +261 -0
- cortexflow_ai/channels/mastodon_.py +235 -0
- cortexflow_ai/channels/matrix.py +196 -0
- cortexflow_ai/channels/mattermost.py +235 -0
- cortexflow_ai/channels/nextcloud.py +297 -0
- cortexflow_ai/channels/signal_.py +221 -0
- cortexflow_ai/channels/slack.py +214 -0
- cortexflow_ai/channels/sms.py +176 -0
- cortexflow_ai/channels/teams.py +214 -0
- cortexflow_ai/channels/telegram.py +151 -0
- cortexflow_ai/channels/webhook.py +201 -0
- cortexflow_ai/channels/whatsapp.py +218 -0
- cortexflow_ai/cli.py +805 -0
- cortexflow_ai/commands/__init__.py +17 -0
- cortexflow_ai/commands/handler.py +202 -0
- cortexflow_ai/config.py +180 -0
- cortexflow_ai/gateway/__init__.py +1 -0
- cortexflow_ai/gateway/main.py +110 -0
- cortexflow_ai/gateway/routes.py +295 -0
- cortexflow_ai/gateway/websocket.py +189 -0
- cortexflow_ai/init_wizard.py +261 -0
- cortexflow_ai/memory/__init__.py +1 -0
- cortexflow_ai/memory/archiver.py +119 -0
- cortexflow_ai/memory/compactor.py +188 -0
- cortexflow_ai/memory/long_term.py +382 -0
- cortexflow_ai/memory/retrieval.py +337 -0
- cortexflow_ai/memory/short_term.py +190 -0
- cortexflow_ai/memory/tagging.py +101 -0
- cortexflow_ai/models/__init__.py +1 -0
- cortexflow_ai/models/deepseek.py +180 -0
- cortexflow_ai/models/openai_.py +157 -0
- cortexflow_ai/models/router.py +451 -0
- cortexflow_ai/observability/__init__.py +1 -0
- cortexflow_ai/observability/logs.py +161 -0
- cortexflow_ai/observability/metrics.py +324 -0
- cortexflow_ai/plugins/__init__.py +1 -0
- cortexflow_ai/plugins/base.py +101 -0
- cortexflow_ai/plugins/registry.py +150 -0
- cortexflow_ai/reflection/__init__.py +1 -0
- cortexflow_ai/reflection/engine.py +214 -0
- cortexflow_ai/tools/__init__.py +1 -0
- cortexflow_ai/tools/base.py +114 -0
- cortexflow_ai/tools/file_ops.py +180 -0
- cortexflow_ai/tools/registry.py +160 -0
- cortexflow_ai/tools/web_search.py +140 -0
- cortexflow_ai/update_checker.py +58 -0
- cortexflow_ai/voice/__init__.py +1 -0
- cortexflow_ai/voice/stt.py +106 -0
- cortexflow_ai/voice/tts.py +230 -0
- cortexflow_ai/voice/wake_word.py +211 -0
- cortexflow_ai/workspace.py +158 -0
- cortexflow_ai-2.0.0.dist-info/METADATA +609 -0
- cortexflow_ai-2.0.0.dist-info/RECORD +66 -0
- cortexflow_ai-2.0.0.dist-info/WHEEL +5 -0
- cortexflow_ai-2.0.0.dist-info/entry_points.txt +2 -0
- cortexflow_ai-2.0.0.dist-info/licenses/LICENSE +105 -0
- cortexflow_ai-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Per-channel session state management.
|
|
2
|
+
|
|
3
|
+
Each active channel connection gets one Session. Sessions hold:
|
|
4
|
+
- Short conversation history (last N turns, kept in memory)
|
|
5
|
+
- The channel ID and sender ID that owns the session
|
|
6
|
+
- Metadata: created_at, last_active, turn count
|
|
7
|
+
|
|
8
|
+
Sessions are intentionally lightweight — heavy memory is offloaded to the
|
|
9
|
+
3-tier MemoryRetrievalPipeline. The in-process history is only the rolling
|
|
10
|
+
window of the current conversation needed for immediate context continuity.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
Role = Literal["user", "assistant", "system"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Turn:
|
|
25
|
+
"""One exchange in a conversation."""
|
|
26
|
+
|
|
27
|
+
role: Role
|
|
28
|
+
content: str
|
|
29
|
+
timestamp: float = field(default_factory=time.time)
|
|
30
|
+
model: str | None = None # model that generated this turn (assistant only)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"role": self.role,
|
|
35
|
+
"content": self.content,
|
|
36
|
+
"timestamp": self.timestamp,
|
|
37
|
+
"model": self.model,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Session:
|
|
42
|
+
"""Conversation state for one user on one channel.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
channel: Channel ID ("telegram", "discord", etc.)
|
|
46
|
+
sender_id: Platform-specific user identifier.
|
|
47
|
+
max_turns: Rolling window size — older turns are dropped.
|
|
48
|
+
Default 20 keeps ~10 back-and-forth exchanges in memory.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
channel: str,
|
|
54
|
+
sender_id: str,
|
|
55
|
+
*,
|
|
56
|
+
max_turns: int = 20,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.session_id: str = str(uuid.uuid4())
|
|
59
|
+
self.channel = channel
|
|
60
|
+
self.sender_id = sender_id
|
|
61
|
+
self.max_turns = max_turns
|
|
62
|
+
self.created_at: float = time.time()
|
|
63
|
+
self.last_active: float = self.created_at
|
|
64
|
+
self.turn_count: int = 0
|
|
65
|
+
self._history: list[Turn] = []
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# History management
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def add_turn(self, role: Role, content: str, *, model: str | None = None) -> Turn:
|
|
72
|
+
"""Append a turn and enforce the rolling window."""
|
|
73
|
+
turn = Turn(role=role, content=content, model=model)
|
|
74
|
+
self._history.append(turn)
|
|
75
|
+
if len(self._history) > self.max_turns:
|
|
76
|
+
self._history = self._history[-self.max_turns:]
|
|
77
|
+
self.last_active = time.time()
|
|
78
|
+
self.turn_count += 1
|
|
79
|
+
return turn
|
|
80
|
+
|
|
81
|
+
def history(self) -> list[Turn]:
|
|
82
|
+
"""Return a copy of the current history window."""
|
|
83
|
+
return list(self._history)
|
|
84
|
+
|
|
85
|
+
def history_as_dicts(self) -> list[dict]:
|
|
86
|
+
"""Return history as a list of dicts, compatible with LLM message arrays."""
|
|
87
|
+
return [t.to_dict() for t in self._history]
|
|
88
|
+
|
|
89
|
+
def clear(self) -> None:
|
|
90
|
+
"""Reset conversation history (e.g., on /reset command)."""
|
|
91
|
+
self._history.clear()
|
|
92
|
+
self.turn_count = 0
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Prompt assembly
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def build_prompt(self, *, include_turns: int | None = None) -> str:
|
|
99
|
+
"""Build a plain-text conversation transcript for LLM prompt injection.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
include_turns: Limit to the last N turns. None = all in window.
|
|
103
|
+
"""
|
|
104
|
+
turns = self._history[-include_turns:] if include_turns else self._history
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
for t in turns:
|
|
107
|
+
prefix = "User" if t.role == "user" else "Assistant"
|
|
108
|
+
lines.append(f"{prefix}: {t.content}")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Properties
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_fresh(self) -> bool:
|
|
117
|
+
"""True if no user turns have been recorded yet."""
|
|
118
|
+
return not any(t.role == "user" for t in self._history)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def idle_seconds(self) -> float:
|
|
122
|
+
return time.time() - self.last_active
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
return (
|
|
126
|
+
f"Session(id={self.session_id[:8]}, channel={self.channel!r}, "
|
|
127
|
+
f"sender={self.sender_id!r}, turns={self.turn_count})"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SessionManager:
|
|
132
|
+
"""Registry of active sessions, keyed by (channel, sender_id).
|
|
133
|
+
|
|
134
|
+
Sessions expire after ``idle_timeout`` seconds to free memory.
|
|
135
|
+
Call ``gc()`` periodically or rely on the runtime to do so.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, idle_timeout: float = 1800.0, max_turns: int = 20) -> None:
|
|
139
|
+
self._idle_timeout = idle_timeout
|
|
140
|
+
self._max_turns = max_turns
|
|
141
|
+
self._sessions: dict[tuple[str, str], Session] = {}
|
|
142
|
+
|
|
143
|
+
def get_or_create(self, channel: str, sender_id: str) -> Session:
|
|
144
|
+
"""Return existing session or create a new one."""
|
|
145
|
+
key = (channel, sender_id)
|
|
146
|
+
if key not in self._sessions:
|
|
147
|
+
self._sessions[key] = Session(channel, sender_id, max_turns=self._max_turns)
|
|
148
|
+
return self._sessions[key]
|
|
149
|
+
|
|
150
|
+
def get(self, channel: str, sender_id: str) -> Session | None:
|
|
151
|
+
return self._sessions.get((channel, sender_id))
|
|
152
|
+
|
|
153
|
+
def remove(self, channel: str, sender_id: str) -> None:
|
|
154
|
+
self._sessions.pop((channel, sender_id), None)
|
|
155
|
+
|
|
156
|
+
def gc(self) -> int:
|
|
157
|
+
"""Remove sessions idle longer than ``idle_timeout``. Returns count removed."""
|
|
158
|
+
expired = [
|
|
159
|
+
key for key, s in self._sessions.items()
|
|
160
|
+
if s.idle_seconds > self._idle_timeout
|
|
161
|
+
]
|
|
162
|
+
for key in expired:
|
|
163
|
+
del self._sessions[key]
|
|
164
|
+
return len(expired)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def active_count(self) -> int:
|
|
168
|
+
return len(self._sessions)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Channel adapters — bridges between messaging platforms and the CortexFlow gateway."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Channel adapter abstract base class and shared data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Attachment:
|
|
13
|
+
"""A file or media attachment from a channel message."""
|
|
14
|
+
|
|
15
|
+
type: str # "image" | "audio" | "video" | "document"
|
|
16
|
+
url: str | None = None
|
|
17
|
+
data: bytes | None = None
|
|
18
|
+
filename: str | None = None
|
|
19
|
+
mime_type: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InboundMessage:
|
|
24
|
+
"""Normalised inbound message from any channel adapter."""
|
|
25
|
+
|
|
26
|
+
channel: str
|
|
27
|
+
sender_id: str
|
|
28
|
+
sender_name: str
|
|
29
|
+
text: str | None
|
|
30
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
31
|
+
thread_id: str | None = None
|
|
32
|
+
reply_to_id: str | None = None
|
|
33
|
+
timestamp: float = field(default_factory=time.time)
|
|
34
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: A coroutine function that handles an inbound message.
|
|
38
|
+
MessageHandler = Callable[[InboundMessage], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChannelAdapter(ABC):
|
|
42
|
+
"""Abstract base for all platform channel adapters.
|
|
43
|
+
|
|
44
|
+
Subclasses must set ``channel_id`` as a class attribute and implement
|
|
45
|
+
``connect``, ``disconnect``, and ``send``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
channel_id: str # e.g. "telegram" | "discord" | "slack"
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
51
|
+
self.config = config
|
|
52
|
+
self._handler: MessageHandler | None = None
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def connect(self) -> None:
|
|
56
|
+
"""Establish connection to the platform. Raises on failure."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def disconnect(self) -> None:
|
|
61
|
+
"""Gracefully disconnect from the platform."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def send(
|
|
66
|
+
self,
|
|
67
|
+
target: str,
|
|
68
|
+
text: str,
|
|
69
|
+
*,
|
|
70
|
+
reply_to: str | None = None,
|
|
71
|
+
attachments: list[Attachment] | None = None,
|
|
72
|
+
) -> str | None:
|
|
73
|
+
"""Send a message to *target* (platform-specific ID).
|
|
74
|
+
|
|
75
|
+
Returns the sent message ID if the platform provides one.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def on_message(self, handler: MessageHandler) -> None:
|
|
80
|
+
"""Register the handler that receives all inbound messages."""
|
|
81
|
+
self._handler = handler
|
|
82
|
+
|
|
83
|
+
async def _dispatch(self, message: InboundMessage) -> None:
|
|
84
|
+
"""Forward *message* to the registered handler (if any)."""
|
|
85
|
+
if self._handler is not None:
|
|
86
|
+
await self._handler(message)
|
|
87
|
+
|
|
88
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
89
|
+
"""Return a JSON Schema describing this adapter's config options."""
|
|
90
|
+
return {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"enabled": {"type": "boolean", "default": False},
|
|
94
|
+
},
|
|
95
|
+
"required": [],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"{self.__class__.__name__}(channel_id={self.channel_id!r})"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Discord channel adapter using discord.py v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cortexflow_ai.channels.base import Attachment, ChannelAdapter, InboundMessage
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiscordAdapter(ChannelAdapter):
|
|
16
|
+
"""Discord Bot adapter.
|
|
17
|
+
|
|
18
|
+
Requires ``discord.py>=2.0`` (``pip install discord.py``).
|
|
19
|
+
|
|
20
|
+
Config keys:
|
|
21
|
+
bot_token (str): Discord bot token. Use ``ENV:DISCORD_BOT_TOKEN``.
|
|
22
|
+
prefix (str): Command prefix for text commands. Default: ``!``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
channel_id = "discord"
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
28
|
+
super().__init__(config)
|
|
29
|
+
self._client: Any | None = None
|
|
30
|
+
self._task: asyncio.Task[None] | None = None
|
|
31
|
+
|
|
32
|
+
async def connect(self) -> None:
|
|
33
|
+
try:
|
|
34
|
+
import discord
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"Discord adapter requires: pip install 'discord.py>=2.0'"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
token = self._resolve(self.config.get("bot_token", ""))
|
|
41
|
+
if not token:
|
|
42
|
+
raise ValueError("channels.discord.bot_token is required (or set DISCORD_BOT_TOKEN)")
|
|
43
|
+
|
|
44
|
+
intents = discord.Intents.default()
|
|
45
|
+
intents.message_content = True
|
|
46
|
+
intents.dm_messages = True
|
|
47
|
+
|
|
48
|
+
class _BotClient(discord.Client):
|
|
49
|
+
def __init__(self_inner, adapter: DiscordAdapter, **kwargs: Any) -> None:
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self_inner._adapter = adapter
|
|
52
|
+
|
|
53
|
+
async def on_ready(self_inner) -> None:
|
|
54
|
+
logger.info("Discord adapter logged in as %s", self_inner.user)
|
|
55
|
+
|
|
56
|
+
async def on_message(self_inner, message: discord.Message) -> None:
|
|
57
|
+
if message.author == self_inner.user:
|
|
58
|
+
return
|
|
59
|
+
attachments = [
|
|
60
|
+
Attachment(
|
|
61
|
+
type=_guess_type(a.content_type or ""),
|
|
62
|
+
url=a.url,
|
|
63
|
+
filename=a.filename,
|
|
64
|
+
mime_type=a.content_type,
|
|
65
|
+
)
|
|
66
|
+
for a in message.attachments
|
|
67
|
+
]
|
|
68
|
+
inbound = InboundMessage(
|
|
69
|
+
channel=self_inner._adapter.channel_id,
|
|
70
|
+
sender_id=str(message.author.id),
|
|
71
|
+
sender_name=str(message.author.display_name),
|
|
72
|
+
text=message.content or None,
|
|
73
|
+
attachments=attachments,
|
|
74
|
+
thread_id=str(message.channel.id),
|
|
75
|
+
reply_to_id=(
|
|
76
|
+
str(message.reference.message_id)
|
|
77
|
+
if message.reference
|
|
78
|
+
else None
|
|
79
|
+
),
|
|
80
|
+
raw={"guild_id": str(message.guild.id) if message.guild else None},
|
|
81
|
+
)
|
|
82
|
+
await self_inner._adapter._dispatch(inbound)
|
|
83
|
+
|
|
84
|
+
self._client = _BotClient(adapter=self, intents=intents)
|
|
85
|
+
self._task = asyncio.create_task(self._client.start(token))
|
|
86
|
+
logger.info("Discord adapter connecting...")
|
|
87
|
+
|
|
88
|
+
async def disconnect(self) -> None:
|
|
89
|
+
if self._client is not None:
|
|
90
|
+
await self._client.close()
|
|
91
|
+
self._client = None
|
|
92
|
+
if self._task is not None:
|
|
93
|
+
self._task.cancel()
|
|
94
|
+
self._task = None
|
|
95
|
+
logger.info("Discord adapter disconnected")
|
|
96
|
+
|
|
97
|
+
async def send(
|
|
98
|
+
self,
|
|
99
|
+
target: str,
|
|
100
|
+
text: str,
|
|
101
|
+
*,
|
|
102
|
+
reply_to: str | None = None,
|
|
103
|
+
attachments: list[Attachment] | None = None,
|
|
104
|
+
) -> str | None:
|
|
105
|
+
if self._client is None:
|
|
106
|
+
raise RuntimeError("DiscordAdapter.connect() has not been called")
|
|
107
|
+
try:
|
|
108
|
+
channel = self._client.get_channel(int(target))
|
|
109
|
+
if channel is None:
|
|
110
|
+
channel = await self._client.fetch_channel(int(target))
|
|
111
|
+
msg = await channel.send(content=text)
|
|
112
|
+
return str(msg.id)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.error("Discord send failed target=%s: %s", target, exc)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _resolve(value: str) -> str:
|
|
119
|
+
if value.startswith("ENV:"):
|
|
120
|
+
return os.getenv(value[4:], "")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"enabled": {"type": "boolean", "default": False},
|
|
128
|
+
"bot_token": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"description": "Bot token from Discord Developer Portal. Use ENV:DISCORD_BOT_TOKEN.",
|
|
131
|
+
},
|
|
132
|
+
"prefix": {"type": "string", "default": "!"},
|
|
133
|
+
},
|
|
134
|
+
"required": ["bot_token"],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _guess_type(content_type: str) -> str:
|
|
139
|
+
if content_type.startswith("image/"):
|
|
140
|
+
return "image"
|
|
141
|
+
if content_type.startswith("audio/"):
|
|
142
|
+
return "audio"
|
|
143
|
+
if content_type.startswith("video/"):
|
|
144
|
+
return "video"
|
|
145
|
+
return "document"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Email channel adapter — IMAP polling (inbound) + SMTP send (outbound).
|
|
2
|
+
|
|
3
|
+
Works with Gmail, Outlook, Fastmail, or any IMAP/SMTP provider.
|
|
4
|
+
|
|
5
|
+
Setup (Gmail example):
|
|
6
|
+
Enable "App Passwords" (requires 2FA):
|
|
7
|
+
https://myaccount.google.com/apppasswords
|
|
8
|
+
|
|
9
|
+
Required config:
|
|
10
|
+
channels.email.imap_host = "imap.gmail.com"
|
|
11
|
+
channels.email.smtp_host = "smtp.gmail.com"
|
|
12
|
+
channels.email.username = "ENV:EMAIL_USER"
|
|
13
|
+
channels.email.password = "ENV:EMAIL_PASSWORD"
|
|
14
|
+
|
|
15
|
+
Optional config:
|
|
16
|
+
poll_interval = 60 # seconds between IMAP checks (default 60)
|
|
17
|
+
imap_port = 993 # default SSL port
|
|
18
|
+
smtp_port = 587 # default STARTTLS port
|
|
19
|
+
mailbox = "INBOX" # folder to watch
|
|
20
|
+
|
|
21
|
+
Requires:
|
|
22
|
+
pip install aiosmtplib aioimaplib
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import email
|
|
29
|
+
import email.policy
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
from email.message import EmailMessage
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from cortexflow_ai.channels.base import Attachment, ChannelAdapter, InboundMessage
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EmailAdapter(ChannelAdapter):
|
|
41
|
+
"""IMAP+SMTP email channel adapter.
|
|
42
|
+
|
|
43
|
+
Polls IMAP on a configurable interval and dispatches new messages.
|
|
44
|
+
Sends replies via SMTP with proper threading headers (In-Reply-To).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
channel_id = "email"
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
50
|
+
super().__init__(config)
|
|
51
|
+
self._imap_host: str = config.get("imap_host", "imap.gmail.com")
|
|
52
|
+
self._imap_port: int = int(config.get("imap_port", 993))
|
|
53
|
+
self._smtp_host: str = config.get("smtp_host", "smtp.gmail.com")
|
|
54
|
+
self._smtp_port: int = int(config.get("smtp_port", 587))
|
|
55
|
+
self._username: str = self._resolve(config.get("username", ""))
|
|
56
|
+
self._password: str = self._resolve(config.get("password", ""))
|
|
57
|
+
self._mailbox: str = config.get("mailbox", "INBOX")
|
|
58
|
+
self._poll_interval: int = int(config.get("poll_interval", 60))
|
|
59
|
+
self._task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
60
|
+
self._seen_uids: set[str] = set()
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Lifecycle
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
async def connect(self) -> None:
|
|
67
|
+
if not self._username or not self._password:
|
|
68
|
+
raise RuntimeError("Email username/password not configured")
|
|
69
|
+
# Quick connectivity check
|
|
70
|
+
await self._imap_check()
|
|
71
|
+
logger.info(
|
|
72
|
+
"EmailAdapter connected user=%s host=%s poll_interval=%ds",
|
|
73
|
+
self._username, self._imap_host, self._poll_interval,
|
|
74
|
+
)
|
|
75
|
+
self._task = asyncio.create_task(self._poll_loop())
|
|
76
|
+
|
|
77
|
+
async def disconnect(self) -> None:
|
|
78
|
+
if self._task:
|
|
79
|
+
self._task.cancel()
|
|
80
|
+
try:
|
|
81
|
+
await self._task
|
|
82
|
+
except asyncio.CancelledError:
|
|
83
|
+
pass
|
|
84
|
+
self._task = None
|
|
85
|
+
logger.info("EmailAdapter disconnected")
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Send
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def send(
|
|
92
|
+
self,
|
|
93
|
+
target: str,
|
|
94
|
+
text: str,
|
|
95
|
+
*,
|
|
96
|
+
reply_to: str | None = None,
|
|
97
|
+
attachments: list[Attachment] | None = None,
|
|
98
|
+
) -> str | None:
|
|
99
|
+
try:
|
|
100
|
+
import aiosmtplib # type: ignore[import]
|
|
101
|
+
except ImportError:
|
|
102
|
+
raise RuntimeError("pip install aiosmtplib")
|
|
103
|
+
|
|
104
|
+
msg = EmailMessage()
|
|
105
|
+
msg["From"] = self._username
|
|
106
|
+
msg["To"] = target
|
|
107
|
+
msg["Subject"] = "Re: CortexFlow" if reply_to else "CortexFlow"
|
|
108
|
+
msg.set_content(text)
|
|
109
|
+
|
|
110
|
+
if reply_to:
|
|
111
|
+
msg["In-Reply-To"] = reply_to
|
|
112
|
+
msg["References"] = reply_to
|
|
113
|
+
|
|
114
|
+
await aiosmtplib.send(
|
|
115
|
+
msg,
|
|
116
|
+
hostname=self._smtp_host,
|
|
117
|
+
port=self._smtp_port,
|
|
118
|
+
username=self._username,
|
|
119
|
+
password=self._password,
|
|
120
|
+
start_tls=True,
|
|
121
|
+
)
|
|
122
|
+
logger.debug("EmailAdapter sent to=%s", target)
|
|
123
|
+
return None # SMTP doesn't return a message ID easily
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Private helpers
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
async def _poll_loop(self) -> None:
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
await self._imap_check()
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
raise
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.warning("EmailAdapter poll error: %s", exc)
|
|
137
|
+
await asyncio.sleep(self._poll_interval)
|
|
138
|
+
|
|
139
|
+
async def _imap_check(self) -> None:
|
|
140
|
+
try:
|
|
141
|
+
import aioimaplib # type: ignore[import]
|
|
142
|
+
except ImportError:
|
|
143
|
+
raise RuntimeError("pip install aioimaplib")
|
|
144
|
+
|
|
145
|
+
client = aioimaplib.IMAP4_SSL(host=self._imap_host, port=self._imap_port)
|
|
146
|
+
await client.wait_hello_from_server()
|
|
147
|
+
await client.login(self._username, self._password)
|
|
148
|
+
await client.select(self._mailbox)
|
|
149
|
+
|
|
150
|
+
# Fetch unseen messages
|
|
151
|
+
_, data = await client.search("UNSEEN")
|
|
152
|
+
if not data or not data[0]:
|
|
153
|
+
await client.logout()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
uids = data[0].decode().split()
|
|
157
|
+
new_uids = [u for u in uids if u not in self._seen_uids]
|
|
158
|
+
|
|
159
|
+
for uid in new_uids:
|
|
160
|
+
self._seen_uids.add(uid)
|
|
161
|
+
try:
|
|
162
|
+
_, msg_data = await client.fetch(uid, "(RFC822)")
|
|
163
|
+
raw = msg_data[1]
|
|
164
|
+
if isinstance(raw, bytes):
|
|
165
|
+
parsed = email.message_from_bytes(raw, policy=email.policy.default)
|
|
166
|
+
await self._dispatch_email(parsed)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.warning("EmailAdapter fetch uid=%s error: %s", uid, exc)
|
|
169
|
+
|
|
170
|
+
await client.logout()
|
|
171
|
+
|
|
172
|
+
async def _dispatch_email(self, msg: Any) -> None:
|
|
173
|
+
sender = str(msg.get("From", ""))
|
|
174
|
+
sender_id = _extract_address(sender)
|
|
175
|
+
subject = str(msg.get("Subject", ""))
|
|
176
|
+
message_id = str(msg.get("Message-ID", ""))
|
|
177
|
+
in_reply_to = str(msg.get("In-Reply-To", "")) or None
|
|
178
|
+
|
|
179
|
+
# Extract plain text body
|
|
180
|
+
body: str | None = None
|
|
181
|
+
attachments: list[Attachment] = []
|
|
182
|
+
|
|
183
|
+
if msg.is_multipart():
|
|
184
|
+
for part in msg.walk():
|
|
185
|
+
ct = part.get_content_type()
|
|
186
|
+
if ct == "text/plain" and body is None:
|
|
187
|
+
body = part.get_content()
|
|
188
|
+
elif ct not in ("text/plain", "text/html", "multipart/mixed", "multipart/alternative"):
|
|
189
|
+
filename = part.get_filename()
|
|
190
|
+
data = part.get_payload(decode=True)
|
|
191
|
+
if data:
|
|
192
|
+
attachments.append(
|
|
193
|
+
Attachment(
|
|
194
|
+
type=_mime_to_type(ct),
|
|
195
|
+
data=data,
|
|
196
|
+
filename=filename,
|
|
197
|
+
mime_type=ct,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
body = msg.get_content()
|
|
202
|
+
|
|
203
|
+
if body:
|
|
204
|
+
body = body.strip()
|
|
205
|
+
|
|
206
|
+
inbound = InboundMessage(
|
|
207
|
+
channel=self.channel_id,
|
|
208
|
+
sender_id=sender_id,
|
|
209
|
+
sender_name=sender_id,
|
|
210
|
+
text=f"Subject: {subject}\n\n{body}" if subject and body else (body or subject or None),
|
|
211
|
+
attachments=attachments,
|
|
212
|
+
thread_id=in_reply_to or message_id,
|
|
213
|
+
reply_to_id=in_reply_to,
|
|
214
|
+
raw={"from": sender, "subject": subject, "message_id": message_id},
|
|
215
|
+
)
|
|
216
|
+
await self._dispatch(inbound)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _resolve(value: str) -> str:
|
|
220
|
+
if isinstance(value, str) and value.startswith("ENV:"):
|
|
221
|
+
return os.getenv(value[4:], "")
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"type": "object",
|
|
227
|
+
"properties": {
|
|
228
|
+
"enabled": {"type": "boolean", "default": False},
|
|
229
|
+
"imap_host": {"type": "string", "default": "imap.gmail.com"},
|
|
230
|
+
"smtp_host": {"type": "string", "default": "smtp.gmail.com"},
|
|
231
|
+
"imap_port": {"type": "integer", "default": 993},
|
|
232
|
+
"smtp_port": {"type": "integer", "default": 587},
|
|
233
|
+
"username": {"type": "string"},
|
|
234
|
+
"password": {"type": "string"},
|
|
235
|
+
"mailbox": {"type": "string", "default": "INBOX"},
|
|
236
|
+
"poll_interval": {"type": "integer", "default": 60},
|
|
237
|
+
},
|
|
238
|
+
"required": ["username", "password"],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_address(header: str) -> str:
|
|
243
|
+
"""Extract raw email address from 'Name <addr>' format."""
|
|
244
|
+
if "<" in header and ">" in header:
|
|
245
|
+
return header.split("<")[1].split(">")[0].strip()
|
|
246
|
+
return header.strip()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _mime_to_type(mime: str) -> str:
|
|
250
|
+
if mime.startswith("image/"):
|
|
251
|
+
return "image"
|
|
252
|
+
if mime.startswith("audio/"):
|
|
253
|
+
return "audio"
|
|
254
|
+
if mime.startswith("video/"):
|
|
255
|
+
return "video"
|
|
256
|
+
return "document"
|