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,235 @@
|
|
|
1
|
+
"""Mattermost channel adapter — WebSocket real-time events + REST API.
|
|
2
|
+
|
|
3
|
+
Connects to a Mattermost server using a personal access token or bot token.
|
|
4
|
+
Inbound messages arrive via the Mattermost WebSocket API (``ws(s)://host/api/v4/websocket``).
|
|
5
|
+
Outbound messages use the ``POST /api/v4/posts`` REST endpoint.
|
|
6
|
+
|
|
7
|
+
Config keys:
|
|
8
|
+
url Base URL of the Mattermost server (default: http://localhost:8065)
|
|
9
|
+
token Personal access token or bot token (ENV:MATTERMOST_TOKEN)
|
|
10
|
+
team Team name or ID to filter messages (optional)
|
|
11
|
+
channel Channel name to listen on (default: town-square)
|
|
12
|
+
|
|
13
|
+
The adapter filters out its own bot messages to prevent echo loops.
|
|
14
|
+
|
|
15
|
+
WebSocket event format (Mattermost ≥ 5.x):
|
|
16
|
+
{
|
|
17
|
+
"event": "posted",
|
|
18
|
+
"data": {
|
|
19
|
+
"post": "{\"id\":\"...\",\"user_id\":\"...\",\"channel_id\":\"...\",\"message\":\"Hello\"}",
|
|
20
|
+
"sender_name": "@alice",
|
|
21
|
+
"team_id": "..."
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from cortexflow_ai.channels.base import ChannelAdapter, InboundMessage
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MattermostAdapter(ChannelAdapter):
|
|
41
|
+
"""Mattermost adapter using the native WebSocket + REST v4 API."""
|
|
42
|
+
|
|
43
|
+
channel_id = "mattermost"
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
46
|
+
super().__init__(config)
|
|
47
|
+
self._url: str = config.get("url", "http://localhost:8065").rstrip("/")
|
|
48
|
+
self._token: str = self._resolve(config.get("token", ""))
|
|
49
|
+
self._team: str = config.get("team", "")
|
|
50
|
+
self._channel_name: str = config.get("channel", "town-square")
|
|
51
|
+
self._bot_user_id: str | None = None
|
|
52
|
+
self._ws_task: asyncio.Task | None = None
|
|
53
|
+
self._ws: Any = None
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Lifecycle
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def connect(self) -> None:
|
|
60
|
+
"""Resolve bot identity, then start the WebSocket listener."""
|
|
61
|
+
self._bot_user_id = await self._fetch_bot_user_id()
|
|
62
|
+
self._ws_task = asyncio.create_task(self._ws_loop())
|
|
63
|
+
logger.info("mattermost.connected url=%s", self._url)
|
|
64
|
+
|
|
65
|
+
async def disconnect(self) -> None:
|
|
66
|
+
if self._ws_task:
|
|
67
|
+
self._ws_task.cancel()
|
|
68
|
+
try:
|
|
69
|
+
await self._ws_task
|
|
70
|
+
except asyncio.CancelledError:
|
|
71
|
+
pass
|
|
72
|
+
self._ws_task = None
|
|
73
|
+
if self._ws is not None:
|
|
74
|
+
try:
|
|
75
|
+
await self._ws.close()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
self._ws = None
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Outbound
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def send(
|
|
85
|
+
self,
|
|
86
|
+
target: str,
|
|
87
|
+
text: str,
|
|
88
|
+
*,
|
|
89
|
+
reply_to: str | None = None,
|
|
90
|
+
attachments=None,
|
|
91
|
+
) -> str | None:
|
|
92
|
+
"""Post a message to Mattermost channel *target* (channel_id).
|
|
93
|
+
|
|
94
|
+
Returns the new post ID on success, None on error.
|
|
95
|
+
"""
|
|
96
|
+
if not self._token:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
import httpx
|
|
100
|
+
|
|
101
|
+
payload: dict[str, Any] = {"channel_id": target, "message": text}
|
|
102
|
+
if reply_to:
|
|
103
|
+
payload["root_id"] = reply_to
|
|
104
|
+
|
|
105
|
+
async with httpx.AsyncClient() as client:
|
|
106
|
+
resp = await client.post(
|
|
107
|
+
f"{self._url}/api/v4/posts",
|
|
108
|
+
headers={
|
|
109
|
+
"Authorization": f"Bearer {self._token}",
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
},
|
|
112
|
+
json=payload,
|
|
113
|
+
timeout=15.0,
|
|
114
|
+
)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
return resp.json().get("id")
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.error("mattermost.send failed: %s", exc)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# WebSocket event loop
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async def _ws_loop(self) -> None:
|
|
126
|
+
"""Connect to the Mattermost WebSocket and dispatch posted events."""
|
|
127
|
+
ws_url = self._url.replace("http://", "ws://").replace("https://", "wss://")
|
|
128
|
+
ws_url += "/api/v4/websocket"
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
await self._ws_connect_and_listen(ws_url)
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
return
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.warning("mattermost.ws_error: %s — reconnecting in 5s", exc)
|
|
137
|
+
await asyncio.sleep(5)
|
|
138
|
+
|
|
139
|
+
async def _ws_connect_and_listen(self, ws_url: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
import websockets # type: ignore[import]
|
|
142
|
+
except ImportError:
|
|
143
|
+
raise RuntimeError("websockets package required: pip install websockets")
|
|
144
|
+
|
|
145
|
+
async with websockets.connect(
|
|
146
|
+
ws_url,
|
|
147
|
+
extra_headers={"Authorization": f"Bearer {self._token}"},
|
|
148
|
+
) as ws:
|
|
149
|
+
self._ws = ws
|
|
150
|
+
# Authenticate via challenge message
|
|
151
|
+
await ws.send(json.dumps({
|
|
152
|
+
"seq": 1,
|
|
153
|
+
"action": "authentication_challenge",
|
|
154
|
+
"data": {"token": self._token},
|
|
155
|
+
}))
|
|
156
|
+
async for raw in ws:
|
|
157
|
+
event = json.loads(raw)
|
|
158
|
+
await self._process_event(event)
|
|
159
|
+
|
|
160
|
+
async def _process_event(self, event: dict[str, Any]) -> None:
|
|
161
|
+
if event.get("event") != "posted":
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
data = event.get("data", {})
|
|
165
|
+
post_raw = data.get("post", "{}")
|
|
166
|
+
try:
|
|
167
|
+
post = json.loads(post_raw) if isinstance(post_raw, str) else post_raw
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
user_id = post.get("user_id", "")
|
|
172
|
+
if self._bot_user_id and user_id == self._bot_user_id:
|
|
173
|
+
return # skip own messages
|
|
174
|
+
|
|
175
|
+
text = (post.get("message") or "").strip()
|
|
176
|
+
if not text:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
sender_name = data.get("sender_name", "")
|
|
180
|
+
msg = InboundMessage(
|
|
181
|
+
channel=self.channel_id,
|
|
182
|
+
sender_id=user_id,
|
|
183
|
+
sender_name=sender_name.lstrip("@"),
|
|
184
|
+
text=text,
|
|
185
|
+
thread_id=post.get("channel_id"),
|
|
186
|
+
timestamp=time.time(),
|
|
187
|
+
raw=post,
|
|
188
|
+
)
|
|
189
|
+
asyncio.create_task(self._dispatch(msg))
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# REST helpers
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
async def _fetch_bot_user_id(self) -> str | None:
|
|
196
|
+
if not self._token:
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
import httpx
|
|
200
|
+
|
|
201
|
+
async with httpx.AsyncClient() as client:
|
|
202
|
+
resp = await client.get(
|
|
203
|
+
f"{self._url}/api/v4/users/me",
|
|
204
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
205
|
+
timeout=10.0,
|
|
206
|
+
)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return resp.json().get("id")
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
logger.warning("mattermost.fetch_user_id failed: %s", exc)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Helpers
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _resolve(self, value: str) -> str:
|
|
218
|
+
if isinstance(value, str) and value.startswith("ENV:"):
|
|
219
|
+
return os.getenv(value[4:], "")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
223
|
+
return {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"required": ["token"],
|
|
226
|
+
"properties": {
|
|
227
|
+
"url": {"type": "string", "default": "http://localhost:8065"},
|
|
228
|
+
"token": {
|
|
229
|
+
"type": "string",
|
|
230
|
+
"description": "Personal access token (ENV:MATTERMOST_TOKEN)",
|
|
231
|
+
},
|
|
232
|
+
"team": {"type": "string", "description": "Team name or ID"},
|
|
233
|
+
"channel": {"type": "string", "default": "town-square"},
|
|
234
|
+
},
|
|
235
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Nextcloud Talk channel adapter — OCS REST API polling.
|
|
2
|
+
|
|
3
|
+
CortexFlow connects to Nextcloud Talk rooms by long-polling the chat endpoint.
|
|
4
|
+
There is no persistent WebSocket in the Nextcloud Talk OCS API; the adapter
|
|
5
|
+
uses ``lastKnownMessageId`` to fetch only new messages on each poll cycle.
|
|
6
|
+
|
|
7
|
+
Config keys:
|
|
8
|
+
url Nextcloud base URL (e.g. https://cloud.example.com)
|
|
9
|
+
username Nextcloud username (ENV:NEXTCLOUD_USERNAME)
|
|
10
|
+
password App password or user password (ENV:NEXTCLOUD_PASSWORD)
|
|
11
|
+
room_token Talk room token to poll (e.g. "abc123de")
|
|
12
|
+
poll_interval Seconds between poll requests (default: 5)
|
|
13
|
+
|
|
14
|
+
OCS API endpoints used:
|
|
15
|
+
GET /ocs/v2.php/apps/spreed/api/v1/chat/{token}
|
|
16
|
+
Query params: lookIntoFuture=1, limit=100, lastKnownMessageId=<id>
|
|
17
|
+
POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}
|
|
18
|
+
Body: {"message": "<text>"}
|
|
19
|
+
|
|
20
|
+
Authentication: HTTP Basic Auth (username + app password).
|
|
21
|
+
All requests include ``OCS-APIRequest: true`` and ``Accept: application/json``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from cortexflow_ai.channels.base import ChannelAdapter, InboundMessage
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_OCS_HEADERS = {
|
|
37
|
+
"OCS-APIRequest": "true",
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NextcloudAdapter(ChannelAdapter):
|
|
43
|
+
"""Nextcloud Talk adapter using the OCS v2 REST API with long-polling."""
|
|
44
|
+
|
|
45
|
+
channel_id = "nextcloud"
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
48
|
+
super().__init__(config)
|
|
49
|
+
self._url: str = config.get("url", "https://localhost").rstrip("/")
|
|
50
|
+
self._username: str = self._resolve(config.get("username", ""))
|
|
51
|
+
self._password: str = self._resolve(config.get("password", ""))
|
|
52
|
+
self._room_token: str = config.get("room_token", "")
|
|
53
|
+
self._poll_interval: float = float(config.get("poll_interval", 5))
|
|
54
|
+
self._last_message_id: int = 0
|
|
55
|
+
self._own_user_id: str | None = None
|
|
56
|
+
self._poll_task: asyncio.Task | None = None
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Lifecycle
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def connect(self) -> None:
|
|
63
|
+
"""Resolve own user identity, then start the polling loop."""
|
|
64
|
+
self._own_user_id = await self._fetch_own_user_id()
|
|
65
|
+
self._last_message_id = await self._fetch_last_message_id()
|
|
66
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
67
|
+
logger.info(
|
|
68
|
+
"nextcloud.connected url=%s room=%s", self._url, self._room_token
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def disconnect(self) -> None:
|
|
72
|
+
if self._poll_task:
|
|
73
|
+
self._poll_task.cancel()
|
|
74
|
+
try:
|
|
75
|
+
await self._poll_task
|
|
76
|
+
except asyncio.CancelledError:
|
|
77
|
+
pass
|
|
78
|
+
self._poll_task = None
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Outbound
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def send(
|
|
85
|
+
self,
|
|
86
|
+
target: str,
|
|
87
|
+
text: str,
|
|
88
|
+
*,
|
|
89
|
+
reply_to: str | None = None,
|
|
90
|
+
attachments=None,
|
|
91
|
+
) -> str | None:
|
|
92
|
+
"""Post *text* to the room given by *target* (room token).
|
|
93
|
+
|
|
94
|
+
Returns the new message ID on success, None on error.
|
|
95
|
+
"""
|
|
96
|
+
if not target:
|
|
97
|
+
target = self._room_token
|
|
98
|
+
if not target or not self._username or not self._password:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
import httpx
|
|
102
|
+
|
|
103
|
+
endpoint = (
|
|
104
|
+
f"{self._url}/ocs/v2.php/apps/spreed/api/v1/chat/{target}"
|
|
105
|
+
)
|
|
106
|
+
payload: dict[str, Any] = {"message": text}
|
|
107
|
+
if reply_to:
|
|
108
|
+
payload["replyTo"] = int(reply_to)
|
|
109
|
+
|
|
110
|
+
async with httpx.AsyncClient() as client:
|
|
111
|
+
resp = await client.post(
|
|
112
|
+
endpoint,
|
|
113
|
+
headers=_OCS_HEADERS,
|
|
114
|
+
json=payload,
|
|
115
|
+
auth=(self._username, self._password),
|
|
116
|
+
timeout=15.0,
|
|
117
|
+
)
|
|
118
|
+
resp.raise_for_status()
|
|
119
|
+
data = resp.json()
|
|
120
|
+
msg_id = (
|
|
121
|
+
data.get("ocs", {})
|
|
122
|
+
.get("data", {})
|
|
123
|
+
.get("id")
|
|
124
|
+
)
|
|
125
|
+
return str(msg_id) if msg_id is not None else None
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.error("nextcloud.send failed: %s", exc)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Polling loop
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async def _poll_loop(self) -> None:
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
await self._poll_once()
|
|
138
|
+
except asyncio.CancelledError:
|
|
139
|
+
return
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning("nextcloud.poll_error: %s", exc)
|
|
142
|
+
await asyncio.sleep(self._poll_interval)
|
|
143
|
+
|
|
144
|
+
async def _poll_once(self) -> None:
|
|
145
|
+
if not self._room_token or not self._username or not self._password:
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
import httpx
|
|
149
|
+
|
|
150
|
+
endpoint = (
|
|
151
|
+
f"{self._url}/ocs/v2.php/apps/spreed/api/v1/chat"
|
|
152
|
+
f"/{self._room_token}"
|
|
153
|
+
)
|
|
154
|
+
params: dict[str, Any] = {
|
|
155
|
+
"lookIntoFuture": 1,
|
|
156
|
+
"limit": 100,
|
|
157
|
+
"lastKnownMessageId": self._last_message_id,
|
|
158
|
+
}
|
|
159
|
+
async with httpx.AsyncClient() as client:
|
|
160
|
+
resp = await client.get(
|
|
161
|
+
endpoint,
|
|
162
|
+
headers=_OCS_HEADERS,
|
|
163
|
+
params=params,
|
|
164
|
+
auth=(self._username, self._password),
|
|
165
|
+
timeout=30.0,
|
|
166
|
+
)
|
|
167
|
+
if resp.status_code == 304:
|
|
168
|
+
return # no new messages
|
|
169
|
+
resp.raise_for_status()
|
|
170
|
+
messages = (
|
|
171
|
+
resp.json().get("ocs", {}).get("data", [])
|
|
172
|
+
)
|
|
173
|
+
for msg in messages:
|
|
174
|
+
await self._process_message(msg)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.debug("nextcloud._poll_once error: %s", exc)
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
async def _process_message(self, msg: dict[str, Any]) -> None:
|
|
180
|
+
msg_id = int(msg.get("id", 0))
|
|
181
|
+
if msg_id > self._last_message_id:
|
|
182
|
+
self._last_message_id = msg_id
|
|
183
|
+
|
|
184
|
+
# Only handle regular chat messages from other users
|
|
185
|
+
if msg.get("systemMessage") or msg.get("messageType") == "system":
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
actor_id = msg.get("actorId", "")
|
|
189
|
+
if self._own_user_id and actor_id == self._own_user_id:
|
|
190
|
+
return # skip own messages
|
|
191
|
+
|
|
192
|
+
text = (msg.get("message") or "").strip()
|
|
193
|
+
if not text:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
inbound = InboundMessage(
|
|
197
|
+
channel=self.channel_id,
|
|
198
|
+
sender_id=actor_id,
|
|
199
|
+
sender_name=msg.get("actorDisplayName", ""),
|
|
200
|
+
text=text,
|
|
201
|
+
thread_id=self._room_token,
|
|
202
|
+
timestamp=float(msg.get("timestamp", time.time())),
|
|
203
|
+
raw=msg,
|
|
204
|
+
)
|
|
205
|
+
asyncio.create_task(self._dispatch(inbound))
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# REST helpers
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async def _fetch_own_user_id(self) -> str | None:
|
|
212
|
+
if not self._username or not self._password:
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
import httpx
|
|
216
|
+
|
|
217
|
+
async with httpx.AsyncClient() as client:
|
|
218
|
+
resp = await client.get(
|
|
219
|
+
f"{self._url}/ocs/v2.php/cloud/user",
|
|
220
|
+
headers=_OCS_HEADERS,
|
|
221
|
+
auth=(self._username, self._password),
|
|
222
|
+
timeout=10.0,
|
|
223
|
+
)
|
|
224
|
+
resp.raise_for_status()
|
|
225
|
+
return (
|
|
226
|
+
resp.json().get("ocs", {}).get("data", {}).get("id")
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
logger.warning("nextcloud.fetch_user_id failed: %s", exc)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
async def _fetch_last_message_id(self) -> int:
|
|
233
|
+
"""Fetch the most recent message ID to avoid replaying history on start."""
|
|
234
|
+
if not self._room_token or not self._username or not self._password:
|
|
235
|
+
return 0
|
|
236
|
+
try:
|
|
237
|
+
import httpx
|
|
238
|
+
|
|
239
|
+
endpoint = (
|
|
240
|
+
f"{self._url}/ocs/v2.php/apps/spreed/api/v1/chat"
|
|
241
|
+
f"/{self._room_token}"
|
|
242
|
+
)
|
|
243
|
+
async with httpx.AsyncClient() as client:
|
|
244
|
+
resp = await client.get(
|
|
245
|
+
endpoint,
|
|
246
|
+
headers=_OCS_HEADERS,
|
|
247
|
+
params={"limit": 1, "lookIntoFuture": 0},
|
|
248
|
+
auth=(self._username, self._password),
|
|
249
|
+
timeout=10.0,
|
|
250
|
+
)
|
|
251
|
+
resp.raise_for_status()
|
|
252
|
+
messages = (
|
|
253
|
+
resp.json().get("ocs", {}).get("data", [])
|
|
254
|
+
)
|
|
255
|
+
if messages:
|
|
256
|
+
return int(messages[-1].get("id", 0))
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.warning("nextcloud.fetch_last_id failed: %s", exc)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Helpers
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def _resolve(self, value: str) -> str:
|
|
266
|
+
if isinstance(value, str) and value.startswith("ENV:"):
|
|
267
|
+
return os.getenv(value[4:], "")
|
|
268
|
+
return value
|
|
269
|
+
|
|
270
|
+
def get_config_schema(self) -> dict[str, Any]:
|
|
271
|
+
return {
|
|
272
|
+
"type": "object",
|
|
273
|
+
"required": ["url", "username", "password", "room_token"],
|
|
274
|
+
"properties": {
|
|
275
|
+
"url": {
|
|
276
|
+
"type": "string",
|
|
277
|
+
"description": "Nextcloud base URL",
|
|
278
|
+
},
|
|
279
|
+
"username": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"description": "Nextcloud username (ENV:NEXTCLOUD_USERNAME)",
|
|
282
|
+
},
|
|
283
|
+
"password": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"description": "App password (ENV:NEXTCLOUD_PASSWORD)",
|
|
286
|
+
},
|
|
287
|
+
"room_token": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"description": "Talk room token",
|
|
290
|
+
},
|
|
291
|
+
"poll_interval": {
|
|
292
|
+
"type": "number",
|
|
293
|
+
"default": 5,
|
|
294
|
+
"description": "Seconds between polls",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
}
|