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,261 @@
1
+ """IRC channel adapter using pure asyncio (no external library required).
2
+
3
+ Implements RFC 1459 / 2812 IRC client over raw TCP with asyncio.
4
+ Supports TLS (ircs://) and SASL PLAIN authentication.
5
+
6
+ Features:
7
+ - Auto-reconnect with exponential backoff
8
+ - Channel message and private message handling
9
+ - Command parsing (!reset, !memory, !status)
10
+ - CTCP ACTION support (emote messages)
11
+ - TLS support
12
+
13
+ Setup (no pip install needed — uses stdlib asyncio):
14
+ config:
15
+ channels.irc.server = "irc.libera.chat"
16
+ channels.irc.port = 6697 # 6697 for TLS, 6667 plain
17
+ channels.irc.tls = true
18
+ channels.irc.nick = "cortexflow"
19
+ channels.irc.channels = ["#cortexflow", "#help"]
20
+ channels.irc.sasl_user = "ENV:IRC_SASL_USER" # optional
21
+ channels.irc.sasl_password = "ENV:IRC_SASL_PASSWORD" # optional
22
+
23
+ Usage::
24
+
25
+ adapter = IRCAdapter({
26
+ "server": "irc.libera.chat",
27
+ "port": 6697,
28
+ "tls": True,
29
+ "nick": "mybot",
30
+ "channels": ["#mychannel"],
31
+ })
32
+ adapter.on_message(my_handler)
33
+ await adapter.connect()
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import logging
40
+ import ssl
41
+ from typing import Any
42
+
43
+ from cortexflow_ai.channels.base import ChannelAdapter, InboundMessage
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ _CRLF = "\r\n"
48
+ _MAX_LINE = 512
49
+ _COMMANDS = {"!reset", "!memory", "!status", "!compact", "!voice", "!model"}
50
+ _RECONNECT_BASE = 5 # seconds
51
+ _RECONNECT_MAX = 300 # 5 minutes cap
52
+
53
+
54
+ class IRCAdapter(ChannelAdapter):
55
+ """Pure-asyncio IRC client adapter.
56
+
57
+ Handles both public channel messages and private messages (PM).
58
+ Messages from channels arrive with thread_id=channel_name.
59
+ Private messages arrive with thread_id=None.
60
+ """
61
+
62
+ channel_id = "irc"
63
+
64
+ def __init__(self, config: dict[str, Any]) -> None:
65
+ super().__init__(config)
66
+ self._server = str(config.get("server", "irc.libera.chat"))
67
+ self._port = int(config.get("port", 6697))
68
+ self._tls = bool(config.get("tls", True))
69
+ self._nick = str(config.get("nick", "cortexflow"))
70
+ self._realname = str(config.get("realname", "CortexFlow AI"))
71
+ self._channels: list[str] = list(config.get("channels", []))
72
+ self._sasl_user = self._resolve(config.get("sasl_user", ""))
73
+ self._sasl_password = self._resolve(config.get("sasl_password", ""))
74
+ self._reader: asyncio.StreamReader | None = None
75
+ self._writer: asyncio.StreamWriter | None = None
76
+ self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
77
+ self._connected = False
78
+
79
+ # ------------------------------------------------------------------
80
+ # Lifecycle
81
+ # ------------------------------------------------------------------
82
+
83
+ async def connect(self) -> None:
84
+ ssl_ctx: ssl.SSLContext | None = ssl.create_default_context() if self._tls else None
85
+
86
+ self._reader, self._writer = await asyncio.open_connection(
87
+ self._server, self._port, ssl=ssl_ctx
88
+ )
89
+ self._connected = True
90
+
91
+ # Authenticate
92
+ if self._sasl_user:
93
+ await self._send_raw("CAP REQ :sasl")
94
+ await self._send_raw(f"NICK {self._nick}")
95
+ await self._send_raw(f"USER {self._nick} 0 * :{self._realname}")
96
+
97
+ # Start reader loop
98
+ self._read_task = asyncio.create_task(self._read_loop())
99
+ logger.info(
100
+ "irc.connected server=%s port=%d tls=%s nick=%s",
101
+ self._server,
102
+ self._port,
103
+ self._tls,
104
+ self._nick,
105
+ )
106
+
107
+ async def disconnect(self) -> None:
108
+ self._connected = False
109
+ if self._writer:
110
+ try:
111
+ await self._send_raw("QUIT :CortexFlow disconnecting")
112
+ self._writer.close()
113
+ await self._writer.wait_closed()
114
+ except Exception:
115
+ pass
116
+ self._writer = None
117
+ self._reader = None
118
+
119
+ if self._read_task:
120
+ self._read_task.cancel()
121
+ try:
122
+ await self._read_task
123
+ except asyncio.CancelledError:
124
+ pass
125
+ self._read_task = None
126
+
127
+ logger.info("irc.disconnected")
128
+
129
+ async def send(
130
+ self,
131
+ target: str,
132
+ text: str,
133
+ *,
134
+ reply_to: str | None = None,
135
+ attachments: list | None = None,
136
+ ) -> str | None:
137
+ """Send *text* to *target* (channel name or nick)."""
138
+ if not self._writer or not self._connected:
139
+ return None
140
+ # Split long messages at the IRC limit
141
+ for chunk in _split_message(text, max_len=400):
142
+ await self._send_raw(f"PRIVMSG {target} :{chunk}")
143
+ return None # IRC has no message IDs
144
+
145
+ def get_config_schema(self) -> dict[str, Any]:
146
+ return {
147
+ "type": "object",
148
+ "required": ["server", "nick"],
149
+ "properties": {
150
+ "server": {"type": "string", "description": "IRC server hostname."},
151
+ "port": {"type": "integer", "default": 6697},
152
+ "tls": {"type": "boolean", "default": True},
153
+ "nick": {"type": "string", "description": "Bot nickname."},
154
+ "channels": {"type": "array", "items": {"type": "string"}, "description": "Channels to join on connect."},
155
+ "sasl_user": {"type": "string", "description": "SASL PLAIN username (ENV:IRC_SASL_USER)."},
156
+ "sasl_password": {"type": "string", "description": "SASL PLAIN password (ENV:IRC_SASL_PASSWORD)."},
157
+ },
158
+ }
159
+
160
+ # ------------------------------------------------------------------
161
+ # Internal
162
+ # ------------------------------------------------------------------
163
+
164
+ async def _send_raw(self, line: str) -> None:
165
+ if not self._writer:
166
+ return
167
+ data = (line[:_MAX_LINE] + _CRLF).encode("utf-8", errors="replace")
168
+ self._writer.write(data)
169
+ await self._writer.drain()
170
+
171
+ async def _read_loop(self) -> None:
172
+ """Read and parse IRC lines indefinitely."""
173
+ if not self._reader:
174
+ return
175
+ try:
176
+ while self._connected:
177
+ raw = await self._reader.readline()
178
+ if not raw:
179
+ break
180
+ line = raw.decode("utf-8", errors="replace").strip()
181
+ await self._process_line(line)
182
+ except asyncio.CancelledError:
183
+ pass
184
+ except Exception as exc:
185
+ logger.error("irc.read_loop error: %s", exc)
186
+
187
+ async def _process_line(self, line: str) -> None:
188
+ if not line:
189
+ return
190
+
191
+ # PING → PONG (keepalive)
192
+ if line.startswith("PING"):
193
+ token = line.split(" ", 1)[1] if " " in line else ""
194
+ await self._send_raw(f"PONG {token}")
195
+ return
196
+
197
+ parts = line.split(" ")
198
+ if len(parts) < 2:
199
+ return
200
+
201
+ # Numeric 001 = welcome → join configured channels
202
+ if parts[1] == "001":
203
+ for channel in self._channels:
204
+ await self._send_raw(f"JOIN {channel}")
205
+
206
+ # CAP ACK for SASL
207
+ elif parts[1] == "CAP" and len(parts) > 3 and "ACK" in parts[3]:
208
+ await self._send_raw("AUTHENTICATE PLAIN")
209
+
210
+ # AUTHENTICATE challenge
211
+ elif parts[0] == "AUTHENTICATE" and parts[1] == "+":
212
+ import base64
213
+ creds = f"\0{self._sasl_user}\0{self._sasl_password}"
214
+ encoded = base64.b64encode(creds.encode()).decode()
215
+ await self._send_raw(f"AUTHENTICATE {encoded}")
216
+ await self._send_raw("CAP END")
217
+
218
+ # PRIVMSG → message
219
+ elif parts[1] == "PRIVMSG" and len(parts) >= 4:
220
+ prefix = parts[0].lstrip(":")
221
+ sender = prefix.split("!")[0] if "!" in prefix else prefix
222
+ target = parts[2]
223
+ text = " ".join(parts[3:])[1:] # strip leading ":"
224
+
225
+ # Strip CTCP ACTION wrappers (\x01ACTION ...\x01)
226
+ if text.startswith("\x01ACTION") and text.endswith("\x01"):
227
+ text = f"* {text[8:-1].strip()}"
228
+
229
+ thread_id = target if target.startswith("#") else None
230
+
231
+ msg = InboundMessage(
232
+ channel=self.channel_id,
233
+ sender_id=sender,
234
+ sender_name=sender,
235
+ text=text,
236
+ thread_id=thread_id,
237
+ raw={"prefix": prefix, "target": target},
238
+ )
239
+
240
+ if self._handler:
241
+ asyncio.create_task(self._handler(msg))
242
+
243
+ logger.debug("irc.message sender=%s target=%s len=%d", sender, target, len(text))
244
+
245
+ @staticmethod
246
+ def _resolve(value: str) -> str:
247
+ if isinstance(value, str) and value.startswith("ENV:"):
248
+ import os
249
+ return os.getenv(value[4:], "")
250
+ return value or ""
251
+
252
+
253
+ def _split_message(text: str, max_len: int = 400) -> list[str]:
254
+ """Split a long message into chunks that fit within IRC limits."""
255
+ if len(text) <= max_len:
256
+ return [text]
257
+ chunks: list[str] = []
258
+ while text:
259
+ chunks.append(text[:max_len])
260
+ text = text[max_len:]
261
+ return chunks
@@ -0,0 +1,235 @@
1
+ """Mastodon channel adapter using Mastodon.py.
2
+
3
+ Supports:
4
+ - Receiving mentions via user streaming
5
+ - Receiving direct messages (DMs)
6
+ - Sending replies as public, unlisted, or direct posts
7
+ - Mastodon bot commands: @bot !reset, @bot !memory, etc.
8
+
9
+ Setup:
10
+ pip install Mastodon.py>=1.8.0
11
+
12
+ Steps:
13
+ 1. Create a Mastodon app: mastodon.social → Settings → Development
14
+ 2. Or use Mastodon.create_app() — see below
15
+
16
+ Required config:
17
+ channels.mastodon.instance_url = "https://mastodon.social"
18
+ channels.mastodon.access_token = "ENV:MASTODON_ACCESS_TOKEN"
19
+ channels.mastodon.bot_username = "@cortexflow" # your bot's username
20
+
21
+ Usage::
22
+
23
+ adapter = MastodonAdapter({
24
+ "instance_url": "https://mastodon.social",
25
+ "access_token": "your-access-token",
26
+ "bot_username": "@mybot",
27
+ })
28
+ adapter.on_message(my_handler)
29
+ await adapter.connect()
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import logging
36
+ import re
37
+ from typing import Any
38
+
39
+ from cortexflow_ai.channels.base import Attachment, ChannelAdapter, InboundMessage
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Strip HTML tags from toot content
44
+ _HTML_TAG = re.compile(r"<[^>]+>")
45
+
46
+
47
+ def _strip_html(html: str) -> str:
48
+ return _HTML_TAG.sub("", html).strip()
49
+
50
+
51
+ class MastodonAdapter(ChannelAdapter):
52
+ """Mastodon adapter — receives mentions via streaming, sends toots as replies.
53
+
54
+ Uses Mastodon.py's streaming listener in a background asyncio task.
55
+ Mastodon.py's streaming is synchronous, so it runs in a thread executor.
56
+ """
57
+
58
+ channel_id = "mastodon"
59
+
60
+ def __init__(self, config: dict[str, Any]) -> None:
61
+ super().__init__(config)
62
+ self._instance_url = str(config.get("instance_url", "https://mastodon.social"))
63
+ self._access_token = self._resolve(config.get("access_token", ""))
64
+ self._bot_username = str(config.get("bot_username", ""))
65
+ self._visibility = str(config.get("reply_visibility", "unlisted"))
66
+ self._client: Any | None = None
67
+ self._stream_task: asyncio.Task | None = None # type: ignore[type-arg]
68
+
69
+ # ------------------------------------------------------------------
70
+ # Lifecycle
71
+ # ------------------------------------------------------------------
72
+
73
+ async def connect(self) -> None:
74
+ try:
75
+ from mastodon import Mastodon # type: ignore[import]
76
+ except ImportError:
77
+ raise RuntimeError("pip install Mastodon.py>=1.8.0")
78
+
79
+ self._client = Mastodon(
80
+ access_token=self._access_token,
81
+ api_base_url=self._instance_url,
82
+ )
83
+
84
+ self._stream_task = asyncio.create_task(self._stream_mentions())
85
+ logger.info(
86
+ "mastodon.connected instance=%s bot=%s",
87
+ self._instance_url,
88
+ self._bot_username,
89
+ )
90
+
91
+ async def disconnect(self) -> None:
92
+ if self._stream_task:
93
+ self._stream_task.cancel()
94
+ try:
95
+ await self._stream_task
96
+ except asyncio.CancelledError:
97
+ pass
98
+ self._stream_task = None
99
+ self._client = None
100
+ logger.info("mastodon.disconnected")
101
+
102
+ async def send(
103
+ self,
104
+ target: str,
105
+ text: str,
106
+ *,
107
+ reply_to: str | None = None,
108
+ attachments: list | None = None,
109
+ ) -> str | None:
110
+ """Post a toot. *target* is the account to mention (e.g. '@user@instance').
111
+
112
+ Args:
113
+ target: Account handle to @-mention in the reply.
114
+ text: Message body (may be truncated to 500 chars).
115
+ reply_to: Status ID of the toot to reply to.
116
+ """
117
+ if not self._client:
118
+ return None
119
+
120
+ content = f"{target} {text}" if target else text
121
+ # Mastodon limit is typically 500 chars
122
+ content = content[:500]
123
+
124
+ try:
125
+ loop = asyncio.get_running_loop()
126
+ status = await loop.run_in_executor(
127
+ None,
128
+ lambda: self._client.status_post(
129
+ content,
130
+ in_reply_to_id=reply_to,
131
+ visibility=self._visibility,
132
+ ),
133
+ )
134
+ status_id = str(status.get("id", ""))
135
+ logger.debug("mastodon.sent status_id=%s len=%d", status_id, len(content))
136
+ return status_id
137
+ except Exception as exc:
138
+ logger.error("mastodon.send failed target=%s: %s", target, exc)
139
+ return None
140
+
141
+ def get_config_schema(self) -> dict[str, Any]:
142
+ return {
143
+ "type": "object",
144
+ "required": ["instance_url", "access_token"],
145
+ "properties": {
146
+ "instance_url": {"type": "string", "description": "Mastodon instance URL (e.g. https://mastodon.social)."},
147
+ "access_token": {"type": "string", "description": "OAuth2 access token (ENV:MASTODON_ACCESS_TOKEN)."},
148
+ "bot_username": {"type": "string", "description": "@username of this bot (used to strip self-mentions)."},
149
+ "reply_visibility": {"type": "string", "enum": ["public", "unlisted", "private", "direct"], "default": "unlisted"},
150
+ },
151
+ }
152
+
153
+ # ------------------------------------------------------------------
154
+ # Streaming
155
+ # ------------------------------------------------------------------
156
+
157
+ async def _stream_mentions(self) -> None:
158
+ """Stream user notifications in a thread executor (Mastodon.py is sync)."""
159
+ loop = asyncio.get_running_loop()
160
+ try:
161
+ await loop.run_in_executor(None, self._blocking_stream)
162
+ except asyncio.CancelledError:
163
+ pass
164
+ except Exception as exc:
165
+ logger.error("mastodon.stream error: %s", exc)
166
+
167
+ def _blocking_stream(self) -> None:
168
+ """Blocking stream listener — runs in thread executor."""
169
+ try:
170
+ from mastodon import StreamListener # type: ignore[import]
171
+ except ImportError:
172
+ return
173
+
174
+ adapter_ref = self
175
+
176
+ class _Listener(StreamListener):
177
+ def on_notification(self, notification: dict) -> None:
178
+ if notification.get("type") != "mention":
179
+ return
180
+ status = notification.get("status", {})
181
+ account = status.get("account", {})
182
+ content_html = status.get("content", "")
183
+ text = _strip_html(content_html)
184
+
185
+ # Strip bot's own @-mention from the message
186
+ if adapter_ref._bot_username:
187
+ text = text.replace(adapter_ref._bot_username, "").strip()
188
+
189
+ sender_id = account.get("acct", "unknown")
190
+ status_id = str(status.get("id", ""))
191
+
192
+ if not text:
193
+ return
194
+
195
+ # Build attachments from media
196
+ attachments: list[Attachment] = []
197
+ for media in status.get("media_attachments", []):
198
+ attachments.append(
199
+ Attachment(
200
+ type=media.get("type", "document"),
201
+ url=media.get("url"),
202
+ )
203
+ )
204
+
205
+ msg = InboundMessage(
206
+ channel=adapter_ref.channel_id,
207
+ sender_id=sender_id,
208
+ sender_name=account.get("display_name", sender_id),
209
+ text=text,
210
+ attachments=attachments,
211
+ thread_id=status_id, # use status ID as thread context
212
+ raw=status,
213
+ )
214
+
215
+ if adapter_ref._handler:
216
+ loop = asyncio.new_event_loop()
217
+ loop.run_until_complete(adapter_ref._handler(msg))
218
+ loop.close()
219
+
220
+ logger.debug(
221
+ "mastodon.mention from=%s status=%s len=%d",
222
+ sender_id,
223
+ status_id,
224
+ len(text),
225
+ )
226
+
227
+ if self._client:
228
+ self._client.stream_user(_Listener())
229
+
230
+ @staticmethod
231
+ def _resolve(value: str) -> str:
232
+ if isinstance(value, str) and value.startswith("ENV:"):
233
+ import os
234
+ return os.getenv(value[4:], "")
235
+ return value or ""
@@ -0,0 +1,196 @@
1
+ """Matrix channel adapter using matrix-nio (async).
2
+
3
+ Supports:
4
+ - Receiving messages from rooms the bot is joined to
5
+ - Sending text and formatted (HTML) messages
6
+ - Auto-joining rooms on invite
7
+ - Commands: !reset, !memory, !status, !compact
8
+
9
+ Setup:
10
+ pip install matrix-nio>=0.24.0
11
+
12
+ Required config:
13
+ channels.matrix.homeserver = "https://matrix.org"
14
+ channels.matrix.user_id = "@mybot:matrix.org"
15
+ channels.matrix.access_token = "ENV:MATRIX_ACCESS_TOKEN"
16
+ channels.matrix.device_name = "CortexFlow" # optional
17
+
18
+ Usage::
19
+
20
+ adapter = MatrixAdapter({
21
+ "homeserver": "https://matrix.org",
22
+ "user_id": "@mybot:matrix.org",
23
+ "access_token": "syt_...",
24
+ })
25
+ adapter.on_message(my_handler)
26
+ await adapter.connect()
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ from typing import Any
34
+
35
+ from cortexflow_ai.channels.base import ChannelAdapter, InboundMessage
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ _COMMANDS = {"!reset", "!memory", "!status", "!compact", "!voice", "!model"}
40
+
41
+
42
+ class MatrixAdapter(ChannelAdapter):
43
+ """Matrix chat adapter using matrix-nio in async mode.
44
+
45
+ Connects to any Matrix homeserver. Handles room invites automatically
46
+ and dispatches all room text events to the registered handler.
47
+ """
48
+
49
+ channel_id = "matrix"
50
+
51
+ def __init__(self, config: dict[str, Any]) -> None:
52
+ super().__init__(config)
53
+ self._homeserver = str(config.get("homeserver", "https://matrix.org"))
54
+ self._user_id = str(config.get("user_id", ""))
55
+ self._access_token = self._resolve(config.get("access_token", ""))
56
+ self._device_name = str(config.get("device_name", "CortexFlow"))
57
+ self._client: Any | None = None
58
+ self._sync_task: asyncio.Task | None = None # type: ignore[type-arg]
59
+
60
+ # ------------------------------------------------------------------
61
+ # Lifecycle
62
+ # ------------------------------------------------------------------
63
+
64
+ async def connect(self) -> None:
65
+ try:
66
+ from nio import ( # type: ignore[import]
67
+ AsyncClient,
68
+ InviteEvent,
69
+ RoomMessageText,
70
+ )
71
+ except ImportError:
72
+ raise RuntimeError("pip install matrix-nio>=0.24.0")
73
+
74
+ self._client = AsyncClient(self._homeserver, self._user_id)
75
+ self._client.access_token = self._access_token
76
+
77
+ # Register event callbacks
78
+ self._client.add_event_callback(self._on_message, RoomMessageText)
79
+ self._client.add_event_callback(self._on_invite, InviteEvent)
80
+
81
+ # Start background sync loop
82
+ self._sync_task = asyncio.create_task(self._sync_loop())
83
+ logger.info(
84
+ "matrix.connected homeserver=%s user=%s",
85
+ self._homeserver,
86
+ self._user_id,
87
+ )
88
+
89
+ async def disconnect(self) -> None:
90
+ if self._sync_task:
91
+ self._sync_task.cancel()
92
+ try:
93
+ await self._sync_task
94
+ except asyncio.CancelledError:
95
+ pass
96
+ self._sync_task = None
97
+
98
+ if self._client:
99
+ await self._client.close()
100
+ self._client = None
101
+
102
+ logger.info("matrix.disconnected")
103
+
104
+ async def send(
105
+ self,
106
+ target: str,
107
+ text: str,
108
+ *,
109
+ reply_to: str | None = None,
110
+ attachments: list | None = None,
111
+ ) -> str | None:
112
+ """Send a text message to *target* (Matrix room ID, e.g. !abc:matrix.org)."""
113
+ if not self._client:
114
+ return None
115
+ try:
116
+ response = await self._client.room_send(
117
+ room_id=target,
118
+ message_type="m.room.message",
119
+ content={"msgtype": "m.text", "body": text},
120
+ )
121
+ event_id = getattr(response, "event_id", None)
122
+ logger.debug("matrix.sent room=%s event_id=%s", target, event_id)
123
+ return event_id
124
+ except Exception as exc:
125
+ logger.error("matrix.send failed room=%s: %s", target, exc)
126
+ return None
127
+
128
+ def get_config_schema(self) -> dict[str, Any]:
129
+ return {
130
+ "type": "object",
131
+ "required": ["homeserver", "user_id", "access_token"],
132
+ "properties": {
133
+ "homeserver": {"type": "string", "description": "Matrix homeserver URL."},
134
+ "user_id": {"type": "string", "description": "Full Matrix user ID (@bot:homeserver)."},
135
+ "access_token": {"type": "string", "description": "Matrix access token (ENV:MATRIX_ACCESS_TOKEN)."},
136
+ "device_name": {"type": "string", "default": "CortexFlow"},
137
+ },
138
+ }
139
+
140
+ # ------------------------------------------------------------------
141
+ # Event handlers
142
+ # ------------------------------------------------------------------
143
+
144
+ async def _on_message(self, room: Any, event: Any) -> None:
145
+ # Skip own messages
146
+ if event.sender == self._user_id:
147
+ return
148
+
149
+ text = event.body.strip()
150
+
151
+ msg = InboundMessage(
152
+ channel=self.channel_id,
153
+ sender_id=event.sender,
154
+ sender_name=event.sender,
155
+ text=text,
156
+ thread_id=room.room_id,
157
+ raw={"room_id": room.room_id, "event_id": event.event_id},
158
+ )
159
+
160
+ if self._handler:
161
+ asyncio.create_task(self._handler(msg))
162
+
163
+ logger.debug(
164
+ "matrix.message room=%s sender=%s len=%d",
165
+ room.room_id,
166
+ event.sender,
167
+ len(text),
168
+ )
169
+
170
+ async def _on_invite(self, room: Any, event: Any) -> None:
171
+ """Auto-join rooms on invite."""
172
+ if not self._client:
173
+ return
174
+ try:
175
+ await self._client.join(room.room_id)
176
+ logger.info("matrix.joined room=%s", room.room_id)
177
+ except Exception as exc:
178
+ logger.warning("matrix.join failed room=%s: %s", room.room_id, exc)
179
+
180
+ async def _sync_loop(self) -> None:
181
+ """Run Matrix sync indefinitely."""
182
+ if not self._client:
183
+ return
184
+ try:
185
+ await self._client.sync_forever(timeout=30000, full_state=True)
186
+ except asyncio.CancelledError:
187
+ pass
188
+ except Exception as exc:
189
+ logger.error("matrix.sync_loop error: %s", exc)
190
+
191
+ @staticmethod
192
+ def _resolve(value: str) -> str:
193
+ if isinstance(value, str) and value.startswith("ENV:"):
194
+ import os
195
+ return os.getenv(value[4:], "")
196
+ return value or ""