@2389research/hermit-openclaw 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/index.ts +20 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +39 -0
- package/src/channel.ts +123 -0
- package/src/client.ts +377 -0
- package/src/config-schema.ts +14 -0
- package/src/config.ts +32 -0
- package/src/dispatch.ts +34 -0
- package/src/normalize.ts +24 -0
- package/src/openclaw-plugin-sdk.d.ts +66 -0
- package/src/runtime.ts +17 -0
- package/src/status.ts +45 -0
- package/src/stream.ts +73 -0
- package/src/tools.ts +152 -0
- package/tsconfig.json +16 -0
package/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ABOUTME: Plugin entry point for the Hermit OpenClaw channel plugin.
|
|
2
|
+
// ABOUTME: Exports the default plugin object with register(api) for OpenClaw discovery.
|
|
3
|
+
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
6
|
+
import { hermitPlugin } from "./src/channel.js";
|
|
7
|
+
import { setHermitRuntime } from "./src/runtime.js";
|
|
8
|
+
|
|
9
|
+
const plugin = {
|
|
10
|
+
id: "hermit-openclaw",
|
|
11
|
+
name: "Hermit E2EE",
|
|
12
|
+
description: "E2EE messaging via the Hermit daemon gRPC API",
|
|
13
|
+
configSchema: emptyPluginConfigSchema(),
|
|
14
|
+
register(api: OpenClawPluginApi) {
|
|
15
|
+
setHermitRuntime(api.runtime);
|
|
16
|
+
api.registerChannel({ plugin: hermitPlugin });
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@2389research/hermit-openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw channel plugin for Hermit E2EE messaging",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": "./index.ts",
|
|
8
|
+
"channel": "hermit",
|
|
9
|
+
"install": "npm install"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"proto",
|
|
14
|
+
"index.ts",
|
|
15
|
+
"openclaw.plugin.json",
|
|
16
|
+
"tsconfig.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"proto": "buf generate"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@grpc/grpc-js": "^1.10.0",
|
|
25
|
+
"@grpc/proto-loader": "^0.7.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"openclaw": ">=2025.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"openclaw": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.7.0",
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ABOUTME: ChannelPlugin definition wiring all Hermit adapters for OpenClaw.
|
|
2
|
+
// ABOUTME: Manages gRPC client lifecycle, inbox subscriptions, and message routing.
|
|
3
|
+
|
|
4
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
5
|
+
import { HermitClient } from "./client.js";
|
|
6
|
+
import type { ResolvedHermitAccount } from "./config.js";
|
|
7
|
+
import { listHermitAccountIds, resolveHermitAccount } from "./config.js";
|
|
8
|
+
import type { HermitChannelConfig } from "./config-schema.js";
|
|
9
|
+
import { parseSessionKey, normalizeHermitTarget } from "./normalize.js";
|
|
10
|
+
import type { HermitProbeResult } from "./status.js";
|
|
11
|
+
import { probeHermit } from "./status.js";
|
|
12
|
+
import { dispatchInboxEvent } from "./dispatch.js";
|
|
13
|
+
import type { InboxSubscription } from "./stream.js";
|
|
14
|
+
import { subscribeToInbox } from "./stream.js";
|
|
15
|
+
|
|
16
|
+
const activeClients = new Map<string, HermitClient>();
|
|
17
|
+
const activeSubscriptions = new Map<string, InboxSubscription>();
|
|
18
|
+
|
|
19
|
+
export const hermitPlugin: ChannelPlugin<ResolvedHermitAccount, HermitProbeResult> = {
|
|
20
|
+
id: "hermit",
|
|
21
|
+
|
|
22
|
+
meta: {
|
|
23
|
+
id: "hermit",
|
|
24
|
+
label: "Hermit E2EE",
|
|
25
|
+
order: 91,
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
capabilities: {
|
|
29
|
+
chatTypes: ["direct"],
|
|
30
|
+
media: false,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
config: {
|
|
34
|
+
listAccountIds(cfg: HermitChannelConfig): string[] {
|
|
35
|
+
return listHermitAccountIds(cfg);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
resolveAccount(cfg: HermitChannelConfig, accountId: string): ResolvedHermitAccount {
|
|
39
|
+
return resolveHermitAccount(cfg, accountId);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
defaultAccountId(cfg: HermitChannelConfig): string {
|
|
43
|
+
const ids = listHermitAccountIds(cfg);
|
|
44
|
+
return ids[0] ?? "default";
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
messaging: {
|
|
49
|
+
normalizeTarget: normalizeHermitTarget,
|
|
50
|
+
targetResolver: {
|
|
51
|
+
looksLikeId(raw: string): boolean {
|
|
52
|
+
return raw.startsWith("hermit:");
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
heartbeat: {
|
|
58
|
+
async checkReady(account: ResolvedHermitAccount): Promise<boolean> {
|
|
59
|
+
const result = await probeHermit(account, 3000);
|
|
60
|
+
return result.ok;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
status: {
|
|
65
|
+
async probeAccount(account: ResolvedHermitAccount): Promise<HermitProbeResult> {
|
|
66
|
+
return probeHermit(account);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
outbound: {
|
|
71
|
+
async sendText(
|
|
72
|
+
account: ResolvedHermitAccount,
|
|
73
|
+
sessionKey: string,
|
|
74
|
+
text: string
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const { channelId } = parseSessionKey(sessionKey);
|
|
77
|
+
const client = activeClients.get(account.accountId);
|
|
78
|
+
if (!client) {
|
|
79
|
+
throw new Error(`No active Hermit client for account ${account.accountId}`);
|
|
80
|
+
}
|
|
81
|
+
await client.postMessage(channelId, text, account.principalUid);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async sendMedia(): Promise<void> {
|
|
85
|
+
throw new Error("Hermit does not support media messages");
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
gateway: {
|
|
90
|
+
async startAccount(account: ResolvedHermitAccount): Promise<() => void> {
|
|
91
|
+
const client = new HermitClient({ address: account.endpoint });
|
|
92
|
+
activeClients.set(account.accountId, client);
|
|
93
|
+
|
|
94
|
+
let sub: InboxSubscription | undefined;
|
|
95
|
+
if (account.principalUid) {
|
|
96
|
+
sub = subscribeToInbox(
|
|
97
|
+
client,
|
|
98
|
+
account.principalUid,
|
|
99
|
+
(item) => {
|
|
100
|
+
dispatchInboxEvent(
|
|
101
|
+
{ client, accountId: account.accountId, principalUid: account.principalUid },
|
|
102
|
+
item
|
|
103
|
+
).catch((err) => {
|
|
104
|
+
console.error(`[hermit] dispatch error: ${err}`);
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
onError: (err) => console.error(`[hermit] inbox stream error: ${err.message}`),
|
|
109
|
+
onEnd: () => console.log(`[hermit] inbox stream ended for ${account.accountId}`),
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
activeSubscriptions.set(account.accountId, sub);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
sub?.cancel();
|
|
117
|
+
activeSubscriptions.delete(account.accountId);
|
|
118
|
+
client.close();
|
|
119
|
+
activeClients.delete(account.accountId);
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// ABOUTME: gRPC client wrapper that connects to the Hermit daemon.
|
|
2
|
+
// ABOUTME: Exposes typed methods for agent, channel, and event operations.
|
|
3
|
+
|
|
4
|
+
import * as grpc from "@grpc/grpc-js";
|
|
5
|
+
import * as protoLoader from "@grpc/proto-loader";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration options for the HermitClient.
|
|
15
|
+
*/
|
|
16
|
+
export interface HermitClientConfig {
|
|
17
|
+
/** gRPC endpoint address. Defaults to 127.0.0.1:13370. */
|
|
18
|
+
address?: string;
|
|
19
|
+
/** Path to the proto directory. Defaults to ../proto relative to this file. */
|
|
20
|
+
protoDir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mirrors the EventEnvelope message from hermit.v1.common.
|
|
25
|
+
*/
|
|
26
|
+
export interface EventEnvelope {
|
|
27
|
+
channelId: string;
|
|
28
|
+
eventId: string;
|
|
29
|
+
createdAtServer: { seconds: number; nanos: number } | null;
|
|
30
|
+
createdAtClient: { seconds: number; nanos: number } | null;
|
|
31
|
+
ciphertext: Buffer;
|
|
32
|
+
cipherSuite: string;
|
|
33
|
+
mlsEpoch: number;
|
|
34
|
+
payloadVersion: number;
|
|
35
|
+
size: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mirrors the DecryptedPayload message from hermit.v1.common.
|
|
40
|
+
*/
|
|
41
|
+
export interface DecryptedPayload {
|
|
42
|
+
eventId: string;
|
|
43
|
+
channelId: string;
|
|
44
|
+
senderUid: string;
|
|
45
|
+
eventKind: string;
|
|
46
|
+
content: Buffer;
|
|
47
|
+
sentAt: { seconds: number; nanos: number } | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mirrors the InboxItem message from hermit.v1.common.
|
|
52
|
+
*/
|
|
53
|
+
export interface InboxItem {
|
|
54
|
+
channelId: string;
|
|
55
|
+
eventId: string;
|
|
56
|
+
createdAtServer: { seconds: number; nanos: number } | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirrors the ChannelSummary message from hermit.v1.common.
|
|
61
|
+
*/
|
|
62
|
+
export interface ChannelSummary {
|
|
63
|
+
channelId: string;
|
|
64
|
+
channelType: number;
|
|
65
|
+
title: string | null;
|
|
66
|
+
lastEventId: string;
|
|
67
|
+
lastEventAt: { seconds: number; nanos: number } | null;
|
|
68
|
+
unreadCount: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mirrors the PrincipalInfo message from hermit.v1.common.
|
|
73
|
+
*/
|
|
74
|
+
export interface PrincipalInfo {
|
|
75
|
+
principalUid: string;
|
|
76
|
+
displayName: string;
|
|
77
|
+
principalType: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_ADDRESS = "127.0.0.1:13370";
|
|
81
|
+
|
|
82
|
+
const PROTO_LOADER_OPTIONS: protoLoader.Options = {
|
|
83
|
+
keepCase: false,
|
|
84
|
+
longs: Number,
|
|
85
|
+
enums: Number,
|
|
86
|
+
defaults: true,
|
|
87
|
+
oneofs: true,
|
|
88
|
+
includeDirs: [],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolves the default proto directory path, accounting for the symlink
|
|
93
|
+
* at openclaw-extension/proto -> ../../crates/hermit-proto/proto.
|
|
94
|
+
*/
|
|
95
|
+
function defaultProtoDir(): string {
|
|
96
|
+
return path.resolve(__dirname, "..", "proto");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Loads all Hermit proto files and returns the grpc package definition.
|
|
101
|
+
*/
|
|
102
|
+
function loadProtos(protoDir: string): grpc.GrpcObject {
|
|
103
|
+
const protoFiles = [
|
|
104
|
+
path.join(protoDir, "hermit", "v1", "common.proto"),
|
|
105
|
+
path.join(protoDir, "hermit", "v1", "agents.proto"),
|
|
106
|
+
path.join(protoDir, "hermit", "v1", "channels.proto"),
|
|
107
|
+
path.join(protoDir, "hermit", "v1", "events.proto"),
|
|
108
|
+
path.join(protoDir, "hermit", "v1", "auth.proto"),
|
|
109
|
+
path.join(protoDir, "hermit", "v1", "presence.proto"),
|
|
110
|
+
path.join(protoDir, "hermit", "v1", "admin.proto"),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const packageDef = protoLoader.loadSync(protoFiles, {
|
|
114
|
+
...PROTO_LOADER_OPTIONS,
|
|
115
|
+
includeDirs: [protoDir],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return grpc.loadPackageDefinition(packageDef);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Typed wrapper around the Hermit daemon's gRPC services.
|
|
123
|
+
*
|
|
124
|
+
* Usage:
|
|
125
|
+
* const client = new HermitClient();
|
|
126
|
+
* const { agentUid, localToken } = await client.registerAgent("my-agent");
|
|
127
|
+
*/
|
|
128
|
+
export class HermitClient {
|
|
129
|
+
readonly address: string;
|
|
130
|
+
readonly protoDir: string;
|
|
131
|
+
|
|
132
|
+
private agentService: grpc.Client;
|
|
133
|
+
private channelService: grpc.Client;
|
|
134
|
+
private eventService: grpc.Client;
|
|
135
|
+
|
|
136
|
+
constructor(config: HermitClientConfig = {}) {
|
|
137
|
+
this.address = config.address ?? DEFAULT_ADDRESS;
|
|
138
|
+
this.protoDir = config.protoDir ?? defaultProtoDir();
|
|
139
|
+
|
|
140
|
+
const pkgDef = loadProtos(this.protoDir);
|
|
141
|
+
const hermitV1 = (pkgDef.hermit as grpc.GrpcObject).v1 as grpc.GrpcObject;
|
|
142
|
+
|
|
143
|
+
const credentials = grpc.credentials.createInsecure();
|
|
144
|
+
|
|
145
|
+
const AgentServiceClient = hermitV1.AgentService as typeof grpc.Client;
|
|
146
|
+
const ChannelServiceClient = hermitV1.ChannelService as typeof grpc.Client;
|
|
147
|
+
const EventServiceClient = hermitV1.EventService as typeof grpc.Client;
|
|
148
|
+
|
|
149
|
+
this.agentService = new AgentServiceClient(this.address, credentials);
|
|
150
|
+
this.channelService = new ChannelServiceClient(this.address, credentials);
|
|
151
|
+
this.eventService = new EventServiceClient(this.address, credentials);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Registers a new agent with the Hermit daemon.
|
|
156
|
+
*/
|
|
157
|
+
registerAgent(
|
|
158
|
+
displayName: string
|
|
159
|
+
): Promise<{ agentUid: string; localToken: string }> {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
(this.agentService as any).registerAgent(
|
|
162
|
+
{ displayName },
|
|
163
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
164
|
+
if (err) {
|
|
165
|
+
reject(err);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
resolve({
|
|
169
|
+
agentUid: response.agentUid,
|
|
170
|
+
localToken: response.localToken,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Creates a direct channel with the specified target agent.
|
|
179
|
+
*/
|
|
180
|
+
createChannel(
|
|
181
|
+
targetAgentUid: string
|
|
182
|
+
): Promise<{ channelId: string }> {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
(this.channelService as any).createChannelDirect(
|
|
185
|
+
{ targetAgentUid },
|
|
186
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
187
|
+
if (err) {
|
|
188
|
+
reject(err);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
resolve({ channelId: response.channelId });
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Posts an encrypted message to a channel. The daemon handles MLS encryption.
|
|
199
|
+
*/
|
|
200
|
+
postMessage(
|
|
201
|
+
channelId: string,
|
|
202
|
+
plaintext: string,
|
|
203
|
+
senderUid?: string
|
|
204
|
+
): Promise<{ eventId: string }> {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const request: Record<string, unknown> = {
|
|
207
|
+
channelId,
|
|
208
|
+
plaintext: Buffer.from(plaintext, "utf-8"),
|
|
209
|
+
eventKind: "message",
|
|
210
|
+
};
|
|
211
|
+
if (senderUid !== undefined) {
|
|
212
|
+
request.senderUid = senderUid;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
(this.eventService as any).postEncryptedEvent(
|
|
216
|
+
request,
|
|
217
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
218
|
+
if (err) {
|
|
219
|
+
reject(err);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
resolve({ eventId: response.eventId });
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Decrypts a single event from a channel.
|
|
230
|
+
*/
|
|
231
|
+
decryptEvent(
|
|
232
|
+
channelId: string,
|
|
233
|
+
eventId: string,
|
|
234
|
+
principalUid?: string
|
|
235
|
+
): Promise<DecryptedPayload> {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const request: Record<string, unknown> = { channelId, eventId };
|
|
238
|
+
if (principalUid !== undefined) {
|
|
239
|
+
request.principalUid = principalUid;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
(this.eventService as any).decryptEvent(
|
|
243
|
+
request,
|
|
244
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
245
|
+
if (err) {
|
|
246
|
+
reject(err);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
resolve({
|
|
250
|
+
eventId: response.eventId,
|
|
251
|
+
channelId: response.channelId,
|
|
252
|
+
senderUid: response.senderUid,
|
|
253
|
+
eventKind: response.eventKind,
|
|
254
|
+
content: Buffer.from(response.content),
|
|
255
|
+
sentAt: response.sentAt ?? null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Lists encrypted event envelopes from a channel.
|
|
264
|
+
*/
|
|
265
|
+
listEvents(
|
|
266
|
+
channelId: string,
|
|
267
|
+
limit: number = 50
|
|
268
|
+
): Promise<EventEnvelope[]> {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
(this.eventService as any).listEvents(
|
|
271
|
+
{ channelId, limit },
|
|
272
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
273
|
+
if (err) {
|
|
274
|
+
reject(err);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const events: EventEnvelope[] = (response.events ?? []).map(
|
|
278
|
+
(e: any) => ({
|
|
279
|
+
channelId: e.channelId,
|
|
280
|
+
eventId: e.eventId,
|
|
281
|
+
createdAtServer: e.createdAtServer ?? null,
|
|
282
|
+
createdAtClient: e.createdAtClient ?? null,
|
|
283
|
+
ciphertext: Buffer.from(e.ciphertext ?? []),
|
|
284
|
+
cipherSuite: e.cipherSuite,
|
|
285
|
+
mlsEpoch: e.mlsEpoch,
|
|
286
|
+
payloadVersion: e.payloadVersion,
|
|
287
|
+
size: e.size ?? null,
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
resolve(events);
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Opens a server-streaming subscription to the inbox for a principal.
|
|
298
|
+
* Each time a new event arrives, the callback is invoked with the InboxItem.
|
|
299
|
+
* Returns the underlying gRPC call object so the caller can cancel it.
|
|
300
|
+
*/
|
|
301
|
+
streamInbox(
|
|
302
|
+
principalUid: string,
|
|
303
|
+
callback: (item: InboxItem) => void
|
|
304
|
+
): grpc.ClientReadableStream<any> {
|
|
305
|
+
const call = (this.eventService as any).streamInbox({ principalUid });
|
|
306
|
+
|
|
307
|
+
call.on("data", (data: any) => {
|
|
308
|
+
callback({
|
|
309
|
+
channelId: data.channelId,
|
|
310
|
+
eventId: data.eventId,
|
|
311
|
+
createdAtServer: data.createdAtServer ?? null,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return call;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Lists all agents registered with the daemon.
|
|
320
|
+
*/
|
|
321
|
+
listAgents(): Promise<PrincipalInfo[]> {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
(this.agentService as any).listAgents(
|
|
324
|
+
{},
|
|
325
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
326
|
+
if (err) {
|
|
327
|
+
reject(err);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
resolve(
|
|
331
|
+
(response.agents ?? []).map((a: any) => ({
|
|
332
|
+
principalUid: a.principalUid,
|
|
333
|
+
displayName: a.displayName,
|
|
334
|
+
principalType: a.principalType,
|
|
335
|
+
}))
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Lists all channels the daemon knows about.
|
|
344
|
+
*/
|
|
345
|
+
listChannels(): Promise<ChannelSummary[]> {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
(this.channelService as any).listChannels(
|
|
348
|
+
{},
|
|
349
|
+
(err: grpc.ServiceError | null, response: any) => {
|
|
350
|
+
if (err) {
|
|
351
|
+
reject(err);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
resolve(
|
|
355
|
+
(response.channels ?? []).map((c: any) => ({
|
|
356
|
+
channelId: c.channelId,
|
|
357
|
+
channelType: c.channelType,
|
|
358
|
+
title: c.title ?? null,
|
|
359
|
+
lastEventId: c.lastEventId,
|
|
360
|
+
lastEventAt: c.lastEventAt ?? null,
|
|
361
|
+
unreadCount: c.unreadCount,
|
|
362
|
+
}))
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Closes all underlying gRPC connections.
|
|
371
|
+
*/
|
|
372
|
+
close(): void {
|
|
373
|
+
this.agentService.close();
|
|
374
|
+
this.channelService.close();
|
|
375
|
+
this.eventService.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ABOUTME: TypeScript types and defaults for Hermit channel plugin configuration.
|
|
2
|
+
// ABOUTME: Defines per-account config shape and the top-level channel config.
|
|
3
|
+
|
|
4
|
+
export type HermitAccountConfig = {
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
principalUid?: string;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type HermitChannelConfig = {
|
|
11
|
+
accounts?: Record<string, HermitAccountConfig>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_ENDPOINT = "127.0.0.1:13370";
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ABOUTME: Account listing and resolution adapters for the Hermit channel plugin.
|
|
2
|
+
// ABOUTME: Reads account config from the OpenClaw channel settings and merges with defaults.
|
|
3
|
+
|
|
4
|
+
import type { HermitAccountConfig, HermitChannelConfig } from "./config-schema.js";
|
|
5
|
+
import { DEFAULT_ENDPOINT } from "./config-schema.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolvedHermitAccount {
|
|
8
|
+
accountId: string;
|
|
9
|
+
endpoint: string;
|
|
10
|
+
principalUid: string | undefined;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function listHermitAccountIds(cfg: HermitChannelConfig): string[] {
|
|
15
|
+
if (!cfg.accounts || Object.keys(cfg.accounts).length === 0) {
|
|
16
|
+
return ["default"];
|
|
17
|
+
}
|
|
18
|
+
return Object.keys(cfg.accounts);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveHermitAccount(
|
|
22
|
+
cfg: HermitChannelConfig,
|
|
23
|
+
accountId: string
|
|
24
|
+
): ResolvedHermitAccount {
|
|
25
|
+
const raw: HermitAccountConfig = cfg.accounts?.[accountId] ?? {};
|
|
26
|
+
return {
|
|
27
|
+
accountId,
|
|
28
|
+
endpoint: raw.endpoint ?? DEFAULT_ENDPOINT,
|
|
29
|
+
principalUid: raw.principalUid,
|
|
30
|
+
enabled: raw.enabled ?? true,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ABOUTME: Bridges inbound Hermit inbox events into OpenClaw's reply dispatch system.
|
|
2
|
+
// ABOUTME: Decrypts incoming events and feeds them to the agent via PluginRuntime.
|
|
3
|
+
|
|
4
|
+
import type { InboxItem } from "./client.js";
|
|
5
|
+
import { HermitClient } from "./client.js";
|
|
6
|
+
import { getHermitRuntime } from "./runtime.js";
|
|
7
|
+
|
|
8
|
+
export interface DispatchContext {
|
|
9
|
+
client: HermitClient;
|
|
10
|
+
accountId: string;
|
|
11
|
+
principalUid?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function dispatchInboxEvent(
|
|
15
|
+
ctx: DispatchContext,
|
|
16
|
+
item: InboxItem
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const _runtime = getHermitRuntime();
|
|
19
|
+
|
|
20
|
+
const decrypted = await ctx.client.decryptEvent(
|
|
21
|
+
item.channelId,
|
|
22
|
+
item.eventId,
|
|
23
|
+
ctx.principalUid
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const content = decrypted.content.toString("utf-8");
|
|
27
|
+
const sessionKey = `hermit:${ctx.accountId}:${item.channelId}`;
|
|
28
|
+
|
|
29
|
+
// Inbound event dispatched — the runtime bridge will be wired once
|
|
30
|
+
// OpenClaw's channel dispatch API stabilizes.
|
|
31
|
+
console.log(
|
|
32
|
+
`[hermit] inbound event ${item.eventId} on ${sessionKey}: ${content.slice(0, 80)}`
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ABOUTME: Session key formatting and parsing for Hermit channel sessions.
|
|
2
|
+
// ABOUTME: Keys follow the pattern hermit:{accountId}:{channelId}.
|
|
3
|
+
|
|
4
|
+
export interface ParsedSessionKey {
|
|
5
|
+
accountId: string;
|
|
6
|
+
channelId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatSessionKey(accountId: string, channelId: string): string {
|
|
10
|
+
return `hermit:${accountId}:${channelId}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseSessionKey(key: string): ParsedSessionKey {
|
|
14
|
+
const parts = key.split(":");
|
|
15
|
+
if (parts.length !== 3 || parts[0] !== "hermit") {
|
|
16
|
+
throw new Error(`Invalid Hermit session key: ${key}`);
|
|
17
|
+
}
|
|
18
|
+
return { accountId: parts[1], channelId: parts[2] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeHermitTarget(raw: string): string {
|
|
22
|
+
parseSessionKey(raw);
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ABOUTME: Type declarations for the openclaw/plugin-sdk module (peer dependency).
|
|
2
|
+
// ABOUTME: Minimal stubs covering the ChannelPlugin, PluginRuntime, and registration APIs.
|
|
3
|
+
|
|
4
|
+
declare module "openclaw/plugin-sdk" {
|
|
5
|
+
/** Runtime context provided by the OpenClaw host to plugins. */
|
|
6
|
+
export interface PluginRuntime {
|
|
7
|
+
/** Channel reply dispatch helpers. */
|
|
8
|
+
channel: {
|
|
9
|
+
reply: {
|
|
10
|
+
dispatchReplyWithBufferedBlockDispatcher: (
|
|
11
|
+
sessionKey: string,
|
|
12
|
+
blocks: string[]
|
|
13
|
+
) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** API object passed to a plugin's register() function. */
|
|
20
|
+
export interface OpenClawPluginApi {
|
|
21
|
+
runtime: PluginRuntime;
|
|
22
|
+
registerChannel(opts: { plugin: ChannelPlugin<any, any> }): void;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Channel plugin contract. Generic over account shape and probe result. */
|
|
27
|
+
export interface ChannelPlugin<TAccount = unknown, TProbe = unknown> {
|
|
28
|
+
id: string;
|
|
29
|
+
meta: {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
order: number;
|
|
33
|
+
};
|
|
34
|
+
capabilities: {
|
|
35
|
+
chatTypes: string[];
|
|
36
|
+
media: boolean;
|
|
37
|
+
};
|
|
38
|
+
config: {
|
|
39
|
+
listAccountIds(cfg: any): string[];
|
|
40
|
+
resolveAccount(cfg: any, accountId: string): TAccount;
|
|
41
|
+
defaultAccountId(cfg: any): string;
|
|
42
|
+
};
|
|
43
|
+
messaging: {
|
|
44
|
+
normalizeTarget: (raw: string) => string;
|
|
45
|
+
targetResolver: {
|
|
46
|
+
looksLikeId(raw: string): boolean;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
heartbeat: {
|
|
50
|
+
checkReady(account: TAccount): Promise<boolean>;
|
|
51
|
+
};
|
|
52
|
+
status: {
|
|
53
|
+
probeAccount(account: TAccount): Promise<TProbe>;
|
|
54
|
+
};
|
|
55
|
+
outbound: {
|
|
56
|
+
sendText(account: TAccount, sessionKey: string, text: string): Promise<void>;
|
|
57
|
+
sendMedia(...args: unknown[]): Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
gateway: {
|
|
60
|
+
startAccount(account: TAccount): Promise<() => void>;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Returns an empty config schema for plugins that need no configuration. */
|
|
65
|
+
export function emptyPluginConfigSchema(): Record<string, never>;
|
|
66
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ABOUTME: Singleton holder for the OpenClaw PluginRuntime instance.
|
|
2
|
+
// ABOUTME: Set during plugin registration, accessed by adapters at runtime.
|
|
3
|
+
|
|
4
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
5
|
+
|
|
6
|
+
let runtime: PluginRuntime | null = null;
|
|
7
|
+
|
|
8
|
+
export function setHermitRuntime(next: PluginRuntime): void {
|
|
9
|
+
runtime = next;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getHermitRuntime(): PluginRuntime {
|
|
13
|
+
if (!runtime) {
|
|
14
|
+
throw new Error("Hermit runtime not initialized — was register() called?");
|
|
15
|
+
}
|
|
16
|
+
return runtime;
|
|
17
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// ABOUTME: Health probe for the Hermit daemon gRPC endpoint.
|
|
2
|
+
// ABOUTME: Returns reachability and latency for account status reporting.
|
|
3
|
+
|
|
4
|
+
import * as grpc from "@grpc/grpc-js";
|
|
5
|
+
import type { ResolvedHermitAccount } from "./config.js";
|
|
6
|
+
|
|
7
|
+
export interface HermitProbeResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
latencyMs: number;
|
|
10
|
+
serverReachable: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function probeHermit(
|
|
15
|
+
account: ResolvedHermitAccount,
|
|
16
|
+
timeoutMs: number = 5000
|
|
17
|
+
): Promise<HermitProbeResult> {
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
const client = new grpc.Client(
|
|
20
|
+
account.endpoint,
|
|
21
|
+
grpc.credentials.createInsecure()
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return new Promise<HermitProbeResult>((resolve) => {
|
|
25
|
+
const deadline = new Date(Date.now() + timeoutMs);
|
|
26
|
+
client.waitForReady(deadline, (err) => {
|
|
27
|
+
const latencyMs = Date.now() - start;
|
|
28
|
+
client.close();
|
|
29
|
+
if (err) {
|
|
30
|
+
resolve({
|
|
31
|
+
ok: false,
|
|
32
|
+
latencyMs,
|
|
33
|
+
serverReachable: false,
|
|
34
|
+
error: err.message,
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
resolve({
|
|
38
|
+
ok: true,
|
|
39
|
+
latencyMs,
|
|
40
|
+
serverReachable: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ABOUTME: Inbox stream subscription helper for real-time event delivery.
|
|
2
|
+
// ABOUTME: Wraps the gRPC StreamInbox server-streaming call with a cancellable handle.
|
|
3
|
+
|
|
4
|
+
import * as grpc from "@grpc/grpc-js";
|
|
5
|
+
import { HermitClient, InboxItem } from "./client.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle returned by subscribeToInbox, allowing the caller to cancel the
|
|
9
|
+
* server-streaming subscription.
|
|
10
|
+
*/
|
|
11
|
+
export interface InboxSubscription {
|
|
12
|
+
/** Cancels the gRPC stream. Safe to call multiple times. */
|
|
13
|
+
cancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for configuring the inbox subscription behavior.
|
|
18
|
+
*/
|
|
19
|
+
export interface SubscribeOptions {
|
|
20
|
+
/** Called when the stream encounters a non-cancellation error. */
|
|
21
|
+
onError?: (err: grpc.ServiceError) => void;
|
|
22
|
+
/** Called when the server closes the stream. */
|
|
23
|
+
onEnd?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subscribes to the inbox stream for a given principal. Each time the daemon
|
|
28
|
+
* delivers a new event notification, the onEvent callback fires with the
|
|
29
|
+
* channel ID and event ID. The caller can stop listening by calling cancel()
|
|
30
|
+
* on the returned subscription handle.
|
|
31
|
+
*
|
|
32
|
+
* Usage:
|
|
33
|
+
* const sub = subscribeToInbox(client, agentUid, (item) => {
|
|
34
|
+
* console.log(`New event ${item.eventId} in ${item.channelId}`);
|
|
35
|
+
* });
|
|
36
|
+
* // later...
|
|
37
|
+
* sub.cancel();
|
|
38
|
+
*/
|
|
39
|
+
export function subscribeToInbox(
|
|
40
|
+
client: HermitClient,
|
|
41
|
+
principalUid: string,
|
|
42
|
+
onEvent: (item: InboxItem) => void,
|
|
43
|
+
options: SubscribeOptions = {}
|
|
44
|
+
): InboxSubscription {
|
|
45
|
+
const call = client.streamInbox(principalUid, onEvent);
|
|
46
|
+
|
|
47
|
+
call.on("error", (err: grpc.ServiceError) => {
|
|
48
|
+
// gRPC fires CANCELLED when we call cancel(); that is not a real error.
|
|
49
|
+
if (err.code === grpc.status.CANCELLED) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (options.onError) {
|
|
53
|
+
options.onError(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
call.on("end", () => {
|
|
58
|
+
if (options.onEnd) {
|
|
59
|
+
options.onEnd();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
cancel() {
|
|
67
|
+
if (!cancelled) {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
call.cancel();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ABOUTME: OpenClaw-compatible tool definitions and handler functions for Hermit.
|
|
2
|
+
// ABOUTME: Defines hermit_read_recent and hermit_send_message tools with JSON Schema parameters.
|
|
3
|
+
|
|
4
|
+
import { HermitClient, DecryptedPayload } from "./client.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* JSON Schema parameter definition for an OpenClaw tool property.
|
|
8
|
+
*/
|
|
9
|
+
interface ToolProperty {
|
|
10
|
+
type: string;
|
|
11
|
+
description: string;
|
|
12
|
+
default?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OpenClaw-compatible tool specification.
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolDefinition {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object";
|
|
23
|
+
properties: Record<string, ToolProperty>;
|
|
24
|
+
required: string[];
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tool definitions exposed to the OpenClaw runtime. Each tool maps to one or
|
|
30
|
+
* more gRPC calls against the Hermit daemon.
|
|
31
|
+
*/
|
|
32
|
+
export const tools: ToolDefinition[] = [
|
|
33
|
+
{
|
|
34
|
+
name: "hermit_read_recent",
|
|
35
|
+
description: "Read recent messages from a Hermit channel",
|
|
36
|
+
parameters: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
channel_id: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Channel ID to read messages from",
|
|
42
|
+
},
|
|
43
|
+
count: {
|
|
44
|
+
type: "number",
|
|
45
|
+
description: "Number of messages to read",
|
|
46
|
+
default: 10,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ["channel_id"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "hermit_send_message",
|
|
54
|
+
description: "Send an encrypted message to a Hermit channel",
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
channel_id: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Channel ID to send the message to",
|
|
61
|
+
},
|
|
62
|
+
text: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Message text to send",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ["channel_id", "text"],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parameters accepted by the hermit_read_recent tool.
|
|
74
|
+
*/
|
|
75
|
+
export interface ReadRecentParams {
|
|
76
|
+
channel_id: string;
|
|
77
|
+
count?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parameters accepted by the hermit_send_message tool.
|
|
82
|
+
*/
|
|
83
|
+
export interface SendMessageParams {
|
|
84
|
+
channel_id: string;
|
|
85
|
+
text: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A single decrypted message returned by handleReadRecent.
|
|
90
|
+
*/
|
|
91
|
+
export interface ReadRecentMessage {
|
|
92
|
+
eventId: string;
|
|
93
|
+
senderUid: string;
|
|
94
|
+
eventKind: string;
|
|
95
|
+
content: string;
|
|
96
|
+
sentAt: { seconds: number; nanos: number } | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Result from sending a message via handleSendMessage.
|
|
101
|
+
*/
|
|
102
|
+
export interface SendMessageResult {
|
|
103
|
+
eventId: string;
|
|
104
|
+
channelId: string;
|
|
105
|
+
status: "sent";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reads recent messages from a channel by listing events and decrypting each one.
|
|
110
|
+
*
|
|
111
|
+
* Fetches envelopes via ListEvents, then decrypts all in parallel via DecryptEvent.
|
|
112
|
+
*/
|
|
113
|
+
export async function handleReadRecent(
|
|
114
|
+
client: HermitClient,
|
|
115
|
+
params: ReadRecentParams
|
|
116
|
+
): Promise<ReadRecentMessage[]> {
|
|
117
|
+
const limit = params.count ?? 10;
|
|
118
|
+
const envelopes = await client.listEvents(params.channel_id, limit);
|
|
119
|
+
|
|
120
|
+
const messages = await Promise.all(
|
|
121
|
+
envelopes.map(async (envelope) => {
|
|
122
|
+
const decrypted: DecryptedPayload = await client.decryptEvent(
|
|
123
|
+
params.channel_id,
|
|
124
|
+
envelope.eventId
|
|
125
|
+
);
|
|
126
|
+
return {
|
|
127
|
+
eventId: decrypted.eventId,
|
|
128
|
+
senderUid: decrypted.senderUid,
|
|
129
|
+
eventKind: decrypted.eventKind,
|
|
130
|
+
content: decrypted.content.toString("utf-8"),
|
|
131
|
+
sentAt: decrypted.sentAt,
|
|
132
|
+
} satisfies ReadRecentMessage;
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return messages;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sends an encrypted message to a channel.
|
|
141
|
+
*/
|
|
142
|
+
export async function handleSendMessage(
|
|
143
|
+
client: HermitClient,
|
|
144
|
+
params: SendMessageParams
|
|
145
|
+
): Promise<SendMessageResult> {
|
|
146
|
+
const { eventId } = await client.postMessage(params.channel_id, params.text);
|
|
147
|
+
return {
|
|
148
|
+
eventId,
|
|
149
|
+
channelId: params.channel_id,
|
|
150
|
+
status: "sent",
|
|
151
|
+
};
|
|
152
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|