@brantrusnak/openclaw-omadeus 1.0.0

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.
@@ -0,0 +1,63 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ type OpenClawConfig,
4
+ type ReplyPayload,
5
+ type RuntimeEnv,
6
+ } from "../runtime-api.js";
7
+ import { sendOmadeusMessage, type OutboundDeps } from "./outbound.js";
8
+ import { getOmadeusRuntime } from "./runtime.js";
9
+
10
+ type Log = {
11
+ info: (msg: string) => void;
12
+ warn: (msg: string) => void;
13
+ error: (msg: string, extra?: Record<string, unknown>) => void;
14
+ debug?: (msg: string) => void;
15
+ };
16
+
17
+ export type CreateOmadeusReplyDispatcherParams = {
18
+ cfg: OpenClawConfig;
19
+ agentId: string;
20
+ accountId?: string;
21
+ runtime: RuntimeEnv;
22
+ log: Log;
23
+ outboundDeps: OutboundDeps;
24
+ roomId: string;
25
+ };
26
+
27
+ export function createOmadeusReplyDispatcher(params: CreateOmadeusReplyDispatcherParams) {
28
+ const core = getOmadeusRuntime();
29
+ const { cfg, agentId, roomId, accountId } = params;
30
+
31
+ const prefixContext = createReplyPrefixContext({ cfg, agentId });
32
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "omadeus", accountId, {
33
+ fallbackLimit: 4000,
34
+ });
35
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "omadeus");
36
+
37
+ const { dispatcher, replyOptions, markDispatchIdle } =
38
+ core.channel.reply.createReplyDispatcherWithTyping({
39
+ responsePrefix: prefixContext.responsePrefix,
40
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
41
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
42
+ deliver: async (payload: ReplyPayload) => {
43
+ const text = payload.text ?? "";
44
+ if (!text.trim()) return;
45
+
46
+ const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
47
+ for (const chunk of chunks) {
48
+ await sendOmadeusMessage(params.outboundDeps, { to: String(roomId), text: chunk });
49
+ }
50
+ },
51
+ onError: (error, info) => {
52
+ const errMsg = error instanceof Error ? error.message : String(error);
53
+ params.runtime.error?.(`omadeus ${info.kind} reply failed: ${errMsg}`);
54
+ params.log.error("reply failed", { kind: info.kind, error: errMsg });
55
+ },
56
+ });
57
+
58
+ return {
59
+ dispatcher,
60
+ replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
61
+ markDispatchIdle,
62
+ };
63
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ import type { PluginRuntime } from "../runtime-api.js";
3
+
4
+ const { setRuntime: setOmadeusRuntime, getRuntime: getOmadeusRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>("Omadeus runtime not initialized");
6
+
7
+ export { getOmadeusRuntime, setOmadeusRuntime };
@@ -0,0 +1,54 @@
1
+ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/setup";
2
+ import type { OpenClawConfig } from "../runtime-api.js";
3
+
4
+ function readSetupStringField(input: Record<string, unknown>, key: string): string | undefined {
5
+ const value = input[key];
6
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
7
+ }
8
+
9
+ function readSetupNumberField(input: Record<string, unknown>, key: string): number | undefined {
10
+ const value = input[key];
11
+ if (typeof value === "number" && Number.isFinite(value)) {
12
+ return value;
13
+ }
14
+ if (typeof value === "string" && value.trim()) {
15
+ const parsed = Number(value.trim());
16
+ return Number.isFinite(parsed) ? parsed : undefined;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ export const omadeusSetupAdapter: ChannelSetupAdapter = {
22
+ validateInput: ({ input }) => {
23
+ const rawInput = input as Record<string, unknown>;
24
+ const email = readSetupStringField(rawInput, "email");
25
+ if (!email && !input.useEnv) {
26
+ return "Omadeus requires --email (or use OMADEUS_EMAIL env var).";
27
+ }
28
+ return null;
29
+ },
30
+ applyAccountConfig: ({ cfg, input }) => {
31
+ const rawInput = input as Record<string, unknown>;
32
+ const casUrl = input.httpUrl?.trim() || undefined;
33
+ const maestroUrl = input.url?.trim() || undefined;
34
+ const email = readSetupStringField(rawInput, "email");
35
+ const password = input.password?.trim() || undefined;
36
+ const organizationId = readSetupNumberField(rawInput, "organizationId");
37
+
38
+ return {
39
+ ...cfg,
40
+ channels: {
41
+ ...cfg.channels,
42
+ omadeus: {
43
+ ...(cfg.channels as Record<string, unknown>)?.["omadeus"],
44
+ enabled: true,
45
+ ...(casUrl ? { casUrl } : {}),
46
+ ...(maestroUrl ? { maestroUrl } : {}),
47
+ ...(email ? { email } : {}),
48
+ ...(password ? { password } : {}),
49
+ ...(organizationId ? { organizationId } : {}),
50
+ },
51
+ },
52
+ } as OpenClawConfig;
53
+ },
54
+ };
@@ -0,0 +1 @@
1
+ export { omadeusSetupWizard } from "./onboarding.js";
@@ -0,0 +1,31 @@
1
+ import type { OmadeusTokenManager } from "../token.js";
2
+ import { createOmadeusSocketClient, type OmadeusSocketClient } from "./socket.js";
3
+
4
+ export type DolphinSocketOptions = {
5
+ maestroUrl: string;
6
+ tokenManager: OmadeusTokenManager;
7
+ /** Called for every event received on the Dolphin data socket. */
8
+ onEvent?: (data: Record<string, unknown>) => void;
9
+ onConnect?: () => void;
10
+ onDisconnect?: (reason: string) => void;
11
+ onError?: (error: Error) => void;
12
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
13
+ };
14
+
15
+ export type DolphinSocketClient = OmadeusSocketClient;
16
+
17
+ export function createDolphinSocketClient(opts: DolphinSocketOptions): DolphinSocketClient {
18
+ const { maestroUrl, tokenManager, onEvent, onConnect, onDisconnect, onError, log } = opts;
19
+
20
+ return createOmadeusSocketClient({
21
+ maestroUrl,
22
+ tokenManager,
23
+ pathSuffix: "dolphin-ws",
24
+ logPrefix: "[dolphin]",
25
+ onEvent,
26
+ onConnect,
27
+ onDisconnect,
28
+ onError,
29
+ log,
30
+ });
31
+ }
@@ -0,0 +1,49 @@
1
+ import { isOmadeusMessage } from "../inbound.js";
2
+ import type { OmadeusTokenManager } from "../token.js";
3
+ import type { OmadeusMessage } from "../types.js";
4
+ import { createOmadeusSocketClient, type OmadeusSocketClient } from "./socket.js";
5
+
6
+ export type JaguarSocketOptions = {
7
+ maestroUrl: string;
8
+ tokenManager: OmadeusTokenManager;
9
+ onMessage?: (msg: OmadeusMessage) => void;
10
+ /** Called for any non-message events (typing, presence, etc.). */
11
+ onOtherEvent?: (data: Record<string, unknown>) => void;
12
+ onConnect?: () => void;
13
+ onDisconnect?: (reason: string) => void;
14
+ onError?: (error: Error) => void;
15
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
16
+ };
17
+
18
+ export type JaguarSocketClient = OmadeusSocketClient;
19
+
20
+ export function createJaguarSocketClient(opts: JaguarSocketOptions): JaguarSocketClient {
21
+ const {
22
+ maestroUrl,
23
+ tokenManager,
24
+ onMessage,
25
+ onOtherEvent,
26
+ onConnect,
27
+ onDisconnect,
28
+ onError,
29
+ log,
30
+ } = opts;
31
+
32
+ return createOmadeusSocketClient({
33
+ maestroUrl,
34
+ tokenManager,
35
+ pathSuffix: "ws",
36
+ logPrefix: "[jaguar]",
37
+ onEvent: (data) => {
38
+ if (isOmadeusMessage(data)) {
39
+ onMessage?.(data as OmadeusMessage);
40
+ } else {
41
+ onOtherEvent?.(data);
42
+ }
43
+ },
44
+ onConnect,
45
+ onDisconnect,
46
+ onError,
47
+ log,
48
+ });
49
+ }
@@ -0,0 +1,207 @@
1
+ import { WebSocket } from "ws";
2
+ import type { OmadeusTokenManager } from "../token.js";
3
+
4
+ export type OmadeusSocketOptions = {
5
+ maestroUrl: string;
6
+ tokenManager: OmadeusTokenManager;
7
+ /** Path suffix for the websocket endpoint (e.g. "ws" or "dolphin-ws"). */
8
+ pathSuffix: string;
9
+ /** Log prefix, e.g. "[jaguar]" or "[dolphin]". */
10
+ logPrefix: string;
11
+ onEvent?: (data: Record<string, unknown>) => void;
12
+ onConnect?: () => void;
13
+ onDisconnect?: (reason: string) => void;
14
+ onError?: (error: Error) => void;
15
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
16
+ };
17
+
18
+ export type OmadeusSocketClient = {
19
+ connect(): void;
20
+ disconnect(): void;
21
+ isConnected(): boolean;
22
+ /** Send a raw JSON payload over the socket. */
23
+ send(data: unknown): void;
24
+ };
25
+
26
+ const RECONNECT_BASE_MS = 2_000;
27
+ const RECONNECT_MAX_MS = 60_000;
28
+ // Heartbeat tuning knobs for Omadeus sockets.
29
+ const HEARTBEAT_INTERVAL_MS = 30_000;
30
+ const HEARTBEAT_MISSED_MAX = 5;
31
+ const KEEP_ALIVE_CONTENT = "keep-alive";
32
+ const KEEP_ALIVE_ACTION = "answer";
33
+
34
+ function isKeepAliveMessage(data: Record<string, unknown>): boolean {
35
+ const content = (data as { content?: unknown }).content;
36
+ const payloadData = (data as { data?: unknown }).data;
37
+ return content === KEEP_ALIVE_CONTENT || payloadData === KEEP_ALIVE_CONTENT;
38
+ }
39
+
40
+ export function createOmadeusSocketClient(opts: OmadeusSocketOptions): OmadeusSocketClient {
41
+ const {
42
+ maestroUrl,
43
+ tokenManager,
44
+ pathSuffix,
45
+ logPrefix,
46
+ onEvent,
47
+ onConnect,
48
+ onDisconnect,
49
+ onError,
50
+ log,
51
+ } = opts;
52
+
53
+ let ws: WebSocket | null = null;
54
+ let reconnectAttempt = 0;
55
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
56
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
57
+ let heartbeatMissCount = 0;
58
+ let intentionalClose = false;
59
+
60
+ function buildWsUrl(): string {
61
+ const base = maestroUrl.replace(/^http/, "ws");
62
+ const token = tokenManager.getToken();
63
+ return `${base}/${pathSuffix}?token=${encodeURIComponent(token)}`;
64
+ }
65
+
66
+ function scheduleReconnect() {
67
+ if (intentionalClose) return;
68
+ const delayMs = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS);
69
+ reconnectAttempt++;
70
+ log?.info(`${logPrefix} reconnecting in ${delayMs}ms (attempt ${reconnectAttempt})`);
71
+ reconnectTimer = setTimeout(() => connect(), delayMs);
72
+ }
73
+
74
+ function stopHeartbeat() {
75
+ if (heartbeatTimer) {
76
+ clearInterval(heartbeatTimer);
77
+ heartbeatTimer = null;
78
+ }
79
+ }
80
+
81
+ function resetHeartbeat() {
82
+ heartbeatMissCount = 0;
83
+ }
84
+
85
+ function sendKeepAlive() {
86
+ if (ws?.readyState !== WebSocket.OPEN) {
87
+ return;
88
+ }
89
+ heartbeatMissCount += 1;
90
+ ws.send(JSON.stringify({ data: KEEP_ALIVE_CONTENT, action: KEEP_ALIVE_ACTION }));
91
+
92
+ if (heartbeatMissCount >= HEARTBEAT_MISSED_MAX) {
93
+ log?.warn(
94
+ `${logPrefix} heartbeat unanswered ${heartbeatMissCount} times; reconnecting socket`,
95
+ );
96
+ ws.close();
97
+ }
98
+ }
99
+
100
+ function startHeartbeat() {
101
+ stopHeartbeat();
102
+ heartbeatTimer = setInterval(() => {
103
+ sendKeepAlive();
104
+ }, HEARTBEAT_INTERVAL_MS);
105
+ }
106
+
107
+ function connect() {
108
+ if (ws) {
109
+ ws.removeAllListeners();
110
+ ws.close();
111
+ ws = null;
112
+ }
113
+ intentionalClose = false;
114
+ stopHeartbeat();
115
+ resetHeartbeat();
116
+
117
+ if (tokenManager.needsRefresh()) {
118
+ tokenManager
119
+ .refresh()
120
+ .then(() => connect())
121
+ .catch((err) => {
122
+ onError?.(err instanceof Error ? err : new Error(String(err)));
123
+ scheduleReconnect();
124
+ });
125
+ return;
126
+ }
127
+
128
+ const url = buildWsUrl();
129
+ log?.info(`${logPrefix} connecting...`);
130
+
131
+ ws = new WebSocket(url);
132
+
133
+ ws.on("open", () => {
134
+ reconnectAttempt = 0;
135
+ log?.info(`${logPrefix} connected`);
136
+ onConnect?.();
137
+ resetHeartbeat();
138
+ sendKeepAlive();
139
+ startHeartbeat();
140
+ });
141
+
142
+ ws.on("message", (raw) => {
143
+ try {
144
+ const data = JSON.parse(String(raw)) as Record<string, unknown>;
145
+
146
+ const action = (data as { action?: unknown }).action;
147
+ if (isKeepAliveMessage(data) && action === KEEP_ALIVE_ACTION) {
148
+ resetHeartbeat();
149
+ return;
150
+ }
151
+
152
+ // If backend sends heartbeat pings, answer them immediately.
153
+ if (isKeepAliveMessage(data) && action === "heartbeat") {
154
+ if (ws?.readyState === WebSocket.OPEN) {
155
+ ws.send(JSON.stringify({ data: KEEP_ALIVE_CONTENT, action: KEEP_ALIVE_ACTION }));
156
+ }
157
+ return;
158
+ }
159
+
160
+ onEvent?.(data);
161
+ } catch {
162
+ log?.warn(`${logPrefix} unparseable message: ${String(raw).slice(0, 200)}`);
163
+ }
164
+ });
165
+
166
+ ws.on("close", (code, reason) => {
167
+ const msg = `code=${code} reason=${String(reason)}`;
168
+ log?.info(`${logPrefix} disconnected: ${msg}`);
169
+ onDisconnect?.(msg);
170
+ ws = null;
171
+ stopHeartbeat();
172
+ resetHeartbeat();
173
+ scheduleReconnect();
174
+ });
175
+
176
+ ws.on("error", (err) => {
177
+ log?.error(`${logPrefix} error: ${err.message}`);
178
+ onError?.(err);
179
+ });
180
+ }
181
+
182
+ function disconnect() {
183
+ intentionalClose = true;
184
+ if (reconnectTimer) {
185
+ clearTimeout(reconnectTimer);
186
+ reconnectTimer = null;
187
+ }
188
+ stopHeartbeat();
189
+ resetHeartbeat();
190
+ if (ws) {
191
+ ws.removeAllListeners();
192
+ ws.close();
193
+ ws = null;
194
+ }
195
+ }
196
+
197
+ return {
198
+ connect,
199
+ disconnect,
200
+ isConnected: () => ws?.readyState === WebSocket.OPEN,
201
+ send: (data) => {
202
+ if (ws?.readyState === WebSocket.OPEN) {
203
+ ws.send(JSON.stringify(data));
204
+ }
205
+ },
206
+ };
207
+ }
package/src/store.ts ADDED
@@ -0,0 +1,18 @@
1
+ export type CasSession = {
2
+ token: string;
3
+ refreshCookie: string;
4
+ };
5
+
6
+ let currentSession: CasSession | null = null;
7
+
8
+ export function setCasSession(session: CasSession): void {
9
+ currentSession = session;
10
+ }
11
+
12
+ export function getCasSession(): CasSession | null {
13
+ return currentSession;
14
+ }
15
+
16
+ export function clearCasSession(): void {
17
+ currentSession = null;
18
+ }
package/src/token.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { authenticate } from "./auth.js";
2
+ import type { OmadeusJwtPayload } from "./types.js";
3
+ import { decodeJwtPayload, tokenExpiresInMs } from "./utils/jwt.util.js";
4
+
5
+ // Re-authenticate 5 minutes before expiry
6
+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
7
+ // Node.js timers use a 32-bit signed integer for delays; clamp below this to avoid overflow warnings.
8
+ const MAX_TIMEOUT_MS = 2_147_483_647;
9
+
10
+ /** Whether the token should be refreshed now (within safety margin). */
11
+ export function shouldRefreshToken(token: string): boolean {
12
+ return tokenExpiresInMs(token) < TOKEN_REFRESH_MARGIN_MS;
13
+ }
14
+
15
+ export type OmadeusTokenManager = {
16
+ getToken(): string;
17
+ getPayload(): OmadeusJwtPayload;
18
+ refresh(): Promise<void>;
19
+ startAutoRefresh(): void;
20
+ stopAutoRefresh(): void;
21
+ needsRefresh(): boolean;
22
+ };
23
+
24
+ export function createTokenManager(params: {
25
+ casUrl: string;
26
+ maestroUrl: string;
27
+ email: string;
28
+ password: string;
29
+ organizationId: number;
30
+ initialToken?: string;
31
+ onRefresh?: (token: string) => void;
32
+ onError?: (error: Error) => void;
33
+ }): OmadeusTokenManager {
34
+ const { casUrl, maestroUrl, email, password, organizationId, initialToken, onRefresh, onError } =
35
+ params;
36
+
37
+ let currentToken = "";
38
+ let currentPayload: OmadeusJwtPayload | null = null;
39
+ if (initialToken) {
40
+ try {
41
+ const payload = decodeJwtPayload(initialToken);
42
+ currentToken = initialToken;
43
+ currentPayload = payload;
44
+ } catch (err) {
45
+ const error = err instanceof Error ? err : new Error(String(err));
46
+ onError?.(error);
47
+ // Ignore malformed seed token and fall back to authenticate().
48
+ }
49
+ }
50
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
51
+
52
+ const refresh = async () => {
53
+ if (currentToken && !shouldRefreshToken(currentToken)) {
54
+ return;
55
+ }
56
+ const { dolphinToken, payload } = await authenticate({
57
+ casUrl,
58
+ maestroUrl,
59
+ email,
60
+ password,
61
+ organizationId,
62
+ });
63
+ currentToken = dolphinToken;
64
+ currentPayload = payload;
65
+ onRefresh?.(dolphinToken);
66
+ };
67
+
68
+ const scheduleNextRefresh = () => {
69
+ if (refreshTimer) {
70
+ clearTimeout(refreshTimer);
71
+ refreshTimer = null;
72
+ }
73
+ if (!currentToken) return;
74
+
75
+ const expiresInMs = tokenExpiresInMs(currentToken);
76
+ const desiredDelayMs = expiresInMs - TOKEN_REFRESH_MARGIN_MS;
77
+ const refreshInMs = Math.min(Math.max(desiredDelayMs, 10_000), MAX_TIMEOUT_MS);
78
+
79
+ refreshTimer = setTimeout(async () => {
80
+ try {
81
+ await refresh();
82
+ scheduleNextRefresh();
83
+ } catch (err) {
84
+ onError?.(err instanceof Error ? err : new Error(String(err)));
85
+ // Retry in 30s on failure
86
+ refreshTimer = setTimeout(() => void scheduleNextRefresh(), 30_000);
87
+ }
88
+ }, refreshInMs);
89
+ };
90
+
91
+ return {
92
+ getToken() {
93
+ return currentToken;
94
+ },
95
+ getPayload() {
96
+ if (!currentPayload) throw new Error("Omadeus: not authenticated");
97
+ return currentPayload;
98
+ },
99
+ async refresh() {
100
+ try {
101
+ await refresh();
102
+ } catch (err) {
103
+ onError?.(err instanceof Error ? err : new Error(String(err)));
104
+ throw err;
105
+ }
106
+ },
107
+ startAutoRefresh() {
108
+ scheduleNextRefresh();
109
+ },
110
+ stopAutoRefresh() {
111
+ if (refreshTimer) {
112
+ clearTimeout(refreshTimer);
113
+ refreshTimer = null;
114
+ }
115
+ },
116
+ needsRefresh() {
117
+ return !currentToken || shouldRefreshToken(currentToken);
118
+ },
119
+ };
120
+ }