@loggie-ai/openclaw-plugin 0.1.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/README.md +205 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/setup-entry.d.ts +73 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/account.d.ts +70 -0
- package/dist/src/account.js +182 -0
- package/dist/src/channel.d.ts +72 -0
- package/dist/src/channel.js +105 -0
- package/dist/src/config-schema.d.ts +98 -0
- package/dist/src/config-schema.js +55 -0
- package/dist/src/cursor-store.d.ts +86 -0
- package/dist/src/cursor-store.js +141 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +29 -0
- package/dist/src/event-types.d.ts +113 -0
- package/dist/src/event-types.js +86 -0
- package/dist/src/loggie-client.d.ts +33 -0
- package/dist/src/loggie-client.js +74 -0
- package/dist/src/monitor/connection.d.ts +30 -0
- package/dist/src/monitor/connection.js +289 -0
- package/dist/src/monitor/event-handler.d.ts +27 -0
- package/dist/src/monitor/event-handler.js +90 -0
- package/dist/src/monitor/transcript-dispatch.d.ts +16 -0
- package/dist/src/monitor/transcript-dispatch.js +124 -0
- package/dist/src/monitor/transcript-format.d.ts +2 -0
- package/dist/src/monitor/transcript-format.js +41 -0
- package/dist/src/object.d.ts +4 -0
- package/dist/src/object.js +12 -0
- package/dist/src/routing.d.ts +11 -0
- package/dist/src/routing.js +13 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/status.d.ts +45 -0
- package/dist/src/status.js +45 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +71 -0
- package/package.json +93 -0
- package/plugin-inspector.config.json +15 -0
- package/setup-entry.ts +4 -0
- package/src/account.ts +265 -0
- package/src/channel.ts +148 -0
- package/src/config-schema.ts +57 -0
- package/src/cursor-store.ts +233 -0
- package/src/doctor.ts +39 -0
- package/src/event-types.ts +105 -0
- package/src/loggie-client.ts +111 -0
- package/src/monitor/connection.ts +349 -0
- package/src/monitor/event-handler.ts +133 -0
- package/src/monitor/transcript-dispatch.ts +145 -0
- package/src/monitor/transcript-format.ts +49 -0
- package/src/object.ts +15 -0
- package/src/routing.ts +27 -0
- package/src/runtime.ts +13 -0
- package/src/status.ts +72 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const LOGGIE_TRANSCRIPT_READY_EVENT = "meeting.transcript.ready";
|
|
3
|
+
const transcriptPayloadSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
eventId: z.string().optional(),
|
|
6
|
+
cursor: z.string().optional(),
|
|
7
|
+
type: z.string().optional(),
|
|
8
|
+
workspaceId: z.string().optional(),
|
|
9
|
+
meetingId: z.string().optional(),
|
|
10
|
+
meetingScheduleId: z.string().optional(),
|
|
11
|
+
title: z.string().optional(),
|
|
12
|
+
source: z.string().nullable().optional(),
|
|
13
|
+
externalId: z.string().nullable().optional(),
|
|
14
|
+
meetingDate: z.string().datetime().optional(),
|
|
15
|
+
hasTranscript: z.boolean().optional(),
|
|
16
|
+
detailPath: z.string().optional(),
|
|
17
|
+
})
|
|
18
|
+
.passthrough();
|
|
19
|
+
export const loggieEventEnvelopeSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
eventId: z.string().min(1),
|
|
22
|
+
cursor: z.string().min(1).optional(),
|
|
23
|
+
workspaceId: z.string().min(1),
|
|
24
|
+
agentProfileId: z.string().min(1).optional(),
|
|
25
|
+
meetingId: z.string().min(1).optional(),
|
|
26
|
+
meetingScheduleId: z.string().min(1).optional(),
|
|
27
|
+
eventType: z.string().min(1),
|
|
28
|
+
occurredAt: z.string().datetime(),
|
|
29
|
+
sequence: z.number().int().nonnegative().optional(),
|
|
30
|
+
payload: z.record(z.unknown()).default({}),
|
|
31
|
+
})
|
|
32
|
+
.passthrough()
|
|
33
|
+
.refine((event) => Boolean(event.meetingId ?? event.meetingScheduleId), {
|
|
34
|
+
message: "meetingId or meetingScheduleId is required",
|
|
35
|
+
});
|
|
36
|
+
export function parseLoggieEventEnvelope(raw) {
|
|
37
|
+
const candidate = raw && typeof raw === "object" && "event" in raw && raw.event
|
|
38
|
+
? raw.event
|
|
39
|
+
: raw;
|
|
40
|
+
const result = loggieEventEnvelopeSchema.safeParse(normalizeLoggieEventCandidate(candidate));
|
|
41
|
+
if (!result.success) {
|
|
42
|
+
return { ok: false, reason: result.error.issues.map((issue) => issue.message).join("; ") };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, event: result.data };
|
|
45
|
+
}
|
|
46
|
+
export function isTranscriptReadyEvent(event) {
|
|
47
|
+
if (event.eventType !== LOGGIE_TRANSCRIPT_READY_EVENT) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return transcriptPayloadSchema.safeParse(event.payload).success;
|
|
51
|
+
}
|
|
52
|
+
function normalizeLoggieEventCandidate(candidate) {
|
|
53
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
const record = candidate;
|
|
57
|
+
const payload = record.payload && typeof record.payload === "object" && !Array.isArray(record.payload)
|
|
58
|
+
? record.payload
|
|
59
|
+
: {};
|
|
60
|
+
const eventType = readString(record.eventType) ?? readString(record.type);
|
|
61
|
+
const cursor = readString(record.cursor) ?? readString(payload.cursor);
|
|
62
|
+
const sequence = typeof record.sequence === "number" ? record.sequence : undefined;
|
|
63
|
+
const eventId = readString(record.eventId) ??
|
|
64
|
+
readString(payload.eventId) ??
|
|
65
|
+
cursor;
|
|
66
|
+
return {
|
|
67
|
+
...record,
|
|
68
|
+
eventId,
|
|
69
|
+
cursor,
|
|
70
|
+
workspaceId: readString(record.workspaceId) ?? readString(payload.workspaceId),
|
|
71
|
+
agentProfileId: readString(record.agentProfileId) ?? readString(payload.agentProfileId),
|
|
72
|
+
meetingId: readString(record.meetingId) ?? readString(payload.meetingId),
|
|
73
|
+
meetingScheduleId: readString(record.meetingScheduleId) ?? readString(payload.meetingScheduleId),
|
|
74
|
+
eventType,
|
|
75
|
+
occurredAt: readString(record.occurredAt) ?? readString(record.createdAt),
|
|
76
|
+
sequence,
|
|
77
|
+
payload,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function readString(value) {
|
|
81
|
+
if (typeof value !== "string") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type RawData } from "ws";
|
|
2
|
+
import type { ResolvedLoggieAccount } from "./account.js";
|
|
3
|
+
import type { LoggieCursorRecord } from "./cursor-store.js";
|
|
4
|
+
export type LoggieSocket = {
|
|
5
|
+
readyState: number;
|
|
6
|
+
send(data: string): void;
|
|
7
|
+
close(code?: number, reason?: string): void;
|
|
8
|
+
on(event: "open", listener: () => void): unknown;
|
|
9
|
+
on(event: "message", listener: (data: RawData | string) => void): unknown;
|
|
10
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
11
|
+
on(event: "close", listener: (code: number, reason: Buffer) => void): unknown;
|
|
12
|
+
};
|
|
13
|
+
export type LoggieSocketFactory = (params: {
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
}) => LoggieSocket;
|
|
17
|
+
export declare function buildLoggieWebSocketUrl(account: Pick<ResolvedLoggieAccount, "baseUrl" | "socketPath">, cursor?: LoggieCursorRecord): string;
|
|
18
|
+
export declare function buildLoggieAuthHeaders(account: Pick<ResolvedLoggieAccount, "authHeader" | "tokenStatus">): Record<string, string>;
|
|
19
|
+
export declare function buildLoggieDetailUrl(account: Pick<ResolvedLoggieAccount, "baseUrl">, detailPath: string): string;
|
|
20
|
+
export declare function fetchLoggieTranscriptDetail(params: {
|
|
21
|
+
account: Pick<ResolvedLoggieAccount, "baseUrl" | "authHeader" | "tokenStatus">;
|
|
22
|
+
detailPath: string;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}): Promise<unknown>;
|
|
25
|
+
export declare const defaultLoggieSocketFactory: LoggieSocketFactory;
|
|
26
|
+
export declare function buildLoggiePingMessage(): {
|
|
27
|
+
type: "ping";
|
|
28
|
+
};
|
|
29
|
+
export declare function buildLoggieResumeMessage(cursor: string): {
|
|
30
|
+
type: "resume";
|
|
31
|
+
cursor: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function decodeLoggieSocketMessage(data: RawData | string): unknown;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
export function buildLoggieWebSocketUrl(account, cursor) {
|
|
3
|
+
const url = new URL(account.baseUrl);
|
|
4
|
+
url.protocol = url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
|
|
5
|
+
if (!url.protocol.startsWith("ws")) {
|
|
6
|
+
throw new Error(`Unsupported Loggie baseUrl protocol: ${url.protocol}`);
|
|
7
|
+
}
|
|
8
|
+
url.pathname = account.socketPath.startsWith("/") ? account.socketPath : `/${account.socketPath}`;
|
|
9
|
+
url.search = "";
|
|
10
|
+
const replayCursor = readReplayCursor(cursor);
|
|
11
|
+
if (replayCursor) {
|
|
12
|
+
url.searchParams.set("cursor", replayCursor);
|
|
13
|
+
}
|
|
14
|
+
return url.toString();
|
|
15
|
+
}
|
|
16
|
+
export function buildLoggieAuthHeaders(account) {
|
|
17
|
+
if (account.tokenStatus.status !== "available") {
|
|
18
|
+
throw new Error(`Loggie token is not available (${account.tokenStatus.status})`);
|
|
19
|
+
}
|
|
20
|
+
return account.authHeader === "x-api-key"
|
|
21
|
+
? { "x-api-key": account.tokenStatus.value }
|
|
22
|
+
: { authorization: `Bearer ${account.tokenStatus.value}` };
|
|
23
|
+
}
|
|
24
|
+
export function buildLoggieDetailUrl(account, detailPath) {
|
|
25
|
+
const base = new URL(account.baseUrl);
|
|
26
|
+
const url = new URL(detailPath, base);
|
|
27
|
+
if (url.origin !== base.origin) {
|
|
28
|
+
throw new Error("Loggie transcript detail URL must stay on the configured Loggie origin");
|
|
29
|
+
}
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|
|
32
|
+
export async function fetchLoggieTranscriptDetail(params) {
|
|
33
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
34
|
+
const response = await fetchImpl(buildLoggieDetailUrl(params.account, params.detailPath), {
|
|
35
|
+
headers: buildLoggieAuthHeaders(params.account),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(`Loggie transcript detail fetch failed with HTTP ${response.status}`);
|
|
39
|
+
}
|
|
40
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
41
|
+
if (contentType.includes("application/json")) {
|
|
42
|
+
return response.json();
|
|
43
|
+
}
|
|
44
|
+
return response.text();
|
|
45
|
+
}
|
|
46
|
+
export const defaultLoggieSocketFactory = ({ url, headers }) => {
|
|
47
|
+
const options = { headers };
|
|
48
|
+
return new WebSocket(url, options);
|
|
49
|
+
};
|
|
50
|
+
export function buildLoggiePingMessage() {
|
|
51
|
+
return { type: "ping" };
|
|
52
|
+
}
|
|
53
|
+
export function buildLoggieResumeMessage(cursor) {
|
|
54
|
+
return { type: "resume", cursor };
|
|
55
|
+
}
|
|
56
|
+
export function decodeLoggieSocketMessage(data) {
|
|
57
|
+
const text = typeof data === "string"
|
|
58
|
+
? data
|
|
59
|
+
: Buffer.isBuffer(data)
|
|
60
|
+
? data.toString("utf8")
|
|
61
|
+
: Array.isArray(data)
|
|
62
|
+
? Buffer.concat(data).toString("utf8")
|
|
63
|
+
: Buffer.from(data).toString("utf8");
|
|
64
|
+
return JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
function readReplayCursor(cursor) {
|
|
67
|
+
if (!cursor) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (cursor.lastCursor) {
|
|
71
|
+
return cursor.lastCursor;
|
|
72
|
+
}
|
|
73
|
+
return typeof cursor.lastSequence === "number" ? String(cursor.lastSequence) : undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
+
import type { ResolvedLoggieAccount } from "../account.js";
|
|
3
|
+
import { type LoggieCursorStore } from "../cursor-store.js";
|
|
4
|
+
import { type LoggieSocketFactory } from "../loggie-client.js";
|
|
5
|
+
import { type TranscriptDispatcher } from "./transcript-dispatch.js";
|
|
6
|
+
type ChannelRuntime = PluginRuntime["channel"];
|
|
7
|
+
export type MonitorLoggieAccountParams = {
|
|
8
|
+
account: ResolvedLoggieAccount;
|
|
9
|
+
cfg: OpenClawConfig;
|
|
10
|
+
runtime: PluginRuntime;
|
|
11
|
+
channelRuntime?: ChannelRuntime;
|
|
12
|
+
abortSignal?: AbortSignal;
|
|
13
|
+
setStatus?: (next: Record<string, unknown>) => void;
|
|
14
|
+
getStatus?: () => Record<string, unknown>;
|
|
15
|
+
log?: {
|
|
16
|
+
info?: (message: string) => void;
|
|
17
|
+
warn?: (message: string) => void;
|
|
18
|
+
};
|
|
19
|
+
cursorStore?: LoggieCursorStore;
|
|
20
|
+
dispatcher?: TranscriptDispatcher;
|
|
21
|
+
socketFactory?: LoggieSocketFactory;
|
|
22
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export declare function monitorLoggieAccount(params: MonitorLoggieAccountParams): Promise<void>;
|
|
25
|
+
export declare function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
|
|
26
|
+
cursorStore: LoggieCursorStore;
|
|
27
|
+
dispatcher: TranscriptDispatcher;
|
|
28
|
+
socketFactory: LoggieSocketFactory;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createRuntimeCursorStore } from "../cursor-store.js";
|
|
2
|
+
import { buildLoggieAuthHeaders, buildLoggiePingMessage, buildLoggieResumeMessage, buildLoggieWebSocketUrl, decodeLoggieSocketMessage, defaultLoggieSocketFactory, } from "../loggie-client.js";
|
|
3
|
+
import { createOpenClawTranscriptDispatcher } from "./transcript-dispatch.js";
|
|
4
|
+
import { handleLoggieEvent } from "./event-handler.js";
|
|
5
|
+
export async function monitorLoggieAccount(params) {
|
|
6
|
+
if (!params.account.enabled) {
|
|
7
|
+
params.log?.info?.(`[${params.account.accountId}] Loggie account disabled`);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (!params.account.configured) {
|
|
11
|
+
throw new Error(`Loggie account ${params.account.accountId} is not configured`);
|
|
12
|
+
}
|
|
13
|
+
if (!params.channelRuntime && !params.dispatcher) {
|
|
14
|
+
throw new Error("Loggie channelRuntime is required for transcript dispatch");
|
|
15
|
+
}
|
|
16
|
+
const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime);
|
|
17
|
+
const dispatcher = params.dispatcher ??
|
|
18
|
+
createOpenClawTranscriptDispatcher({
|
|
19
|
+
account: params.account,
|
|
20
|
+
cfg: params.cfg,
|
|
21
|
+
runtime: params.runtime,
|
|
22
|
+
channelRuntime: params.channelRuntime,
|
|
23
|
+
abortSignal: params.abortSignal,
|
|
24
|
+
});
|
|
25
|
+
const sleep = params.sleep ?? defaultSleep;
|
|
26
|
+
let attempts = 0;
|
|
27
|
+
while (!params.abortSignal?.aborted) {
|
|
28
|
+
try {
|
|
29
|
+
await runLoggieSocketOnce({
|
|
30
|
+
...params,
|
|
31
|
+
cursorStore,
|
|
32
|
+
dispatcher,
|
|
33
|
+
socketFactory: params.socketFactory ?? defaultLoggieSocketFactory,
|
|
34
|
+
});
|
|
35
|
+
attempts = 0;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (params.abortSignal?.aborted) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
attempts += 1;
|
|
42
|
+
const delayMs = Math.min(params.account.reconnect.maxMs, params.account.reconnect.minMs * 2 ** Math.max(0, attempts - 1));
|
|
43
|
+
params.setStatus?.({
|
|
44
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
45
|
+
running: true,
|
|
46
|
+
connected: false,
|
|
47
|
+
authenticated: false,
|
|
48
|
+
reconnectAttempts: attempts,
|
|
49
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
50
|
+
});
|
|
51
|
+
params.log?.warn?.(`[${params.account.accountId}] Loggie socket failed; reconnecting in ${delayMs}ms`);
|
|
52
|
+
await sleep(delayMs, params.abortSignal);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function runLoggieSocketOnce(params) {
|
|
57
|
+
const cursor = await params.cursorStore.load(params.account.accountId, params.account.agentProfileId);
|
|
58
|
+
const ws = params.socketFactory({
|
|
59
|
+
url: buildLoggieWebSocketUrl(params.account, cursor),
|
|
60
|
+
headers: buildLoggieAuthHeaders(params.account),
|
|
61
|
+
});
|
|
62
|
+
params.setStatus?.({
|
|
63
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
64
|
+
running: true,
|
|
65
|
+
connected: false,
|
|
66
|
+
authenticated: false,
|
|
67
|
+
lastStartAt: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
let stoppedByAbort = false;
|
|
72
|
+
let heartbeatTimeout;
|
|
73
|
+
let pingInterval;
|
|
74
|
+
let eventQueue = Promise.resolve();
|
|
75
|
+
const pendingMessages = new Set();
|
|
76
|
+
const clearHeartbeat = () => {
|
|
77
|
+
if (heartbeatTimeout) {
|
|
78
|
+
clearTimeout(heartbeatTimeout);
|
|
79
|
+
heartbeatTimeout = undefined;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const clearPing = () => {
|
|
83
|
+
if (pingInterval) {
|
|
84
|
+
clearInterval(pingInterval);
|
|
85
|
+
pingInterval = undefined;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const finish = (fn) => {
|
|
89
|
+
if (settled) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
settled = true;
|
|
93
|
+
clearHeartbeat();
|
|
94
|
+
clearPing();
|
|
95
|
+
params.abortSignal?.removeEventListener("abort", closeOnAbort);
|
|
96
|
+
fn();
|
|
97
|
+
};
|
|
98
|
+
const failSocket = (error, options) => {
|
|
99
|
+
clearPing();
|
|
100
|
+
try {
|
|
101
|
+
ws.close(1011, error.message);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Ignore close errors and report the original failure.
|
|
105
|
+
}
|
|
106
|
+
if (options?.waitForPending) {
|
|
107
|
+
void Promise.allSettled([...pendingMessages]).then(() => finish(() => reject(error)));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
finish(() => reject(error));
|
|
111
|
+
};
|
|
112
|
+
const resetHeartbeat = () => {
|
|
113
|
+
clearHeartbeat();
|
|
114
|
+
heartbeatTimeout = setTimeout(() => {
|
|
115
|
+
failSocket(new Error("Loggie heartbeat timeout"), { waitForPending: true });
|
|
116
|
+
}, params.account.heartbeat.timeoutMs);
|
|
117
|
+
heartbeatTimeout.unref?.();
|
|
118
|
+
};
|
|
119
|
+
const startPing = () => {
|
|
120
|
+
clearPing();
|
|
121
|
+
const intervalMs = Math.max(250, Math.floor(params.account.heartbeat.timeoutMs / 2));
|
|
122
|
+
pingInterval = setInterval(() => {
|
|
123
|
+
try {
|
|
124
|
+
ws.send(JSON.stringify(buildLoggiePingMessage()));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
failSocket(error instanceof Error ? error : new Error(String(error)), { waitForPending: true });
|
|
128
|
+
}
|
|
129
|
+
}, intervalMs);
|
|
130
|
+
pingInterval.unref?.();
|
|
131
|
+
};
|
|
132
|
+
const closeOnAbort = () => {
|
|
133
|
+
stoppedByAbort = true;
|
|
134
|
+
ws.close(1000, "OpenClaw Loggie monitor stopped");
|
|
135
|
+
};
|
|
136
|
+
params.abortSignal?.addEventListener("abort", closeOnAbort, { once: true });
|
|
137
|
+
ws.on("open", () => {
|
|
138
|
+
resetHeartbeat();
|
|
139
|
+
startPing();
|
|
140
|
+
void (async () => {
|
|
141
|
+
const connectedAt = Date.now();
|
|
142
|
+
params.setStatus?.({
|
|
143
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
144
|
+
running: true,
|
|
145
|
+
connected: true,
|
|
146
|
+
authenticated: true,
|
|
147
|
+
reconnectAttempts: 0,
|
|
148
|
+
lastConnectedAt: connectedAt,
|
|
149
|
+
lastError: null,
|
|
150
|
+
});
|
|
151
|
+
await params.cursorStore.save({
|
|
152
|
+
...(cursor ?? {
|
|
153
|
+
accountId: params.account.accountId,
|
|
154
|
+
agentProfileId: params.account.agentProfileId,
|
|
155
|
+
seenEventIds: [],
|
|
156
|
+
updatedAt: new Date(connectedAt).toISOString(),
|
|
157
|
+
}),
|
|
158
|
+
lastConnectedAt: connectedAt,
|
|
159
|
+
updatedAt: new Date(connectedAt).toISOString(),
|
|
160
|
+
});
|
|
161
|
+
})().catch((error) => {
|
|
162
|
+
ws.close(1011, error instanceof Error ? error.message : "Loggie open initialization failed");
|
|
163
|
+
finish(() => reject(error));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
ws.on("message", (data) => {
|
|
167
|
+
resetHeartbeat();
|
|
168
|
+
let decoded;
|
|
169
|
+
try {
|
|
170
|
+
decoded = decodeLoggieSocketMessage(data);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
failSocket(error instanceof Error ? error : new Error(String(error)));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const control = parseControlMessage(decoded);
|
|
177
|
+
if (control.type === "ignore") {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (control.type === "resume") {
|
|
181
|
+
try {
|
|
182
|
+
ws.send(JSON.stringify(buildLoggieResumeMessage(control.cursor)));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
failSocket(error instanceof Error ? error : new Error(String(error)), { waitForPending: true });
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (control.type === "error") {
|
|
190
|
+
failSocket(new Error(control.error), { waitForPending: true });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const task = eventQueue.then(async () => {
|
|
194
|
+
const result = await handleLoggieEvent({
|
|
195
|
+
raw: decoded,
|
|
196
|
+
account: params.account,
|
|
197
|
+
cursorStore: params.cursorStore,
|
|
198
|
+
dispatcher: params.dispatcher,
|
|
199
|
+
});
|
|
200
|
+
if (result.status === "failed") {
|
|
201
|
+
throw new Error(result.reason);
|
|
202
|
+
}
|
|
203
|
+
const eventAt = Date.now();
|
|
204
|
+
const deadLettered = result.status === "skipped" ? result.deadLettered : undefined;
|
|
205
|
+
if (deadLettered && result.event) {
|
|
206
|
+
params.log?.warn?.(`[${params.account.accountId}] Loggie event ${result.event.eventId} dead-lettered after ${deadLettered.attempts} attempts: ${deadLettered.reason}`);
|
|
207
|
+
}
|
|
208
|
+
params.setStatus?.({
|
|
209
|
+
...(params.getStatus?.() ?? { accountId: params.account.accountId }),
|
|
210
|
+
running: true,
|
|
211
|
+
connected: true,
|
|
212
|
+
authenticated: true,
|
|
213
|
+
caughtUp: true,
|
|
214
|
+
lastEventAt: eventAt,
|
|
215
|
+
...(result.status === "dispatched" ? { lastDispatchAt: eventAt } : {}),
|
|
216
|
+
...(deadLettered && result.event
|
|
217
|
+
? {
|
|
218
|
+
lastDeadLetterAt: eventAt,
|
|
219
|
+
lastDeadLetteredEventId: result.event.eventId,
|
|
220
|
+
lastDeadLetteredCursor: result.event.cursor ?? null,
|
|
221
|
+
lastDeadLetterReason: deadLettered.reason,
|
|
222
|
+
lastDeadLetterAttempts: deadLettered.attempts,
|
|
223
|
+
}
|
|
224
|
+
: {}),
|
|
225
|
+
lastEventId: result.event?.eventId ?? null,
|
|
226
|
+
lastCursor: result.event?.cursor ?? null,
|
|
227
|
+
lastSequence: result.event?.sequence ?? null,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
eventQueue = task.catch(() => undefined);
|
|
231
|
+
pendingMessages.add(task);
|
|
232
|
+
void task
|
|
233
|
+
.catch((error) => {
|
|
234
|
+
ws.close(1011, error instanceof Error ? error.message : "Loggie event handling failed");
|
|
235
|
+
finish(() => reject(error));
|
|
236
|
+
})
|
|
237
|
+
.finally(() => {
|
|
238
|
+
pendingMessages.delete(task);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
ws.on("error", (error) => finish(() => reject(error)));
|
|
242
|
+
ws.on("close", (code, reason) => {
|
|
243
|
+
clearHeartbeat();
|
|
244
|
+
clearPing();
|
|
245
|
+
const reasonText = reason.toString("utf8");
|
|
246
|
+
void Promise.allSettled([...pendingMessages]).then(() => {
|
|
247
|
+
if (stoppedByAbort || params.abortSignal?.aborted) {
|
|
248
|
+
finish(resolve);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
finish(() => reject(new Error(`Loggie socket closed unexpectedly (${code}${reasonText ? `: ${reasonText}` : ""})`)));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function parseControlMessage(value) {
|
|
257
|
+
if (value === null || typeof value !== "object" || !("type" in value)) {
|
|
258
|
+
return { type: "none" };
|
|
259
|
+
}
|
|
260
|
+
const record = value;
|
|
261
|
+
if (record.type === "hello" || record.type === "pong" || record.type === "ack") {
|
|
262
|
+
return { type: "ignore" };
|
|
263
|
+
}
|
|
264
|
+
if (record.type === "loggie.events.more_available") {
|
|
265
|
+
return typeof record.cursor === "string" && record.cursor.trim().length > 0
|
|
266
|
+
? { type: "resume", cursor: record.cursor.trim() }
|
|
267
|
+
: { type: "error", error: "Loggie replay cursor missing from more_available message" };
|
|
268
|
+
}
|
|
269
|
+
if (record.type === "error") {
|
|
270
|
+
return {
|
|
271
|
+
type: "error",
|
|
272
|
+
error: typeof record.error === "string" ? record.error : "Loggie socket returned an error",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { type: "none" };
|
|
276
|
+
}
|
|
277
|
+
function defaultSleep(ms, signal) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
if (signal?.aborted) {
|
|
280
|
+
resolve();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const timeout = setTimeout(resolve, ms);
|
|
284
|
+
signal?.addEventListener("abort", () => {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
resolve();
|
|
287
|
+
}, { once: true });
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResolvedLoggieAccount } from "../account.js";
|
|
2
|
+
import { type LoggieCursorStore } from "../cursor-store.js";
|
|
3
|
+
import { type LoggieEventEnvelope } from "../event-types.js";
|
|
4
|
+
import type { TranscriptDispatcher } from "./transcript-dispatch.js";
|
|
5
|
+
export type LoggieEventHandleResult = {
|
|
6
|
+
status: "dispatched";
|
|
7
|
+
event: LoggieEventEnvelope;
|
|
8
|
+
} | {
|
|
9
|
+
status: "skipped";
|
|
10
|
+
reason: string;
|
|
11
|
+
event?: LoggieEventEnvelope;
|
|
12
|
+
deadLettered?: {
|
|
13
|
+
attempts: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
} | {
|
|
17
|
+
status: "failed";
|
|
18
|
+
reason: string;
|
|
19
|
+
retryable: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function handleLoggieEvent(params: {
|
|
22
|
+
raw: unknown;
|
|
23
|
+
account: ResolvedLoggieAccount;
|
|
24
|
+
cursorStore: LoggieCursorStore;
|
|
25
|
+
dispatcher: TranscriptDispatcher;
|
|
26
|
+
now?: Date;
|
|
27
|
+
}): Promise<LoggieEventHandleResult>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { advanceCursor, deadLetterEvent, decideCursor, recordEventFailure, } from "../cursor-store.js";
|
|
2
|
+
import { isTranscriptReadyEvent, parseLoggieEventEnvelope, } from "../event-types.js";
|
|
3
|
+
const MAX_EVENT_DISPATCH_ATTEMPTS = 3;
|
|
4
|
+
export async function handleLoggieEvent(params) {
|
|
5
|
+
const parsed = parseLoggieEventEnvelope(params.raw);
|
|
6
|
+
if (!parsed.ok) {
|
|
7
|
+
return { status: "failed", reason: parsed.reason, retryable: false };
|
|
8
|
+
}
|
|
9
|
+
const event = parsed.event;
|
|
10
|
+
const cursor = await params.cursorStore.load(params.account.accountId, params.account.agentProfileId);
|
|
11
|
+
if (event.agentProfileId && event.agentProfileId !== params.account.agentProfileId) {
|
|
12
|
+
const reason = `event agentProfileId does not match configured account`;
|
|
13
|
+
await params.cursorStore.save(deadLetterEvent({
|
|
14
|
+
record: cursor,
|
|
15
|
+
event,
|
|
16
|
+
accountId: params.account.accountId,
|
|
17
|
+
agentProfileId: params.account.agentProfileId,
|
|
18
|
+
reason,
|
|
19
|
+
attempts: 1,
|
|
20
|
+
now: params.now,
|
|
21
|
+
}));
|
|
22
|
+
return {
|
|
23
|
+
status: "skipped",
|
|
24
|
+
reason: `dead_lettered_non_retryable:${reason}`,
|
|
25
|
+
event,
|
|
26
|
+
deadLettered: {
|
|
27
|
+
attempts: 1,
|
|
28
|
+
reason,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const decision = decideCursor(cursor, event);
|
|
33
|
+
if (decision.action === "skip") {
|
|
34
|
+
return { status: "skipped", reason: decision.reason, event };
|
|
35
|
+
}
|
|
36
|
+
if (!isTranscriptReadyEvent(event)) {
|
|
37
|
+
await params.cursorStore.save(advanceCursor({
|
|
38
|
+
record: cursor,
|
|
39
|
+
event,
|
|
40
|
+
accountId: params.account.accountId,
|
|
41
|
+
agentProfileId: params.account.agentProfileId,
|
|
42
|
+
now: params.now,
|
|
43
|
+
}));
|
|
44
|
+
return { status: "skipped", reason: `unsupported_event:${event.eventType}`, event };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await params.dispatcher.dispatchTranscriptReady(event);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
51
|
+
const failure = recordEventFailure({
|
|
52
|
+
record: cursor,
|
|
53
|
+
event,
|
|
54
|
+
accountId: params.account.accountId,
|
|
55
|
+
agentProfileId: params.account.agentProfileId,
|
|
56
|
+
reason,
|
|
57
|
+
now: params.now,
|
|
58
|
+
});
|
|
59
|
+
if (failure.attempts < MAX_EVENT_DISPATCH_ATTEMPTS) {
|
|
60
|
+
await params.cursorStore.save(failure.record);
|
|
61
|
+
return { status: "failed", reason, retryable: true };
|
|
62
|
+
}
|
|
63
|
+
await params.cursorStore.save(deadLetterEvent({
|
|
64
|
+
record: failure.record,
|
|
65
|
+
event,
|
|
66
|
+
accountId: params.account.accountId,
|
|
67
|
+
agentProfileId: params.account.agentProfileId,
|
|
68
|
+
reason,
|
|
69
|
+
attempts: failure.attempts,
|
|
70
|
+
now: params.now,
|
|
71
|
+
}));
|
|
72
|
+
return {
|
|
73
|
+
status: "skipped",
|
|
74
|
+
reason: `dead_lettered_after_${failure.attempts}_attempts:${reason}`,
|
|
75
|
+
event,
|
|
76
|
+
deadLettered: {
|
|
77
|
+
attempts: failure.attempts,
|
|
78
|
+
reason,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
await params.cursorStore.save(advanceCursor({
|
|
83
|
+
record: cursor,
|
|
84
|
+
event,
|
|
85
|
+
accountId: params.account.accountId,
|
|
86
|
+
agentProfileId: params.account.agentProfileId,
|
|
87
|
+
now: params.now,
|
|
88
|
+
}));
|
|
89
|
+
return { status: "dispatched", event };
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/channel-core";
|
|
2
|
+
import type { ResolvedLoggieAccount } from "../account.js";
|
|
3
|
+
import type { LoggieTranscriptReadyEvent } from "../event-types.js";
|
|
4
|
+
export type TranscriptDispatcher = {
|
|
5
|
+
dispatchTranscriptReady(event: LoggieTranscriptReadyEvent): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
type ChannelRuntime = PluginRuntime["channel"];
|
|
8
|
+
export declare function createOpenClawTranscriptDispatcher(params: {
|
|
9
|
+
account: ResolvedLoggieAccount;
|
|
10
|
+
cfg: OpenClawConfig;
|
|
11
|
+
runtime: PluginRuntime;
|
|
12
|
+
channelRuntime: ChannelRuntime;
|
|
13
|
+
abortSignal?: AbortSignal;
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
}): TranscriptDispatcher;
|
|
16
|
+
export {};
|