@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.
package/src/channel.ts ADDED
@@ -0,0 +1,817 @@
1
+ import {
2
+ createTopLevelChannelConfigAdapter,
3
+ } from "openclaw/plugin-sdk/channel-config-helpers";
4
+ import {
5
+ type ChannelStatusIssue,
6
+ } from "openclaw/plugin-sdk/channel-runtime";
7
+ import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
8
+ import { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
9
+ import {
10
+ buildPassiveChannelStatusSummary,
11
+ buildTrafficStatusSummary,
12
+ } from "openclaw/plugin-sdk/extension-shared";
13
+ import {
14
+ DEFAULT_ACCOUNT_ID,
15
+ missingTargetError,
16
+ type ChannelPlugin,
17
+ type OpenClawConfig,
18
+ } from "../runtime-api.js";
19
+ import {
20
+ ALLOWED_OMADEUS_REACTION_EMOJI_LIST,
21
+ isAllowedOmadeusReactionEmoji,
22
+ } from "./allowed-reaction-emojis.js";
23
+ import {
24
+ createNugget,
25
+ resolveTaskRoomIdByNumber,
26
+ type OmadeusNuggetKind,
27
+ type OmadeusNuggetPriority,
28
+ } from "./api/nugget.api.js";
29
+ import { addMessageReaction, deleteMessage, editMessage } from "./api/message.api.js";
30
+ import {
31
+ listOmadeusAccountIds,
32
+ resolveDefaultOmadeusAccountId,
33
+ resolveOmadeusAccount,
34
+ } from "./config.js";
35
+ import { parseJaguarMessage } from "./inbound.js";
36
+ import { createOmadeusMessageHandler } from "./message-handler.js";
37
+ import { parseTaskChannelTargetIntent } from "./nugget-lookup.js";
38
+ import { sendOmadeusMessage, type OutboundDeps } from "./outbound.js";
39
+ import { getOmadeusRuntime } from "./runtime.js";
40
+ import { omadeusSetupAdapter } from "./setup-core.js";
41
+ import { omadeusSetupWizard } from "./setup-surface.js";
42
+ import { createDolphinSocketClient, type DolphinSocketClient } from "./socket/dolphin.socket.js";
43
+ import { createJaguarSocketClient, type JaguarSocketClient } from "./socket/jaguar.socket.js";
44
+ import { createTokenManager, type OmadeusTokenManager } from "./token.js";
45
+ import type { ResolvedOmadeusAccount as Account } from "./types.js";
46
+
47
+ // Gateway-scoped state for the running account
48
+ let activeTokenManager: OmadeusTokenManager | null = null;
49
+ let activeDolphin: DolphinSocketClient | null = null;
50
+ let activeJaguar: JaguarSocketClient | null = null;
51
+
52
+ async function persistSessionToken(token: string): Promise<void> {
53
+ const runtime = getOmadeusRuntime();
54
+ const cfg = runtime.config.loadConfig();
55
+ const section = ((cfg.channels as Record<string, unknown> | undefined)?.["omadeus"] ??
56
+ {}) as Record<string, unknown>;
57
+ if (section["sessionToken"] === token) {
58
+ return;
59
+ }
60
+
61
+ await runtime.config.writeConfigFile({
62
+ ...cfg,
63
+ channels: {
64
+ ...cfg.channels,
65
+ omadeus: {
66
+ ...section,
67
+ sessionToken: token,
68
+ },
69
+ },
70
+ } as OpenClawConfig);
71
+ }
72
+
73
+ const omadeusConfigAdapter = createTopLevelChannelConfigAdapter<Account>({
74
+ sectionKey: "omadeus",
75
+ resolveAccount: (cfg) => resolveOmadeusAccount({ cfg }),
76
+ listAccountIds: listOmadeusAccountIds,
77
+ defaultAccountId: resolveDefaultOmadeusAccountId,
78
+ deleteMode: "clear-fields",
79
+ clearBaseFields: [
80
+ "casUrl",
81
+ "maestroUrl",
82
+ "email",
83
+ "password",
84
+ "organizationId",
85
+ "sessionToken",
86
+ "selectedMemberReferenceId",
87
+ "selectedChannelViewId",
88
+ "selectedChannelTitle",
89
+ "selectedChannelPrivateRoomId",
90
+ "selectedChannelPublicRoomId",
91
+ ],
92
+ // Keep adapter contract satisfied even though Omadeus no longer uses DM allowlists.
93
+ resolveAllowFrom: () => [],
94
+ formatAllowFrom: () => [],
95
+ });
96
+
97
+ const defaultRuntimeState = {
98
+ accountId: DEFAULT_ACCOUNT_ID,
99
+ running: false,
100
+ connected: false,
101
+ lastConnectedAt: null,
102
+ lastStartAt: null,
103
+ lastStopAt: null,
104
+ lastInboundAt: null,
105
+ lastOutboundAt: null,
106
+ lastError: null,
107
+ } as const;
108
+
109
+ /** Normalize Jaguar chat target: `room:123` or `123` -> `123` (numeric room id for APIs). */
110
+ function normalizeOmadeusRoomId(raw: string): string | undefined {
111
+ const trimmed = raw.trim();
112
+ if (!trimmed) {
113
+ return undefined;
114
+ }
115
+ const prefixed = /^room:(\d+)$/i.exec(trimmed);
116
+ if (prefixed) {
117
+ return prefixed[1];
118
+ }
119
+ return /^\d+$/.test(trimmed) ? trimmed : undefined;
120
+ }
121
+
122
+ function readReactionMessageId(
123
+ params: Record<string, unknown>,
124
+ toolContext?: { currentMessageId?: string | number },
125
+ ): number | undefined {
126
+ const raw = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
127
+ if (raw == null) {
128
+ return undefined;
129
+ }
130
+ const n = typeof raw === "number" ? raw : Number(String(raw).trim());
131
+ return Number.isFinite(n) ? n : undefined;
132
+ }
133
+
134
+ function readStringParam(params: Record<string, unknown>, keys: string[]): string | undefined {
135
+ for (const key of keys) {
136
+ const value = params[key];
137
+ if (typeof value === "string" && value.trim()) {
138
+ return value.trim();
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function readNumberParam(params: Record<string, unknown>, keys: string[]): number | undefined {
145
+ for (const key of keys) {
146
+ const value = params[key];
147
+ if (typeof value === "number" && Number.isFinite(value)) {
148
+ return value;
149
+ }
150
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
151
+ return Number(value.trim());
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function readNuggetKind(params: Record<string, unknown>): OmadeusNuggetKind {
158
+ const raw = readStringParam(params, ["kind", "entity", "type"])?.toLowerCase();
159
+ return raw === "nugget" ? "nugget" : "task";
160
+ }
161
+
162
+ function readNuggetPriority(params: Record<string, unknown>): OmadeusNuggetPriority {
163
+ const raw = readStringParam(params, ["priority"])?.toLowerCase();
164
+ if (raw === "urgent" || raw === "high" || raw === "medium" || raw === "low") {
165
+ return raw;
166
+ }
167
+ return "low";
168
+ }
169
+
170
+ function isCreateNuggetRequest(params: Record<string, unknown>): boolean {
171
+ const op = readStringParam(params, ["op", "operation", "intent"])?.toLowerCase();
172
+ if (op === "create_nugget" || op === "create_task") {
173
+ return true;
174
+ }
175
+ const create =
176
+ params["createNugget"] === true ||
177
+ params["createTask"] === true ||
178
+ params["create"] === true ||
179
+ readStringParam(params, ["actionType", "mode"])?.toLowerCase() === "create";
180
+ if (create) {
181
+ return true;
182
+ }
183
+ return Boolean(readStringParam(params, ["title"]) && readStringParam(params, ["description"]));
184
+ }
185
+
186
+ export const omadeusPlugin: ChannelPlugin<Account> = {
187
+ id: "omadeus",
188
+ meta: {
189
+ id: "omadeus",
190
+ label: "Omadeus",
191
+ selectionLabel: "Omadeus (WebSocket)",
192
+ docsPath: "/channels/omadeus",
193
+ docsLabel: "omadeus",
194
+ blurb: "Omadeus project management — tasks, chat rooms, and sprints.",
195
+ },
196
+ capabilities: {
197
+ chatTypes: ["direct", "group"],
198
+ reactions: true,
199
+ threads: false,
200
+ media: false,
201
+ nativeCommands: false,
202
+ blockStreaming: true,
203
+ },
204
+ agentPrompt: {
205
+ messageToolHints: () => [
206
+ "- Omadeus routing: **send** uses **room id** (`to` / `target`, e.g. `room:117947` or `117947`). **edit**, **delete**, **react** use the Jaguar **message** `id` (`messageId`, or the current inbound message from context).",
207
+ "- Create Omadeus task/nugget: use `action=send` with params `{ op: \"create_task\"|\"create_nugget\", title, description, priority?, stage?, kind?, memberReferenceId?, clientId?, folderId? }`.",
208
+ `- Reactions only allow these emojis (others are ignored): ${ALLOWED_OMADEUS_REACTION_EMOJI_LIST.join(" ")}`,
209
+ "- Reply in chat with plain text; use the message tool for proactive sends, edits, deletes, or reactions.",
210
+ ],
211
+ },
212
+ actions: {
213
+ describeMessageTool: ({ cfg }) => {
214
+ const enabled =
215
+ cfg.channels?.omadeus?.enabled !== false &&
216
+ resolveOmadeusAccount({ cfg }).credentialSource !== "none";
217
+ return {
218
+ actions: enabled ? ["send", "edit", "delete", "react"] : [],
219
+ capabilities: [],
220
+ schema: null,
221
+ };
222
+ },
223
+ handleAction: async (ctx) => {
224
+ const apiOptsForAccount = () => {
225
+ const account = resolveOmadeusAccount({ cfg: ctx.cfg });
226
+ if (!activeTokenManager) {
227
+ throw new Error("Omadeus: not connected; gateway must be running with Omadeus enabled.");
228
+ }
229
+ return { maestroUrl: account.maestroUrl, tokenManager: activeTokenManager };
230
+ };
231
+
232
+ if (ctx.action === "send" && isCreateNuggetRequest(ctx.params)) {
233
+ const title = readStringParam(ctx.params, ["title", "subject", "name"]);
234
+ const description = readStringParam(ctx.params, ["description", "details", "body"]);
235
+ if (!title || !description) {
236
+ return {
237
+ isError: true,
238
+ content: [
239
+ {
240
+ type: "text" as const,
241
+ text: "Omadeus create task/nugget requires `title` and `description`.",
242
+ },
243
+ ],
244
+ details: { error: "Missing title/description." },
245
+ };
246
+ }
247
+
248
+ const kind = readNuggetKind(ctx.params);
249
+ const priority = readNuggetPriority(ctx.params);
250
+ const stage = readStringParam(ctx.params, ["stage"]) ?? "Triage";
251
+ const memberReferenceId =
252
+ readNumberParam(ctx.params, ["memberReferenceId", "assigneeReferenceId"]) ??
253
+ activeTokenManager?.getPayload().referenceId;
254
+ const clientId = readNumberParam(ctx.params, ["clientId"]) ?? 1;
255
+ const folderId = readNumberParam(ctx.params, ["folderId"]) ?? 1;
256
+
257
+ if (!memberReferenceId) {
258
+ return {
259
+ isError: true,
260
+ content: [
261
+ {
262
+ type: "text" as const,
263
+ text: "Omadeus create task/nugget needs `memberReferenceId` or an active authenticated user.",
264
+ },
265
+ ],
266
+ details: { error: "Missing memberReferenceId." },
267
+ };
268
+ }
269
+
270
+ try {
271
+ const created = await createNugget(apiOptsForAccount(), {
272
+ title,
273
+ description,
274
+ stage,
275
+ kind,
276
+ priority,
277
+ memberReferenceId,
278
+ clientId,
279
+ folderId,
280
+ });
281
+ const number = created["number"];
282
+ const id = created["id"];
283
+ return {
284
+ content: [
285
+ {
286
+ type: "text" as const,
287
+ text: JSON.stringify({
288
+ ok: true,
289
+ channel: "omadeus",
290
+ action: "create",
291
+ kind,
292
+ number,
293
+ id,
294
+ title,
295
+ }),
296
+ },
297
+ ],
298
+ details: {
299
+ ok: true,
300
+ channel: "omadeus",
301
+ action: "create",
302
+ kind,
303
+ number,
304
+ id,
305
+ },
306
+ };
307
+ } catch (err) {
308
+ const msg = err instanceof Error ? err.message : String(err);
309
+ return {
310
+ isError: true,
311
+ content: [{ type: "text" as const, text: msg }],
312
+ details: { error: msg },
313
+ };
314
+ }
315
+ }
316
+
317
+ if (ctx.action === "edit") {
318
+ const messageId = readReactionMessageId(ctx.params, ctx.toolContext);
319
+ const body =
320
+ (typeof ctx.params.message === "string" && ctx.params.message.trim()) ||
321
+ (typeof ctx.params.text === "string" && ctx.params.text.trim()) ||
322
+ (typeof ctx.params.content === "string" && ctx.params.content.trim()) ||
323
+ "";
324
+ if (messageId == null) {
325
+ return {
326
+ isError: true,
327
+ content: [
328
+ {
329
+ type: "text" as const,
330
+ text: "Omadeus edit requires `messageId` (Jaguar message id) or current inbound MessageSid.",
331
+ },
332
+ ],
333
+ details: { error: "Missing messageId for edit." },
334
+ };
335
+ }
336
+ if (!body) {
337
+ return {
338
+ isError: true,
339
+ content: [
340
+ {
341
+ type: "text" as const,
342
+ text: "Omadeus edit requires new text in `message`, `text`, or `content`.",
343
+ },
344
+ ],
345
+ details: { error: "Missing body for edit." },
346
+ };
347
+ }
348
+ try {
349
+ await editMessage(apiOptsForAccount(), { messageId, body });
350
+ } catch (err) {
351
+ const msg = err instanceof Error ? err.message : String(err);
352
+ return {
353
+ isError: true,
354
+ content: [{ type: "text" as const, text: msg }],
355
+ details: { error: msg },
356
+ };
357
+ }
358
+ return {
359
+ content: [
360
+ {
361
+ type: "text" as const,
362
+ text: JSON.stringify({ ok: true, channel: "omadeus", action: "edit", messageId }),
363
+ },
364
+ ],
365
+ details: { ok: true, channel: "omadeus", messageId },
366
+ };
367
+ }
368
+
369
+ if (ctx.action === "delete") {
370
+ const messageId = readReactionMessageId(ctx.params, ctx.toolContext);
371
+ if (messageId == null) {
372
+ return {
373
+ isError: true,
374
+ content: [
375
+ {
376
+ type: "text" as const,
377
+ text: "Omadeus delete requires `messageId` (Jaguar message id) or current inbound MessageSid.",
378
+ },
379
+ ],
380
+ details: { error: "Missing messageId for delete." },
381
+ };
382
+ }
383
+ try {
384
+ await deleteMessage(apiOptsForAccount(), { messageId });
385
+ } catch (err) {
386
+ const msg = err instanceof Error ? err.message : String(err);
387
+ return {
388
+ isError: true,
389
+ content: [{ type: "text" as const, text: msg }],
390
+ details: { error: msg },
391
+ };
392
+ }
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text" as const,
397
+ text: JSON.stringify({ ok: true, channel: "omadeus", action: "delete", messageId }),
398
+ },
399
+ ],
400
+ details: { ok: true, channel: "omadeus", messageId },
401
+ };
402
+ }
403
+
404
+ if (ctx.action === "react") {
405
+ const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
406
+ if (!emoji) {
407
+ return {
408
+ isError: true,
409
+ content: [{ type: "text" as const, text: "Omadeus react requires `emoji`." }],
410
+ details: { error: "Omadeus react requires emoji." },
411
+ };
412
+ }
413
+ if (!isAllowedOmadeusReactionEmoji(emoji)) {
414
+ return {
415
+ content: [
416
+ {
417
+ type: "text" as const,
418
+ text: JSON.stringify({
419
+ ok: true,
420
+ channel: "omadeus",
421
+ ignored: true,
422
+ reason: "unsupported_emoji",
423
+ emoji,
424
+ allowed: [...ALLOWED_OMADEUS_REACTION_EMOJI_LIST],
425
+ }),
426
+ },
427
+ ],
428
+ details: {
429
+ ok: true,
430
+ ignored: true,
431
+ channel: "omadeus",
432
+ },
433
+ };
434
+ }
435
+ const messageId = readReactionMessageId(ctx.params, ctx.toolContext);
436
+ if (messageId == null) {
437
+ return {
438
+ isError: true,
439
+ content: [
440
+ {
441
+ type: "text" as const,
442
+ text: "Omadeus react requires `messageId` or a current inbound message id (MessageSid).",
443
+ },
444
+ ],
445
+ details: { error: "Missing messageId for reaction." },
446
+ };
447
+ }
448
+ if (!activeTokenManager) {
449
+ return {
450
+ isError: true,
451
+ content: [
452
+ {
453
+ type: "text" as const,
454
+ text: "Omadeus is not connected; cannot react (gateway must be running).",
455
+ },
456
+ ],
457
+ details: { error: "Omadeus not connected." },
458
+ };
459
+ }
460
+ try {
461
+ await addMessageReaction(apiOptsForAccount(), { messageId, emoji });
462
+ } catch (err) {
463
+ const msg = err instanceof Error ? err.message : String(err);
464
+ return {
465
+ isError: true,
466
+ content: [{ type: "text" as const, text: msg }],
467
+ details: { error: msg },
468
+ };
469
+ }
470
+ return {
471
+ content: [
472
+ {
473
+ type: "text" as const,
474
+ text: JSON.stringify({
475
+ ok: true,
476
+ channel: "omadeus",
477
+ messageId,
478
+ emoji,
479
+ }),
480
+ },
481
+ ],
482
+ details: { ok: true, channel: "omadeus", messageId, emoji },
483
+ };
484
+ }
485
+ // Return null to fall through to default handler
486
+ return null as never;
487
+ },
488
+ },
489
+ reload: { configPrefixes: ["channels.omadeus"] },
490
+ setup: omadeusSetupAdapter,
491
+ setupWizard: omadeusSetupWizard,
492
+
493
+ // -------------------------------------------------------------------------
494
+ // Config adapter
495
+ // -------------------------------------------------------------------------
496
+ config: {
497
+ ...omadeusConfigAdapter,
498
+ isConfigured: (account) => account.credentialSource !== "none",
499
+ unconfiguredReason: () =>
500
+ "Omadeus requires email, password, and organizationId. Run: openclaw setup omadeus",
501
+ describeAccount: (account) => ({
502
+ accountId: account.accountId,
503
+ name: account.name,
504
+ enabled: account.enabled,
505
+ configured: account.credentialSource !== "none",
506
+ credentialSource: account.credentialSource,
507
+ baseUrl: account.maestroUrl,
508
+ }),
509
+ },
510
+
511
+ // Used by shared message-tool target resolution (send, react, etc.).
512
+ messaging: {
513
+ targetResolver: {
514
+ hint: "Use room:<roomId> (matches OpenClaw OriginatingTo) or a numeric Jaguar room id.",
515
+ looksLikeId: (raw) => {
516
+ const t = raw.trim();
517
+ return /^room:\d+$/i.test(t) || /^\d+$/.test(t) || /^[nt]\d+$/i.test(t);
518
+ },
519
+ resolveTarget: async ({ cfg, input }) => {
520
+ const id = normalizeOmadeusRoomId(input);
521
+ if (!id) {
522
+ const taskIntent = parseTaskChannelTargetIntent(input);
523
+ if (!taskIntent || !activeTokenManager) {
524
+ return null;
525
+ }
526
+ const roomId = await resolveTaskRoomIdByNumber(
527
+ {
528
+ maestroUrl: resolveOmadeusAccount({ cfg }).maestroUrl,
529
+ tokenManager: activeTokenManager,
530
+ },
531
+ { nuggetNumber: taskIntent.nuggetNumber },
532
+ );
533
+ if (!roomId) {
534
+ return null;
535
+ }
536
+ return {
537
+ to: String(roomId),
538
+ kind: "group",
539
+ display: `${taskIntent.rawPrefix.toUpperCase()}${taskIntent.nuggetNumber}`,
540
+ source: "normalized",
541
+ };
542
+ }
543
+ return {
544
+ to: id,
545
+ kind: "group",
546
+ display: `room:${id}`,
547
+ source: "normalized",
548
+ };
549
+ },
550
+ },
551
+ },
552
+
553
+ // -------------------------------------------------------------------------
554
+ // Outbound adapter
555
+ // -------------------------------------------------------------------------
556
+ outbound: {
557
+ deliveryMode: "direct",
558
+ textChunkLimit: 4000,
559
+ chunker: (text, limit) => getOmadeusRuntime().channel.text.chunkMarkdownText(text, limit),
560
+ chunkerMode: "markdown",
561
+ ...createAttachedChannelResultAdapter({
562
+ channel: "omadeus",
563
+ sendText: async ({ cfg, to, text }) => {
564
+ if (!activeJaguar || !activeTokenManager) {
565
+ throw new Error("Omadeus: not connected. Is the gateway running with Omadeus enabled?");
566
+ }
567
+ const deps: OutboundDeps = {
568
+ apiOpts: {
569
+ maestroUrl: resolveOmadeusAccount({
570
+ cfg,
571
+ }).maestroUrl,
572
+ tokenManager: activeTokenManager,
573
+ },
574
+ jaguarSocket: activeJaguar,
575
+ };
576
+ return await sendOmadeusMessage(deps, { to, text });
577
+ },
578
+ }),
579
+ resolveTarget: ({ to }) => {
580
+ const trimmed = to?.trim() ?? "";
581
+ const id = normalizeOmadeusRoomId(trimmed);
582
+ if (!id) {
583
+ if (/^[nt]\d+$/i.test(trimmed)) {
584
+ // Allow task-id-like target to proceed; async target resolver may dock it to a room later.
585
+ return { ok: true, to: trimmed };
586
+ }
587
+ return {
588
+ ok: false,
589
+ error: missingTargetError("Omadeus", "room:<roomId> or numeric room id"),
590
+ };
591
+ }
592
+ return { ok: true, to: id };
593
+ },
594
+ },
595
+
596
+ // -------------------------------------------------------------------------
597
+ // Status adapter
598
+ // -------------------------------------------------------------------------
599
+ status: {
600
+ defaultRuntime: defaultRuntimeState,
601
+ collectStatusIssues: (accounts): ChannelStatusIssue[] =>
602
+ accounts.flatMap((entry) => {
603
+ const issues: ChannelStatusIssue[] = [];
604
+ if (entry.enabled !== false && entry.configured !== true) {
605
+ issues.push({
606
+ channel: "omadeus",
607
+ accountId: String(entry.accountId ?? DEFAULT_ACCOUNT_ID),
608
+ kind: "config",
609
+ message: "Omadeus credentials are missing.",
610
+ fix: "Run: openclaw setup omadeus",
611
+ });
612
+ }
613
+ return issues;
614
+ }),
615
+ buildChannelSummary: ({ snapshot }) => ({
616
+ ...buildPassiveChannelStatusSummary(snapshot, {
617
+ credentialSource: snapshot.credentialSource ?? "none",
618
+ baseUrl: snapshot.baseUrl ?? null,
619
+ connected: snapshot.connected ?? false,
620
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
621
+ }),
622
+ ...buildTrafficStatusSummary(snapshot),
623
+ }),
624
+ buildAccountSnapshot: ({ account, runtime }) => ({
625
+ ...buildComputedAccountStatusSnapshot({
626
+ accountId: account.accountId,
627
+ name: account.name,
628
+ enabled: account.enabled,
629
+ configured: account.credentialSource !== "none",
630
+ runtime,
631
+ }),
632
+ baseUrl: account.maestroUrl,
633
+ credentialSource: account.credentialSource,
634
+ connected: runtime?.connected ?? false,
635
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
636
+ }),
637
+ },
638
+
639
+ // -------------------------------------------------------------------------
640
+ // Gateway adapter — starts sockets on gateway boot
641
+ // -------------------------------------------------------------------------
642
+ gateway: {
643
+ startAccount: async (ctx) => {
644
+ const { account, cfg, abortSignal } = ctx;
645
+ ctx.log?.info(`[omadeus] starting for org ${account.organizationId}`);
646
+
647
+ if (account.credentialSource === "none") {
648
+ ctx.log?.warn("[omadeus] skipping start: credentials not configured");
649
+ ctx.setStatus({
650
+ accountId: account.accountId,
651
+ running: false,
652
+ lastError: "credentials not configured",
653
+ });
654
+ return;
655
+ }
656
+
657
+ const hasCachedSession = Boolean(account.sessionToken?.trim());
658
+ if (!account.password && !hasCachedSession) {
659
+ ctx.log?.warn("[omadeus] skipping start: password/sessionToken not set");
660
+ ctx.setStatus({
661
+ accountId: account.accountId,
662
+ running: false,
663
+ lastError: "password/sessionToken not set",
664
+ });
665
+ return;
666
+ }
667
+
668
+ const log = ctx.log ?? { info: () => {}, warn: () => {}, error: () => {} };
669
+
670
+ // Auth
671
+ const tokenManager = createTokenManager({
672
+ casUrl: account.casUrl,
673
+ maestroUrl: account.maestroUrl,
674
+ email: account.email,
675
+ password: account.password,
676
+ organizationId: account.organizationId,
677
+ initialToken: account.sessionToken,
678
+ onRefresh: (token) => {
679
+ log.info("[omadeus] token refreshed");
680
+ void persistSessionToken(token).catch((err) =>
681
+ log.warn(`[omadeus] failed to persist session token: ${String(err)}`),
682
+ );
683
+ },
684
+ onError: (err) => {
685
+ log.error(`[omadeus] token refresh failed: ${err.message}`);
686
+ ctx.setStatus({ accountId: account.accountId, lastError: err.message });
687
+ },
688
+ });
689
+
690
+ try {
691
+ await tokenManager.refresh();
692
+ } catch (err) {
693
+ const msg = err instanceof Error ? err.message : String(err);
694
+ log.error(`[omadeus] initial auth failed: ${msg}`);
695
+ ctx.setStatus({ accountId: account.accountId, running: false, lastError: msg });
696
+ return;
697
+ }
698
+
699
+ tokenManager.startAutoRefresh();
700
+ activeTokenManager = tokenManager;
701
+
702
+ const selfReferenceId = tokenManager.getPayload().referenceId;
703
+
704
+ const outboundDeps: OutboundDeps = {
705
+ apiOpts: { maestroUrl: account.maestroUrl, tokenManager },
706
+ jaguarSocket: null as unknown as JaguarSocketClient,
707
+ };
708
+
709
+ const handleMessage = createOmadeusMessageHandler({
710
+ cfg,
711
+ runtime: ctx.runtime,
712
+ log,
713
+ outboundDeps,
714
+ });
715
+
716
+ // Jaguar socket (chat — DMs, nugget/task/project rooms)
717
+ const jaguar = createJaguarSocketClient({
718
+ maestroUrl: account.maestroUrl,
719
+ tokenManager,
720
+ log,
721
+ onMessage: (msg) => {
722
+ const label =
723
+ msg.subscribableKind === "direct"
724
+ ? `DM from ${msg.senderReferenceId}`
725
+ : `${msg.subscribableKind}/${msg.roomName ?? msg.roomId} from ${msg.senderReferenceId}`;
726
+ log.info(`[jaguar] ${label}: ${msg.body.slice(0, 80)}`);
727
+
728
+ const inbound = parseJaguarMessage(msg, { selfReferenceId }, log);
729
+ if (inbound) {
730
+ log.info(
731
+ `[jaguar] inbound: ${inbound.subscribableKind} room=${inbound.roomId} ` +
732
+ `from=${inbound.from} mention=${inbound.isMention}`,
733
+ );
734
+ ctx.setStatus({ accountId: account.accountId, lastInboundAt: Date.now() });
735
+ handleMessage(inbound).catch((err) => {
736
+ log.error(
737
+ `[jaguar] dispatch error: ${err instanceof Error ? err.message : String(err)}`,
738
+ );
739
+ });
740
+ }
741
+ },
742
+ onOtherEvent: (data) => {
743
+ log.info(`[jaguar] non-message event: ${JSON.stringify(data).slice(0, 120)}`);
744
+ },
745
+ onConnect: () =>
746
+ ctx.setStatus({
747
+ accountId: account.accountId,
748
+ connected: true,
749
+ lastConnectedAt: Date.now(),
750
+ }),
751
+ onDisconnect: () => ctx.setStatus({ accountId: account.accountId, connected: false }),
752
+ onError: (err) => ctx.setStatus({ accountId: account.accountId, lastError: err.message }),
753
+ });
754
+
755
+ // Dolphin socket (data — tasks, projects, sprints, releases)
756
+ const dolphin = createDolphinSocketClient({
757
+ maestroUrl: account.maestroUrl,
758
+ tokenManager,
759
+ log,
760
+ onEvent: (data) => {
761
+ log.info(`[dolphin] event: ${JSON.stringify(data).slice(0, 120)}`);
762
+ // TODO: handle task assignment/update events as they are discovered
763
+ },
764
+ onConnect: () =>
765
+ ctx.setStatus({
766
+ accountId: account.accountId,
767
+ connected: true,
768
+ lastConnectedAt: Date.now(),
769
+ }),
770
+ onDisconnect: () => ctx.setStatus({ accountId: account.accountId, connected: false }),
771
+ onError: (err) => ctx.setStatus({ accountId: account.accountId, lastError: err.message }),
772
+ });
773
+
774
+ // Wire the jaguar socket into outbound deps now that it's created
775
+ outboundDeps.jaguarSocket = jaguar;
776
+
777
+ jaguar.connect();
778
+ dolphin.connect();
779
+ activeJaguar = jaguar;
780
+ activeDolphin = dolphin;
781
+
782
+ ctx.setStatus({
783
+ accountId: account.accountId,
784
+ running: true,
785
+ lastStartAt: Date.now(),
786
+ });
787
+
788
+ let cleanedUp = false;
789
+ const cleanup = () => {
790
+ if (cleanedUp) return;
791
+ cleanedUp = true;
792
+ tokenManager.stopAutoRefresh();
793
+ jaguar.disconnect();
794
+ dolphin.disconnect();
795
+ activeTokenManager = null;
796
+ activeJaguar = null;
797
+ activeDolphin = null;
798
+ ctx.setStatus({
799
+ accountId: account.accountId,
800
+ running: false,
801
+ lastStopAt: Date.now(),
802
+ });
803
+ };
804
+
805
+ // Keep this account runner alive until the gateway aborts it.
806
+ await new Promise<void>((resolve) => {
807
+ if (abortSignal.aborted) {
808
+ resolve();
809
+ return;
810
+ }
811
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
812
+ });
813
+
814
+ cleanup();
815
+ },
816
+ },
817
+ };