@otterware/openclaw-otter 0.1.3 → 0.2.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/index.js +128 -14
- package/otter.js +42 -3
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,19 +1,46 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
createChannelPluginBase,
|
|
4
5
|
createChatChannelPlugin,
|
|
5
6
|
defineChannelPluginEntry,
|
|
6
7
|
} from "openclaw/plugin-sdk/channel-core";
|
|
8
|
+
import { toInboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
|
|
9
|
+
import { defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
7
10
|
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
|
8
11
|
import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
|
|
9
12
|
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
10
13
|
import { createBrowserControl } from "./control.js";
|
|
14
|
+
import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
|
|
11
15
|
import { readWorkspaceFile } from "./files.js";
|
|
12
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
accountOf,
|
|
18
|
+
chatOf,
|
|
19
|
+
chatTarget,
|
|
20
|
+
isOwnerConfigured,
|
|
21
|
+
messageOf,
|
|
22
|
+
OTTER_TARGET,
|
|
23
|
+
OWNER,
|
|
24
|
+
pointerOf,
|
|
25
|
+
sessionOf,
|
|
26
|
+
tell,
|
|
27
|
+
} from "./otter.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where a message sent from Otter starts its run: outside the request that carried it,
|
|
31
|
+
* so the run is the channel's owner's and not that connection's, and outlives it.
|
|
32
|
+
*/
|
|
33
|
+
const receiving = new AsyncResource("otter-inbound");
|
|
34
|
+
|
|
35
|
+
const owner = defineStableChannelIngressIdentity({
|
|
36
|
+
key: "otter-sender",
|
|
37
|
+
normalize: (value) => String(value).trim(),
|
|
38
|
+
sensitivity: "pii",
|
|
39
|
+
});
|
|
13
40
|
|
|
14
41
|
async function deliver(ctx, payload) {
|
|
15
42
|
const account = accountOf(ctx.cfg);
|
|
16
|
-
const pointer = pointerOf({ sessionKey: ctx.sessionKey, payload });
|
|
43
|
+
const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
|
|
17
44
|
await tell(account, pointer, { signal: ctx.signal });
|
|
18
45
|
return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
|
|
19
46
|
}
|
|
@@ -38,33 +65,120 @@ export const otterChannel = createChatChannelPlugin({
|
|
|
38
65
|
},
|
|
39
66
|
}),
|
|
40
67
|
messaging: {
|
|
41
|
-
targetResolver: {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
targetResolver: {
|
|
69
|
+
looksLikeId: () => true,
|
|
70
|
+
hint: `"${OTTER_TARGET}", or "chat:<session key>" for one of Otter's chats`,
|
|
71
|
+
},
|
|
72
|
+
// A delivery to a chat is kept in its session; any other, in the agent's Otter session.
|
|
73
|
+
resolveOutboundSessionRoute: ({ agentId, target }) => {
|
|
74
|
+
const chat = chatOf(target);
|
|
75
|
+
const sessionKey = sessionOf(target, agentId);
|
|
76
|
+
const to = chat ? chatTarget(chat) : OTTER_TARGET;
|
|
77
|
+
return {
|
|
78
|
+
sessionKey,
|
|
79
|
+
baseSessionKey: sessionKey,
|
|
80
|
+
recipientSessionExact: true,
|
|
81
|
+
peer: { kind: "direct", id: to },
|
|
82
|
+
chatType: "direct",
|
|
83
|
+
from: "otter",
|
|
84
|
+
to,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
52
87
|
},
|
|
53
88
|
},
|
|
54
89
|
outbound: {
|
|
55
90
|
deliveryMode: "direct",
|
|
56
|
-
resolveTarget: () =>
|
|
91
|
+
resolveTarget: (params) => {
|
|
92
|
+
const chat = chatOf(params?.to);
|
|
93
|
+
return { ok: true, to: chat ? chatTarget(chat) : OTTER_TARGET };
|
|
94
|
+
},
|
|
57
95
|
sendText: (ctx) => deliver(ctx),
|
|
58
96
|
sendPayload: (ctx) => deliver(ctx, ctx.payload),
|
|
59
97
|
},
|
|
60
98
|
});
|
|
61
99
|
|
|
100
|
+
/** Runs what you sent from Otter as the channel's owner: an inbound message, as from any chat app. */
|
|
101
|
+
async function receive(api, cfg, message) {
|
|
102
|
+
const { sessionKey, agentId, text, messageId, files } = message;
|
|
103
|
+
const inbound = api.runtime.channel.inbound;
|
|
104
|
+
const saved = await Promise.all(
|
|
105
|
+
files.map((file) =>
|
|
106
|
+
api.runtime.channel.media.saveMediaBuffer(
|
|
107
|
+
Buffer.from(file.content, "base64"),
|
|
108
|
+
file.mimeType,
|
|
109
|
+
"inbound",
|
|
110
|
+
undefined,
|
|
111
|
+
typeof file.fileName === "string" ? file.fileName : undefined,
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
const channelIngress = await inbound.ingress.resolve({
|
|
116
|
+
channelId: "otter",
|
|
117
|
+
accountId: "default",
|
|
118
|
+
identity: owner,
|
|
119
|
+
subject: { stableId: OWNER },
|
|
120
|
+
conversation: { kind: "direct", id: sessionKey },
|
|
121
|
+
contextBinding: { agentId, sessionKey, messageId, inboundEventKind: "user_request" },
|
|
122
|
+
event: { kind: "message", authMode: "inbound", mayPair: false },
|
|
123
|
+
policy: { dmPolicy: "allowlist" },
|
|
124
|
+
allowFrom: [OWNER],
|
|
125
|
+
readStoreAllowFrom: async () => [],
|
|
126
|
+
});
|
|
127
|
+
const to = chatTarget(sessionKey);
|
|
128
|
+
const ctxPayload = await inbound.buildContext({
|
|
129
|
+
channelIngress,
|
|
130
|
+
channel: "otter",
|
|
131
|
+
accountId: "default",
|
|
132
|
+
messageId,
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
from: `otter:${OWNER}`,
|
|
135
|
+
sender: { id: OWNER, name: "Otter" },
|
|
136
|
+
conversation: { kind: "direct", id: sessionKey },
|
|
137
|
+
route: { agentId, accountId: "default", routeSessionKey: sessionKey },
|
|
138
|
+
reply: { to, originatingTo: to },
|
|
139
|
+
message: { body: text, rawBody: text, commandBody: text, bodyForAgent: text },
|
|
140
|
+
...(saved.length > 0 && {
|
|
141
|
+
media: toInboundMediaFacts(
|
|
142
|
+
saved.map((file) => ({ path: file.path, contentType: file.contentType, messageId })),
|
|
143
|
+
),
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
await inbound.dispatch({
|
|
147
|
+
cfg,
|
|
148
|
+
channel: "otter",
|
|
149
|
+
accountId: "default",
|
|
150
|
+
route: { agentId, sessionKey },
|
|
151
|
+
ctxPayload,
|
|
152
|
+
replyOptions: { runId: messageId, sourceReplyDeliveryMode: "automatic" },
|
|
153
|
+
// The reply is kept in the chat's session, which Otter is reading as it's written.
|
|
154
|
+
delivery: { deliver: async () => ({ visibleReplySent: true }) },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
62
158
|
export default defineChannelPluginEntry({
|
|
63
159
|
id: "otter",
|
|
64
160
|
name: "Otter",
|
|
65
161
|
description: "Send what your agents want you to see to Otter.",
|
|
66
162
|
plugin: otterChannel,
|
|
67
163
|
registerFull(api) {
|
|
164
|
+
// What you send in an Otter chat, as the gateway's owner: `chat.send`'s run, with the authority
|
|
165
|
+
// a configured channel owner has (an automation another chat made, for one).
|
|
166
|
+
api.registerGatewayMethod(
|
|
167
|
+
"otter.chat.send",
|
|
168
|
+
async ({ params, context }) => {
|
|
169
|
+
const cfg = context?.getRuntimeConfig?.() ?? api.config;
|
|
170
|
+
if (!isOwnerConfigured(cfg)) throw new Error("otter_not_owner");
|
|
171
|
+
const message = messageOf(params);
|
|
172
|
+
receiving.runInAsyncScope(() =>
|
|
173
|
+
runDetachedWebhookWork(() => receive(api, cfg, message)).catch((error) =>
|
|
174
|
+
api.logger?.warn?.(`otter: a message from Otter failed: ${String(error)}`),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return { runId: message.messageId, status: "started" };
|
|
178
|
+
},
|
|
179
|
+
{ scope: "operator.admin" },
|
|
180
|
+
);
|
|
181
|
+
|
|
68
182
|
// Otter's preview of a file an agent linked: PDFs and other files sessions.files.get won't send.
|
|
69
183
|
api.registerGatewayMethod(
|
|
70
184
|
"otter.files.get",
|
package/otter.js
CHANGED
|
@@ -5,25 +5,48 @@ export const inboxKey = (agentId) => `agent:${agentId}:otter:inbox`;
|
|
|
5
5
|
|
|
6
6
|
export const OTTER_TARGET = "inbox";
|
|
7
7
|
|
|
8
|
+
/** Who sends what you type in Otter: the gateway's owner once `commands.ownerAllowFrom` lists `otter:owner`. */
|
|
9
|
+
export const OWNER = "owner";
|
|
10
|
+
export const OWNER_ENTRY = `otter:${OWNER}`;
|
|
11
|
+
|
|
12
|
+
/** A chat in Otter is a target of its own: what's sent to it is kept in its session. */
|
|
13
|
+
export const chatTarget = (sessionKey) => `chat:${sessionKey}`;
|
|
14
|
+
|
|
8
15
|
export const agentOf = (sessionKey) =>
|
|
9
16
|
typeof sessionKey === "string" && sessionKey.startsWith("agent:")
|
|
10
17
|
? sessionKey.split(":")[1]
|
|
11
18
|
: undefined;
|
|
12
19
|
|
|
20
|
+
/** The chat a target names, if it names one. */
|
|
21
|
+
export function chatOf(to) {
|
|
22
|
+
const target = typeof to === "string" ? to.trim().replace(/^otter:/, "") : "";
|
|
23
|
+
if (!target.startsWith("chat:")) return undefined;
|
|
24
|
+
const sessionKey = target.slice("chat:".length);
|
|
25
|
+
return agentOf(sessionKey) ? sessionKey : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session a delivery to this target is kept in. */
|
|
29
|
+
export const sessionOf = (to, agentId) => chatOf(to) ?? inboxKey(agentId);
|
|
30
|
+
|
|
31
|
+
export const isOwnerConfigured = (cfg) =>
|
|
32
|
+
Array.isArray(cfg?.commands?.ownerAllowFrom) &&
|
|
33
|
+
cfg.commands.ownerAllowFrom.some((entry) => String(entry).trim() === OWNER_ENTRY);
|
|
34
|
+
|
|
13
35
|
/**
|
|
14
36
|
* What the relay is told of a delivery: never its text, only where to read it.
|
|
15
37
|
* Otter's apps read it from the gateway themselves.
|
|
16
38
|
*/
|
|
17
|
-
export function pointerOf({ sessionKey, payload, at = Date.now() }) {
|
|
39
|
+
export function pointerOf({ sessionKey, to, payload, at = Date.now() }) {
|
|
18
40
|
const data = payload?.channelData ?? {};
|
|
19
41
|
const approval = data.execApproval;
|
|
20
42
|
const question = data.askUser;
|
|
21
43
|
const session = approval?.sessionKey ?? sessionKey;
|
|
22
|
-
const
|
|
44
|
+
const chat = chatOf(to);
|
|
45
|
+
const agentId = approval?.agentId ?? agentOf(chat ?? session) ?? "main";
|
|
23
46
|
return {
|
|
24
47
|
kind: approval ? "approval" : question ? "question" : "message",
|
|
25
48
|
agentId,
|
|
26
|
-
sessionKey: approval || question ? (session ?? null) : inboxKey(agentId),
|
|
49
|
+
sessionKey: approval || question ? (session ?? null) : (chat ?? inboxKey(agentId)),
|
|
27
50
|
...(approval && {
|
|
28
51
|
approvalId: String(approval.approvalId),
|
|
29
52
|
approvalKind: approval.approvalKind === "plugin" ? "plugin" : "exec",
|
|
@@ -56,3 +79,19 @@ export async function tell(account, pointer, { fetch = globalThis.fetch, signal
|
|
|
56
79
|
if (!response.ok) throw new Error(`Otter's relay answered ${response.status}`);
|
|
57
80
|
return true;
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
/** A message sent from Otter, checked: where it goes and what it carries. */
|
|
84
|
+
export function messageOf(params) {
|
|
85
|
+
const { sessionKey, message, idempotencyKey, attachments } = params ?? {};
|
|
86
|
+
if (typeof sessionKey !== "string" || !agentOf(sessionKey))
|
|
87
|
+
throw new Error("sessionKey must be an agent's session");
|
|
88
|
+
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim())
|
|
89
|
+
throw new Error("idempotencyKey is required");
|
|
90
|
+
const files = Array.isArray(attachments) ? attachments : [];
|
|
91
|
+
for (const file of files)
|
|
92
|
+
if (typeof file?.content !== "string" || typeof file?.mimeType !== "string")
|
|
93
|
+
throw new Error("each attachment needs its content (base64) and mimeType");
|
|
94
|
+
const text = typeof message === "string" ? message : "";
|
|
95
|
+
if (!text.trim() && files.length === 0) throw new Error("message or attachments are required");
|
|
96
|
+
return { sessionKey, agentId: agentOf(sessionKey), text, messageId: idempotencyKey, files };
|
|
97
|
+
}
|
package/package.json
CHANGED