@ozaiya/openclaw-channel 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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/src/api.d.ts +101 -0
- package/dist/src/api.js +247 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/botActions.d.ts +25 -0
- package/dist/src/botActions.js +51 -0
- package/dist/src/botActions.js.map +1 -0
- package/dist/src/channel.d.ts +10 -0
- package/dist/src/channel.js +643 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/crypto.d.ts +45 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/runtime.d.ts +6 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/types.d.ts +87 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/webhook.d.ts +14 -0
- package/dist/src/webhook.js +138 -0
- package/dist/src/webhook.js.map +1 -0
- package/package.json +73 -0
- package/types/openclaw-plugin-sdk.d.ts +109 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { registerPluginHttpRoute } from "openclaw/plugin-sdk";
|
|
2
|
+
import { unwrapGroupKey, decryptMessage, encryptMessage } from "./crypto.js";
|
|
3
|
+
import { sendMessage, probeApi, fetchGroups, toggleReaction, editMessage, deleteMessage, pinMessage, unpinMessage, uploadFile, } from "./api.js";
|
|
4
|
+
import { createOzaiyaWebhookHandler } from "./webhook.js";
|
|
5
|
+
import { getOzaiyaRuntime } from "./runtime.js";
|
|
6
|
+
const DEFAULT_API_BASE_URL = "https://api.ozai.dev";
|
|
7
|
+
const DEFAULT_WEBHOOK_PATH = "/ozaiya/webhook";
|
|
8
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
9
|
+
// In-memory cache of unwrapped group keys (groupId → Uint8Array)
|
|
10
|
+
const unwrappedKeys = new Map();
|
|
11
|
+
// Runtime state tracking
|
|
12
|
+
const runtimeState = new Map();
|
|
13
|
+
function recordState(accountId, patch) {
|
|
14
|
+
const key = `ozaiya:${accountId}`;
|
|
15
|
+
const existing = runtimeState.get(key) ?? {
|
|
16
|
+
running: false,
|
|
17
|
+
lastStartAt: null,
|
|
18
|
+
lastStopAt: null,
|
|
19
|
+
lastError: null,
|
|
20
|
+
lastInboundAt: null,
|
|
21
|
+
lastOutboundAt: null,
|
|
22
|
+
};
|
|
23
|
+
runtimeState.set(key, { ...existing, ...patch });
|
|
24
|
+
}
|
|
25
|
+
function getState(accountId) {
|
|
26
|
+
return runtimeState.get(`ozaiya:${accountId}`);
|
|
27
|
+
}
|
|
28
|
+
/** Resolve the Ozaiya channel config from OpenClaw config */
|
|
29
|
+
function resolveConfig(cfg) {
|
|
30
|
+
const channels = cfg.channels;
|
|
31
|
+
const ozaiya = channels?.ozaiya;
|
|
32
|
+
return ozaiya ?? null;
|
|
33
|
+
}
|
|
34
|
+
/** List all configured account IDs (default + named) */
|
|
35
|
+
function listAccountIds(cfg) {
|
|
36
|
+
const ozaiya = resolveConfig(cfg);
|
|
37
|
+
if (!ozaiya)
|
|
38
|
+
return [];
|
|
39
|
+
const ids = [];
|
|
40
|
+
if (ozaiya.botToken?.trim()) {
|
|
41
|
+
ids.push(DEFAULT_ACCOUNT_ID);
|
|
42
|
+
}
|
|
43
|
+
if (ozaiya.accounts) {
|
|
44
|
+
for (const id of Object.keys(ozaiya.accounts)) {
|
|
45
|
+
ids.push(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (ids.length === 0 && ozaiya.enabled !== false) {
|
|
49
|
+
ids.push(DEFAULT_ACCOUNT_ID);
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
/** Resolve a full account from config */
|
|
54
|
+
function resolveAccount(cfg, accountId) {
|
|
55
|
+
const ozaiya = resolveConfig(cfg);
|
|
56
|
+
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
57
|
+
const apiBaseUrl = ozaiya?.apiBaseUrl ?? DEFAULT_API_BASE_URL;
|
|
58
|
+
// Named account from accounts map
|
|
59
|
+
if (id !== DEFAULT_ACCOUNT_ID && ozaiya?.accounts?.[id]) {
|
|
60
|
+
const acc = ozaiya.accounts[id];
|
|
61
|
+
return {
|
|
62
|
+
accountId: id,
|
|
63
|
+
enabled: acc.enabled !== false,
|
|
64
|
+
botToken: acc.botToken ?? "",
|
|
65
|
+
botPrivateKey: acc.botPrivateKey ?? "",
|
|
66
|
+
webhookSecret: acc.webhookSecret ?? "",
|
|
67
|
+
apiBaseUrl,
|
|
68
|
+
webhookPath: acc.webhookPath ?? `${DEFAULT_WEBHOOK_PATH}/${id}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Default account (top-level fields)
|
|
72
|
+
return {
|
|
73
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
74
|
+
enabled: ozaiya?.enabled !== false,
|
|
75
|
+
botToken: ozaiya?.botToken ?? "",
|
|
76
|
+
botPrivateKey: ozaiya?.botPrivateKey ?? "",
|
|
77
|
+
webhookSecret: ozaiya?.webhookSecret ?? "",
|
|
78
|
+
apiBaseUrl,
|
|
79
|
+
webhookPath: ozaiya?.webhookPath ?? DEFAULT_WEBHOOK_PATH,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Fetch group keys from API and unwrap them into the cache */
|
|
83
|
+
async function fetchAndUnwrapGroupKeys(account) {
|
|
84
|
+
const groups = await fetchGroups(account.apiBaseUrl, account.botToken);
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const g of groups) {
|
|
87
|
+
if (unwrappedKeys.has(g.id)) {
|
|
88
|
+
count++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const key = await unwrapGroupKey(g.wrappedGroupKey, account.botPrivateKey);
|
|
92
|
+
if (key) {
|
|
93
|
+
unwrappedKeys.set(g.id, key);
|
|
94
|
+
count++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
99
|
+
/** Wait for an AbortSignal to fire */
|
|
100
|
+
function waitForAbort(signal) {
|
|
101
|
+
if (signal.aborted)
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export const ozaiyaPlugin = {
|
|
108
|
+
id: "ozaiya",
|
|
109
|
+
meta: {
|
|
110
|
+
id: "ozaiya",
|
|
111
|
+
label: "Ozaiya",
|
|
112
|
+
selectionLabel: "Ozaiya Chat",
|
|
113
|
+
docsPath: "/channels/ozaiya",
|
|
114
|
+
blurb: "E2E encrypted group chat with Ozaiya.",
|
|
115
|
+
},
|
|
116
|
+
capabilities: {
|
|
117
|
+
chatTypes: ["group", "direct"],
|
|
118
|
+
reactions: true,
|
|
119
|
+
threads: false,
|
|
120
|
+
media: true,
|
|
121
|
+
nativeCommands: false,
|
|
122
|
+
blockStreaming: true,
|
|
123
|
+
},
|
|
124
|
+
reload: { configPrefixes: ["channels.ozaiya"] },
|
|
125
|
+
config: {
|
|
126
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
127
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
|
|
128
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
129
|
+
isConfigured: (account) => Boolean(account.botToken?.trim() && account.botPrivateKey?.trim() && account.webhookSecret?.trim()),
|
|
130
|
+
describeAccount: (account) => ({
|
|
131
|
+
accountId: account.accountId,
|
|
132
|
+
enabled: account.enabled,
|
|
133
|
+
configured: Boolean(account.botToken?.trim() && account.botPrivateKey?.trim()),
|
|
134
|
+
webhookPath: account.webhookPath,
|
|
135
|
+
baseUrl: account.apiBaseUrl,
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
security: {
|
|
139
|
+
resolveDmPolicy: () => ({
|
|
140
|
+
policy: "open",
|
|
141
|
+
allowFrom: [],
|
|
142
|
+
policyPath: "channels.ozaiya.dmPolicy",
|
|
143
|
+
allowFromPath: "channels.ozaiya.",
|
|
144
|
+
approveHint: "N/A (group-only)",
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
messaging: {
|
|
148
|
+
normalizeTarget: (target) => target.trim() || undefined,
|
|
149
|
+
targetResolver: {
|
|
150
|
+
looksLikeId: (id) => Boolean(id?.trim()),
|
|
151
|
+
hint: "<groupId>",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
directory: {
|
|
155
|
+
self: async () => null,
|
|
156
|
+
listPeers: async () => [],
|
|
157
|
+
listGroups: async ({ cfg, accountId }) => {
|
|
158
|
+
const account = resolveAccount(cfg, accountId);
|
|
159
|
+
if (!account.botToken?.trim())
|
|
160
|
+
return [];
|
|
161
|
+
try {
|
|
162
|
+
const groups = await fetchGroups(account.apiBaseUrl, account.botToken);
|
|
163
|
+
return groups.map((g) => ({
|
|
164
|
+
id: g.id,
|
|
165
|
+
name: g.name ?? g.id,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
setup: {
|
|
174
|
+
applyAccountConfig: ({ cfg, input }) => {
|
|
175
|
+
const ozaiyaConfig = resolveConfig(cfg) ?? {};
|
|
176
|
+
return {
|
|
177
|
+
...cfg,
|
|
178
|
+
channels: {
|
|
179
|
+
...cfg.channels,
|
|
180
|
+
ozaiya: {
|
|
181
|
+
...ozaiyaConfig,
|
|
182
|
+
enabled: true,
|
|
183
|
+
...(input.token ? { botToken: input.token } : {}),
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
outbound: {
|
|
190
|
+
deliveryMode: "direct",
|
|
191
|
+
textChunkLimit: 4000,
|
|
192
|
+
sendText: async ({ to, text, cfg, accountId }) => {
|
|
193
|
+
const account = resolveAccount(cfg, accountId);
|
|
194
|
+
const groupId = to.replace(/^ozaiya:group:/, "");
|
|
195
|
+
let groupKey = unwrappedKeys.get(groupId);
|
|
196
|
+
if (!groupKey) {
|
|
197
|
+
// On-demand fetch for groups added after startup
|
|
198
|
+
await fetchAndUnwrapGroupKeys(account);
|
|
199
|
+
groupKey = unwrappedKeys.get(groupId);
|
|
200
|
+
if (!groupKey) {
|
|
201
|
+
throw new Error(`No group key available for group ${groupId}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const encrypted = encryptMessage({ text }, groupKey);
|
|
205
|
+
const result = await sendMessage(account.apiBaseUrl, account.botToken, groupId, encrypted);
|
|
206
|
+
recordState(account.accountId, { lastOutboundAt: Date.now() });
|
|
207
|
+
return {
|
|
208
|
+
channel: "ozaiya",
|
|
209
|
+
messageId: result.message.id,
|
|
210
|
+
chatId: groupId,
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
sendMedia: async ({ to, text, mediaUrl, cfg, accountId }) => {
|
|
214
|
+
const account = resolveAccount(cfg, accountId);
|
|
215
|
+
const groupId = to.replace(/^ozaiya:group:/, "");
|
|
216
|
+
let groupKey = unwrappedKeys.get(groupId);
|
|
217
|
+
if (!groupKey) {
|
|
218
|
+
await fetchAndUnwrapGroupKeys(account);
|
|
219
|
+
groupKey = unwrappedKeys.get(groupId);
|
|
220
|
+
if (!groupKey) {
|
|
221
|
+
throw new Error(`No group key available for group ${groupId}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let fileInfo;
|
|
225
|
+
if (mediaUrl) {
|
|
226
|
+
const mediaRes = await fetch(mediaUrl, { signal: AbortSignal.timeout(60_000) });
|
|
227
|
+
if (!mediaRes.ok) {
|
|
228
|
+
throw new Error(`Failed to download media: ${mediaRes.status}`);
|
|
229
|
+
}
|
|
230
|
+
const buffer = Buffer.from(await mediaRes.arrayBuffer());
|
|
231
|
+
const filename = new URL(mediaUrl).pathname.split("/").pop() || "file";
|
|
232
|
+
const mime = mediaRes.headers.get("content-type") || "application/octet-stream";
|
|
233
|
+
fileInfo = await uploadFile(account.apiBaseUrl, account.botToken, groupId, filename, mime, buffer);
|
|
234
|
+
}
|
|
235
|
+
const content = {
|
|
236
|
+
text: text || undefined,
|
|
237
|
+
files: fileInfo ? [fileInfo] : undefined,
|
|
238
|
+
};
|
|
239
|
+
const encrypted = encryptMessage(content, groupKey);
|
|
240
|
+
const result = await sendMessage(account.apiBaseUrl, account.botToken, groupId, encrypted);
|
|
241
|
+
recordState(account.accountId, { lastOutboundAt: Date.now() });
|
|
242
|
+
return {
|
|
243
|
+
channel: "ozaiya",
|
|
244
|
+
messageId: result.message.id,
|
|
245
|
+
chatId: groupId,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
mentions: {
|
|
250
|
+
stripMentions: ({ text }) => {
|
|
251
|
+
// Strip leading @mention trigger (e.g. "@codex remind me..." → "remind me...")
|
|
252
|
+
return text.replace(/^@\S+\s*/u, "").trim() || text;
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
groups: {
|
|
256
|
+
// requireMention is enforced server-side via replyAllowed in the webhook payload;
|
|
257
|
+
// return undefined here to defer to the framework's default group policy.
|
|
258
|
+
resolveRequireMention: () => undefined,
|
|
259
|
+
},
|
|
260
|
+
actions: {
|
|
261
|
+
listActions: (_params) => ["send", "react", "edit", "unsend", "pin", "unpin"],
|
|
262
|
+
supportsButtons: (_params) => true,
|
|
263
|
+
handleAction: async (ctx) => {
|
|
264
|
+
const { action, params, cfg, accountId } = ctx;
|
|
265
|
+
const account = resolveAccount(cfg, accountId);
|
|
266
|
+
switch (action) {
|
|
267
|
+
case "react": {
|
|
268
|
+
const messageId = params.messageId;
|
|
269
|
+
const emoji = params.emoji;
|
|
270
|
+
const result = await toggleReaction(account.apiBaseUrl, account.botToken, messageId, emoji);
|
|
271
|
+
return { ok: true, ...result };
|
|
272
|
+
}
|
|
273
|
+
case "edit": {
|
|
274
|
+
const messageId = params.messageId;
|
|
275
|
+
const content = params.content;
|
|
276
|
+
const groupId = params.chatId;
|
|
277
|
+
let groupKey = unwrappedKeys.get(groupId);
|
|
278
|
+
if (!groupKey) {
|
|
279
|
+
await fetchAndUnwrapGroupKeys(account);
|
|
280
|
+
groupKey = unwrappedKeys.get(groupId);
|
|
281
|
+
if (!groupKey) {
|
|
282
|
+
throw new Error(`No group key available for group ${groupId}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const encrypted = encryptMessage({ text: content }, groupKey);
|
|
286
|
+
await editMessage(account.apiBaseUrl, account.botToken, messageId, encrypted);
|
|
287
|
+
return { ok: true };
|
|
288
|
+
}
|
|
289
|
+
case "unsend": {
|
|
290
|
+
const messageId = params.messageId;
|
|
291
|
+
await deleteMessage(account.apiBaseUrl, account.botToken, messageId);
|
|
292
|
+
return { ok: true, deleted: true };
|
|
293
|
+
}
|
|
294
|
+
case "pin": {
|
|
295
|
+
const messageId = params.messageId;
|
|
296
|
+
await pinMessage(account.apiBaseUrl, account.botToken, messageId);
|
|
297
|
+
return { ok: true };
|
|
298
|
+
}
|
|
299
|
+
case "unpin": {
|
|
300
|
+
const messageId = params.messageId;
|
|
301
|
+
await unpinMessage(account.apiBaseUrl, account.botToken, messageId);
|
|
302
|
+
return { ok: true };
|
|
303
|
+
}
|
|
304
|
+
default:
|
|
305
|
+
throw new Error(`Action "${action}" not supported`);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
status: {
|
|
310
|
+
defaultRuntime: {
|
|
311
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
312
|
+
running: false,
|
|
313
|
+
lastStartAt: null,
|
|
314
|
+
lastStopAt: null,
|
|
315
|
+
lastError: null,
|
|
316
|
+
},
|
|
317
|
+
probeAccount: async ({ account, timeoutMs }) => probeApi(account.apiBaseUrl, account.botToken, timeoutMs),
|
|
318
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
319
|
+
const configured = Boolean(account.botToken?.trim() && account.botPrivateKey?.trim() && account.webhookSecret?.trim());
|
|
320
|
+
const state = getState(account.accountId);
|
|
321
|
+
return {
|
|
322
|
+
accountId: account.accountId,
|
|
323
|
+
enabled: account.enabled,
|
|
324
|
+
configured,
|
|
325
|
+
running: state?.running ?? runtime?.running ?? false,
|
|
326
|
+
lastStartAt: state?.lastStartAt ?? runtime?.lastStartAt ?? null,
|
|
327
|
+
lastStopAt: state?.lastStopAt ?? runtime?.lastStopAt ?? null,
|
|
328
|
+
lastError: state?.lastError ?? runtime?.lastError ?? null,
|
|
329
|
+
lastInboundAt: state?.lastInboundAt ?? runtime?.lastInboundAt ?? null,
|
|
330
|
+
lastOutboundAt: state?.lastOutboundAt ?? runtime?.lastOutboundAt ?? null,
|
|
331
|
+
mode: "webhook",
|
|
332
|
+
probe,
|
|
333
|
+
webhookPath: account.webhookPath,
|
|
334
|
+
baseUrl: account.apiBaseUrl,
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
agentTools: (({ cfg }) => {
|
|
339
|
+
if (!cfg)
|
|
340
|
+
return [];
|
|
341
|
+
const account = resolveAccount(cfg);
|
|
342
|
+
if (!account.botToken?.trim())
|
|
343
|
+
return [];
|
|
344
|
+
return [createScheduleMessageTool(account)];
|
|
345
|
+
}),
|
|
346
|
+
gateway: {
|
|
347
|
+
startAccount: async (ctx) => {
|
|
348
|
+
const account = ctx.account;
|
|
349
|
+
// Validate config
|
|
350
|
+
if (!account.botToken?.trim()) {
|
|
351
|
+
throw new Error("Ozaiya: botToken is required");
|
|
352
|
+
}
|
|
353
|
+
if (!account.botPrivateKey?.trim()) {
|
|
354
|
+
throw new Error("Ozaiya: botPrivateKey is required");
|
|
355
|
+
}
|
|
356
|
+
if (!account.webhookSecret?.trim()) {
|
|
357
|
+
throw new Error("Ozaiya: webhookSecret is required");
|
|
358
|
+
}
|
|
359
|
+
ctx.log?.info(`[${account.accountId}] starting Ozaiya provider`);
|
|
360
|
+
// Record starting state
|
|
361
|
+
recordState(account.accountId, { running: true, lastStartAt: Date.now() });
|
|
362
|
+
// Fetch and unwrap group keys from API
|
|
363
|
+
const keyCount = await fetchAndUnwrapGroupKeys(account);
|
|
364
|
+
ctx.log?.info(`[${account.accountId}] unwrapped ${keyCount} group key(s)`);
|
|
365
|
+
// Register webhook HTTP handler
|
|
366
|
+
const unregisterHttp = registerPluginHttpRoute({
|
|
367
|
+
path: account.webhookPath,
|
|
368
|
+
auth: "plugin",
|
|
369
|
+
replaceExisting: true,
|
|
370
|
+
pluginId: "ozaiya",
|
|
371
|
+
source: "ozaiya-channel",
|
|
372
|
+
log: (msg) => ctx.log?.info(msg),
|
|
373
|
+
handler: createOzaiyaWebhookHandler({
|
|
374
|
+
webhookSecret: account.webhookSecret,
|
|
375
|
+
log: (msg) => ctx.log?.info(msg),
|
|
376
|
+
onMessage: async (payload) => {
|
|
377
|
+
await handleInboundMessage(payload, ctx);
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
ctx.log?.info(`[${account.accountId}] registered webhook at ${account.webhookPath}`);
|
|
382
|
+
// Block until abort
|
|
383
|
+
const stopHandler = () => {
|
|
384
|
+
ctx.log?.info(`[${account.accountId}] stopping Ozaiya provider`);
|
|
385
|
+
unregisterHttp();
|
|
386
|
+
recordState(account.accountId, { running: false, lastStopAt: Date.now() });
|
|
387
|
+
};
|
|
388
|
+
if (ctx.abortSignal.aborted) {
|
|
389
|
+
stopHandler();
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
ctx.abortSignal.addEventListener("abort", stopHandler, { once: true });
|
|
393
|
+
await waitForAbort(ctx.abortSignal);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Create the schedule_message agent tool for a specific account.
|
|
400
|
+
*
|
|
401
|
+
* Uses an in-memory setTimeout, so scheduled messages survive only until the
|
|
402
|
+
* process restarts. For persistent scheduling, pair with a server-side BullMQ queue.
|
|
403
|
+
*/
|
|
404
|
+
function createScheduleMessageTool(account) {
|
|
405
|
+
return {
|
|
406
|
+
label: "Schedule Message",
|
|
407
|
+
name: "schedule_message",
|
|
408
|
+
ownerOnly: false,
|
|
409
|
+
description: "Schedule an encrypted message to be sent to an Ozaiya group at a future time. " +
|
|
410
|
+
"Provide either delaySeconds (1–86400) for a relative delay, " +
|
|
411
|
+
"or atTime (ISO-8601) for an absolute send time. " +
|
|
412
|
+
"Note: scheduled messages are held in memory and will be lost if the gateway restarts.",
|
|
413
|
+
// Plain JSON Schema — cast as `any` because openclaw's ChannelAgentTool
|
|
414
|
+
// expects a TypeBox TObject, but JSON Schema objects are accepted at runtime.
|
|
415
|
+
parameters: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
groupId: {
|
|
419
|
+
type: "string",
|
|
420
|
+
description: "The Ozaiya group ID to send the message to.",
|
|
421
|
+
},
|
|
422
|
+
message: {
|
|
423
|
+
type: "string",
|
|
424
|
+
description: "The message text to send.",
|
|
425
|
+
},
|
|
426
|
+
delaySeconds: {
|
|
427
|
+
type: "number",
|
|
428
|
+
minimum: 1,
|
|
429
|
+
maximum: 86400,
|
|
430
|
+
description: "Delay in seconds before sending (1–86400). Use this OR atTime.",
|
|
431
|
+
},
|
|
432
|
+
atTime: {
|
|
433
|
+
type: "string",
|
|
434
|
+
description: "ISO-8601 datetime when to send, e.g. 2026-03-09T20:00:00Z. Use this OR delaySeconds.",
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
required: ["groupId", "message"],
|
|
438
|
+
},
|
|
439
|
+
execute: async (_toolCallId, rawArgs) => {
|
|
440
|
+
const args = rawArgs;
|
|
441
|
+
const { groupId, message, delaySeconds, atTime } = args;
|
|
442
|
+
if (!groupId?.trim()) {
|
|
443
|
+
return { content: [{ type: "text", text: "Error: groupId is required." }] };
|
|
444
|
+
}
|
|
445
|
+
if (!message?.trim()) {
|
|
446
|
+
return { content: [{ type: "text", text: "Error: message is required." }] };
|
|
447
|
+
}
|
|
448
|
+
let delayMs;
|
|
449
|
+
if (atTime) {
|
|
450
|
+
const ts = new Date(atTime).getTime();
|
|
451
|
+
if (isNaN(ts)) {
|
|
452
|
+
return { content: [{ type: "text", text: `Error: invalid atTime "${atTime}".` }] };
|
|
453
|
+
}
|
|
454
|
+
delayMs = ts - Date.now();
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
delayMs = (delaySeconds ?? 0) * 1000;
|
|
458
|
+
}
|
|
459
|
+
if (delayMs < 0) {
|
|
460
|
+
return { content: [{ type: "text", text: "Error: scheduled time is in the past." }] };
|
|
461
|
+
}
|
|
462
|
+
if (delayMs > 86_400_000) {
|
|
463
|
+
return { content: [{ type: "text", text: "Error: cannot schedule more than 24 hours in advance." }] };
|
|
464
|
+
}
|
|
465
|
+
const scheduledAt = new Date(Date.now() + delayMs).toISOString();
|
|
466
|
+
setTimeout(async () => {
|
|
467
|
+
let groupKey = unwrappedKeys.get(groupId);
|
|
468
|
+
if (!groupKey) {
|
|
469
|
+
try {
|
|
470
|
+
await fetchAndUnwrapGroupKeys(account);
|
|
471
|
+
groupKey = unwrappedKeys.get(groupId);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// ignore key-fetch errors silently
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!groupKey)
|
|
478
|
+
return;
|
|
479
|
+
const encrypted = encryptMessage({ text: message }, groupKey);
|
|
480
|
+
await sendMessage(account.apiBaseUrl, account.botToken, groupId, encrypted).catch(() => { });
|
|
481
|
+
recordState(account.accountId, { lastOutboundAt: Date.now() });
|
|
482
|
+
}, delayMs);
|
|
483
|
+
const delayHuman = delayMs < 60_000
|
|
484
|
+
? `${Math.round(delayMs / 1000)} seconds`
|
|
485
|
+
: delayMs < 3_600_000
|
|
486
|
+
? `${Math.round(delayMs / 60_000)} minutes`
|
|
487
|
+
: `${(delayMs / 3_600_000).toFixed(1)} hours`;
|
|
488
|
+
return {
|
|
489
|
+
content: [{ type: "text", text: `Message scheduled for ${scheduledAt} (in ${delayHuman}).` }],
|
|
490
|
+
};
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Handle an inbound webhook message:
|
|
496
|
+
* 1. Decrypt message content
|
|
497
|
+
* 2. Resolve agent route via PluginRuntime
|
|
498
|
+
* 3. Build inbound envelope
|
|
499
|
+
* 4. Dispatch to agent via buffered block dispatcher
|
|
500
|
+
* 5. Deliver reply (encrypt + send)
|
|
501
|
+
*/
|
|
502
|
+
async function handleInboundMessage(payload,
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
ctx) {
|
|
505
|
+
const { groupId, message, replyAllowed = true, context } = payload;
|
|
506
|
+
const account = ctx.account;
|
|
507
|
+
// Record inbound activity
|
|
508
|
+
recordState(account.accountId, { lastInboundAt: Date.now() });
|
|
509
|
+
// Get group key (on-demand fetch if not cached)
|
|
510
|
+
let groupKey = unwrappedKeys.get(groupId);
|
|
511
|
+
if (!groupKey) {
|
|
512
|
+
ctx.log?.info?.(`ozaiya: group key not cached for ${groupId}, fetching from API`);
|
|
513
|
+
try {
|
|
514
|
+
await fetchAndUnwrapGroupKeys(account);
|
|
515
|
+
groupKey = unwrappedKeys.get(groupId);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
ctx.log?.warn?.(`ozaiya: failed to fetch group keys: ${String(err)}`);
|
|
519
|
+
}
|
|
520
|
+
if (!groupKey) {
|
|
521
|
+
ctx.log?.warn?.(`ozaiya: no group key for group ${groupId}, skipping message`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// Decrypt message
|
|
526
|
+
const content = decryptMessage(message.content, groupKey);
|
|
527
|
+
if (!content?.text) {
|
|
528
|
+
ctx.log?.warn?.("ozaiya: failed to decrypt message or empty text");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
ctx.log?.info(`ozaiya: received message from ${message.senderName} in group ${groupId}`);
|
|
532
|
+
// Skip reply when server indicates reply is not allowed (mentionToReply setting)
|
|
533
|
+
if (!replyAllowed) {
|
|
534
|
+
ctx.log?.info(`ozaiya: replyAllowed=false, skipping reply`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Decrypt context messages for InboundHistory (recent chat history for agent context)
|
|
538
|
+
const inboundHistory = [];
|
|
539
|
+
if (context && context.length > 0) {
|
|
540
|
+
for (const ctxMsg of context) {
|
|
541
|
+
const decrypted = decryptMessage(ctxMsg.content, groupKey);
|
|
542
|
+
if (decrypted?.text) {
|
|
543
|
+
inboundHistory.push({
|
|
544
|
+
sender: ctxMsg.senderName,
|
|
545
|
+
body: decrypted.text,
|
|
546
|
+
timestamp: ctxMsg.createdAt,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
ctx.log?.info(`ozaiya: decrypted ${inboundHistory.length} context messages for InboundHistory`);
|
|
551
|
+
}
|
|
552
|
+
// Access agent dispatch APIs via PluginRuntime (set during plugin registration)
|
|
553
|
+
const runtime = getOzaiyaRuntime();
|
|
554
|
+
const ch = runtime.channel;
|
|
555
|
+
// Resolve agent route
|
|
556
|
+
const route = ch.routing.resolveAgentRoute({
|
|
557
|
+
cfg: ctx.cfg,
|
|
558
|
+
channel: "ozaiya",
|
|
559
|
+
accountId: account.accountId,
|
|
560
|
+
peer: {
|
|
561
|
+
kind: "group",
|
|
562
|
+
id: groupId,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
const fromAddress = `ozaiya:group:${groupId}`;
|
|
566
|
+
const conversationLabel = `group:${groupId}`;
|
|
567
|
+
// Build inbound session envelope context
|
|
568
|
+
const storePath = ch.session.resolveStorePath(undefined, {
|
|
569
|
+
agentId: route.agentId,
|
|
570
|
+
});
|
|
571
|
+
const previousTimestamp = ch.session.readSessionUpdatedAt({
|
|
572
|
+
storePath,
|
|
573
|
+
sessionKey: route.sessionKey,
|
|
574
|
+
});
|
|
575
|
+
const envelopeOptions = ch.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
576
|
+
const body = ch.reply.formatAgentEnvelope({
|
|
577
|
+
channel: "Ozaiya",
|
|
578
|
+
from: `${message.senderName} (${conversationLabel})`,
|
|
579
|
+
timestamp: message.createdAt,
|
|
580
|
+
previousTimestamp,
|
|
581
|
+
envelope: envelopeOptions,
|
|
582
|
+
body: content.text,
|
|
583
|
+
});
|
|
584
|
+
// Build finalized context for dispatch
|
|
585
|
+
const msgCtx = ch.reply.finalizeInboundContext({
|
|
586
|
+
Body: body,
|
|
587
|
+
BodyForAgent: content.text,
|
|
588
|
+
RawBody: content.text,
|
|
589
|
+
CommandBody: content.text,
|
|
590
|
+
From: fromAddress,
|
|
591
|
+
To: fromAddress,
|
|
592
|
+
SessionKey: route.sessionKey,
|
|
593
|
+
AccountId: route.accountId,
|
|
594
|
+
ChatType: "group",
|
|
595
|
+
ConversationLabel: conversationLabel,
|
|
596
|
+
GroupSubject: groupId,
|
|
597
|
+
SenderId: message.senderId,
|
|
598
|
+
SenderName: message.senderName,
|
|
599
|
+
Provider: "ozaiya",
|
|
600
|
+
Surface: "ozaiya",
|
|
601
|
+
MessageSid: message.id,
|
|
602
|
+
Timestamp: message.createdAt,
|
|
603
|
+
ReplyToId: message.replyToId ?? undefined,
|
|
604
|
+
CommandAuthorized: true,
|
|
605
|
+
InboundHistory: inboundHistory.length > 0 ? inboundHistory : undefined,
|
|
606
|
+
OriginatingChannel: "ozaiya",
|
|
607
|
+
OriginatingTo: fromAddress,
|
|
608
|
+
});
|
|
609
|
+
// Record inbound session
|
|
610
|
+
void ch.session.recordInboundSession({
|
|
611
|
+
storePath,
|
|
612
|
+
sessionKey: route.sessionKey,
|
|
613
|
+
ctx: msgCtx,
|
|
614
|
+
onRecordError: (err) => {
|
|
615
|
+
ctx.log?.warn?.(`ozaiya: failed recording session: ${String(err)}`);
|
|
616
|
+
},
|
|
617
|
+
}).catch((err) => {
|
|
618
|
+
ctx.log?.warn?.(`ozaiya: failed recording session: ${String(err)}`);
|
|
619
|
+
});
|
|
620
|
+
// Dispatch to agent with buffered block dispatcher
|
|
621
|
+
await ch.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
622
|
+
ctx: msgCtx,
|
|
623
|
+
cfg: ctx.cfg,
|
|
624
|
+
dispatcherOptions: {
|
|
625
|
+
deliver: async (replyPayload, _info) => {
|
|
626
|
+
const replyText = replyPayload.text;
|
|
627
|
+
ctx.log?.info?.(`ozaiya: deliver called, text length=${replyText?.length ?? 0}, empty=${!replyText?.trim()}`);
|
|
628
|
+
if (!replyText?.trim())
|
|
629
|
+
return;
|
|
630
|
+
// Encrypt and send response
|
|
631
|
+
const encrypted = encryptMessage({ text: replyText }, groupKey);
|
|
632
|
+
ctx.log?.info?.(`ozaiya: sending reply to group ${groupId}`);
|
|
633
|
+
await sendMessage(account.apiBaseUrl, account.botToken, groupId, encrypted);
|
|
634
|
+
recordState(account.accountId, { lastOutboundAt: Date.now() });
|
|
635
|
+
ctx.log?.info?.(`ozaiya: reply sent successfully`);
|
|
636
|
+
},
|
|
637
|
+
onError: (err) => {
|
|
638
|
+
ctx.log?.warn?.(`ozaiya: reply dispatch error: ${String(err)}`);
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
//# sourceMappingURL=channel.js.map
|