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.
Files changed (66) hide show
  1. cortexflow_ai/__init__.py +8 -0
  2. cortexflow_ai/agent/__init__.py +1 -0
  3. cortexflow_ai/agent/pipeline.py +194 -0
  4. cortexflow_ai/agent/runtime.py +467 -0
  5. cortexflow_ai/agent/session.py +168 -0
  6. cortexflow_ai/channels/__init__.py +1 -0
  7. cortexflow_ai/channels/base.py +99 -0
  8. cortexflow_ai/channels/discord_.py +145 -0
  9. cortexflow_ai/channels/email_.py +256 -0
  10. cortexflow_ai/channels/irc.py +261 -0
  11. cortexflow_ai/channels/mastodon_.py +235 -0
  12. cortexflow_ai/channels/matrix.py +196 -0
  13. cortexflow_ai/channels/mattermost.py +235 -0
  14. cortexflow_ai/channels/nextcloud.py +297 -0
  15. cortexflow_ai/channels/signal_.py +221 -0
  16. cortexflow_ai/channels/slack.py +214 -0
  17. cortexflow_ai/channels/sms.py +176 -0
  18. cortexflow_ai/channels/teams.py +214 -0
  19. cortexflow_ai/channels/telegram.py +151 -0
  20. cortexflow_ai/channels/webhook.py +201 -0
  21. cortexflow_ai/channels/whatsapp.py +218 -0
  22. cortexflow_ai/cli.py +805 -0
  23. cortexflow_ai/commands/__init__.py +17 -0
  24. cortexflow_ai/commands/handler.py +202 -0
  25. cortexflow_ai/config.py +180 -0
  26. cortexflow_ai/gateway/__init__.py +1 -0
  27. cortexflow_ai/gateway/main.py +110 -0
  28. cortexflow_ai/gateway/routes.py +295 -0
  29. cortexflow_ai/gateway/websocket.py +189 -0
  30. cortexflow_ai/init_wizard.py +261 -0
  31. cortexflow_ai/memory/__init__.py +1 -0
  32. cortexflow_ai/memory/archiver.py +119 -0
  33. cortexflow_ai/memory/compactor.py +188 -0
  34. cortexflow_ai/memory/long_term.py +382 -0
  35. cortexflow_ai/memory/retrieval.py +337 -0
  36. cortexflow_ai/memory/short_term.py +190 -0
  37. cortexflow_ai/memory/tagging.py +101 -0
  38. cortexflow_ai/models/__init__.py +1 -0
  39. cortexflow_ai/models/deepseek.py +180 -0
  40. cortexflow_ai/models/openai_.py +157 -0
  41. cortexflow_ai/models/router.py +451 -0
  42. cortexflow_ai/observability/__init__.py +1 -0
  43. cortexflow_ai/observability/logs.py +161 -0
  44. cortexflow_ai/observability/metrics.py +324 -0
  45. cortexflow_ai/plugins/__init__.py +1 -0
  46. cortexflow_ai/plugins/base.py +101 -0
  47. cortexflow_ai/plugins/registry.py +150 -0
  48. cortexflow_ai/reflection/__init__.py +1 -0
  49. cortexflow_ai/reflection/engine.py +214 -0
  50. cortexflow_ai/tools/__init__.py +1 -0
  51. cortexflow_ai/tools/base.py +114 -0
  52. cortexflow_ai/tools/file_ops.py +180 -0
  53. cortexflow_ai/tools/registry.py +160 -0
  54. cortexflow_ai/tools/web_search.py +140 -0
  55. cortexflow_ai/update_checker.py +58 -0
  56. cortexflow_ai/voice/__init__.py +1 -0
  57. cortexflow_ai/voice/stt.py +106 -0
  58. cortexflow_ai/voice/tts.py +230 -0
  59. cortexflow_ai/voice/wake_word.py +211 -0
  60. cortexflow_ai/workspace.py +158 -0
  61. cortexflow_ai-2.0.0.dist-info/METADATA +609 -0
  62. cortexflow_ai-2.0.0.dist-info/RECORD +66 -0
  63. cortexflow_ai-2.0.0.dist-info/WHEEL +5 -0
  64. cortexflow_ai-2.0.0.dist-info/entry_points.txt +2 -0
  65. cortexflow_ai-2.0.0.dist-info/licenses/LICENSE +105 -0
  66. 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"