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,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 ""
|