@towns-protocol/bot 0.0.260
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.txt +21 -0
- package/README.md +19 -0
- package/dist/bot.d.ts +273 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +705 -0
- package/dist/bot.js.map +1 -0
- package/dist/bot.test.d.ts +2 -0
- package/dist/bot.test.d.ts.map +1 -0
- package/dist/bot.test.js +420 -0
- package/dist/bot.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/bot.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
// Crypto Store uses IndexedDB, so we need to import fake-indexeddb/auto
|
|
2
|
+
import 'fake-indexeddb/auto';
|
|
3
|
+
import { create, fromBinary, fromJsonString, toBinary } from '@bufbuild/protobuf';
|
|
4
|
+
import { getRefEventIdFromChannelMessage, isChannelStreamId, isDMChannelStreamId, isGDMChannelStreamId, makeEvent, make_ChannelPayload_Message, make_DMChannelPayload_Message, make_GDMChannelPayload_Message, streamIdAsBytes, createTownsClient, streamIdAsString, make_MemberPayload_KeySolicitation, make_UserMetadataPayload_EncryptionDevice, logNever, userIdFromAddress, makeUserMetadataStreamId, unsafe_makeTags, getStreamMetadataUrl, makeBaseChainConfig, usernameChecksum, make_MemberPayload_Username, make_MemberPayload_DisplayName, make_UserMetadataPayload_ProfileImage, spaceIdFromChannelId, } from '@towns-protocol/sdk';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import EventEmitter from 'node:events';
|
|
7
|
+
import { ChannelMessageSchema, AppServiceRequestSchema, AppServiceResponseSchema, SessionKeysSchema, AppPrivateDataSchema, MembershipOp, ChunkedMediaSchema, EncryptedDataSchema, } from '@towns-protocol/proto';
|
|
8
|
+
import { bin_fromBase64, bin_fromHexString, bin_toHexString, bin_toString, check, } from '@towns-protocol/dlog';
|
|
9
|
+
import { AES_GCM_DERIVED_ALGORITHM, GroupEncryptionAlgorithmId, parseGroupEncryptionAlgorithmId, } from '@towns-protocol/encryption';
|
|
10
|
+
import { createClient as createViemClient, http, } from 'viem';
|
|
11
|
+
import { readContract, writeContract, } from 'viem/actions';
|
|
12
|
+
import { base, baseSepolia } from 'viem/chains';
|
|
13
|
+
// Import the jsonwebtoken library and necessary dlog utilities
|
|
14
|
+
import { default as jwt } from 'jsonwebtoken';
|
|
15
|
+
import { deriveKeyAndIV, encryptAESGCM, uint8ArrayToBase64 } from '@towns-protocol/sdk-crypto';
|
|
16
|
+
export class Bot extends EventEmitter {
|
|
17
|
+
server;
|
|
18
|
+
client;
|
|
19
|
+
botId;
|
|
20
|
+
viemClient;
|
|
21
|
+
jwtSecret;
|
|
22
|
+
currentMessageTags;
|
|
23
|
+
constructor(clientV2, viemClient, jwtSecretBase64) {
|
|
24
|
+
super();
|
|
25
|
+
this.client = clientV2;
|
|
26
|
+
this.botId = clientV2.userId;
|
|
27
|
+
this.viemClient = viemClient;
|
|
28
|
+
this.jwtSecret = bin_fromBase64(jwtSecretBase64);
|
|
29
|
+
this.currentMessageTags = undefined;
|
|
30
|
+
this.server = new Hono();
|
|
31
|
+
this.server.post('webhook', (c) => this.webhookResponseHandler(c));
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
await this.client.uploadDeviceKeys();
|
|
35
|
+
return { fetch: this.server.fetch };
|
|
36
|
+
}
|
|
37
|
+
async webhookResponseHandler(c) {
|
|
38
|
+
const authHeader = c.req.header('Authorization');
|
|
39
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
40
|
+
return c.text('Unauthorized: Missing or malformed token', 401);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const tokenString = authHeader.substring(7);
|
|
44
|
+
try {
|
|
45
|
+
// Convert botId (Ethereum address string, e.g., "0xABC") to raw bytes,
|
|
46
|
+
// then to a lowercase hex string for the audience check.
|
|
47
|
+
// This must match how the Go service creates the 'aud' claim.
|
|
48
|
+
const botAddressBytes = bin_fromHexString(this.botId);
|
|
49
|
+
// Assumes lowercase hex output without "0x"
|
|
50
|
+
const expectedAudience = bin_toHexString(botAddressBytes);
|
|
51
|
+
jwt.verify(tokenString, Buffer.from(this.jwtSecret), {
|
|
52
|
+
algorithms: ['HS256'],
|
|
53
|
+
audience: expectedAudience,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
let errorMessage = 'Unauthorized: Token verification failed';
|
|
58
|
+
if (err instanceof jwt.TokenExpiredError) {
|
|
59
|
+
errorMessage = 'Unauthorized: Token expired';
|
|
60
|
+
}
|
|
61
|
+
else if (err instanceof jwt.JsonWebTokenError) {
|
|
62
|
+
errorMessage = `Unauthorized: Invalid token (${err.message})`;
|
|
63
|
+
}
|
|
64
|
+
return c.text(errorMessage, 401);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const body = await c.req.arrayBuffer();
|
|
68
|
+
const encryptionDevice = this.client.crypto.getUserDevice();
|
|
69
|
+
const request = fromBinary(AppServiceRequestSchema, new Uint8Array(body));
|
|
70
|
+
const statusResponse = create(AppServiceResponseSchema, {
|
|
71
|
+
payload: {
|
|
72
|
+
case: 'status',
|
|
73
|
+
value: {
|
|
74
|
+
frameworkVersion: 1,
|
|
75
|
+
deviceKey: encryptionDevice.deviceKey,
|
|
76
|
+
fallbackKey: encryptionDevice.fallbackKey,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
let response = statusResponse;
|
|
81
|
+
if (request.payload.case === 'initialize') {
|
|
82
|
+
response = create(AppServiceResponseSchema, {
|
|
83
|
+
payload: {
|
|
84
|
+
case: 'initialize',
|
|
85
|
+
value: {
|
|
86
|
+
encryptionDevice,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else if (request.payload.case === 'events') {
|
|
92
|
+
for (const event of request.payload.value.events) {
|
|
93
|
+
await this.handleEvent(event);
|
|
94
|
+
}
|
|
95
|
+
response = statusResponse;
|
|
96
|
+
}
|
|
97
|
+
else if (request.payload.case === 'status') {
|
|
98
|
+
response = statusResponse;
|
|
99
|
+
}
|
|
100
|
+
c.header('Content-Type', 'application/x-protobuf');
|
|
101
|
+
return c.body(toBinary(AppServiceResponseSchema, response), 200);
|
|
102
|
+
}
|
|
103
|
+
// TODO: onTip
|
|
104
|
+
async handleEvent(appEvent) {
|
|
105
|
+
if (!appEvent.payload.case || !appEvent.payload.value)
|
|
106
|
+
return;
|
|
107
|
+
const streamId = streamIdAsString(appEvent.payload.value.streamId);
|
|
108
|
+
if (appEvent.payload.case === 'messages') {
|
|
109
|
+
const groupEncryptionSessionsMessages = await this.client
|
|
110
|
+
.unpackEnvelopes(appEvent.payload.value.groupEncryptionSessionsMessages)
|
|
111
|
+
.then((x) => x.flatMap((e) => {
|
|
112
|
+
if (e.event.payload.case === 'userInboxPayload' &&
|
|
113
|
+
e.event.payload.value.content.case === 'groupEncryptionSessions') {
|
|
114
|
+
return e.event.payload.value.content.value;
|
|
115
|
+
}
|
|
116
|
+
return [];
|
|
117
|
+
}));
|
|
118
|
+
const events = await this.client.unpackEnvelopes(appEvent.payload.value.messages);
|
|
119
|
+
const zip = events.map((m, i) => [m, groupEncryptionSessionsMessages[i]]);
|
|
120
|
+
for (const [parsed, groupEncryptionSession] of zip) {
|
|
121
|
+
if (parsed.creatorUserId === this.client.userId) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!parsed.event.payload.case) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
this.currentMessageTags = parsed.event.tags;
|
|
128
|
+
this.emit('streamEvent', this.client, {
|
|
129
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
130
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
131
|
+
channelId: streamId,
|
|
132
|
+
eventId: parsed.hashStr,
|
|
133
|
+
event: parsed,
|
|
134
|
+
});
|
|
135
|
+
switch (parsed.event.payload.case) {
|
|
136
|
+
case 'channelPayload':
|
|
137
|
+
case 'dmChannelPayload':
|
|
138
|
+
case 'gdmChannelPayload': {
|
|
139
|
+
if (!parsed.event.payload.value.content.case)
|
|
140
|
+
return;
|
|
141
|
+
if (parsed.event.payload.value.content.case === 'message') {
|
|
142
|
+
const decryptedSessions = await this.client.decryptSessions(streamId, groupEncryptionSession);
|
|
143
|
+
await this.client.crypto.importSessionKeys(streamId, decryptedSessions);
|
|
144
|
+
const eventCleartext = await this.client.crypto.decryptGroupEvent(streamId, parsed.event.payload.value.content.value);
|
|
145
|
+
let channelMessage;
|
|
146
|
+
if (typeof eventCleartext === 'string') {
|
|
147
|
+
channelMessage = fromJsonString(ChannelMessageSchema, eventCleartext);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
channelMessage = fromBinary(ChannelMessageSchema, eventCleartext);
|
|
151
|
+
}
|
|
152
|
+
await this.handleChannelMessage(streamId, parsed, channelMessage);
|
|
153
|
+
}
|
|
154
|
+
else if (parsed.event.payload.value.content.case === 'redaction') {
|
|
155
|
+
this.emit('eventRevoke', this.client, {
|
|
156
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
157
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
158
|
+
channelId: streamId,
|
|
159
|
+
refEventId: bin_toHexString(parsed.event.payload.value.content.value.eventId),
|
|
160
|
+
eventId: parsed.hashStr,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else if (parsed.event.payload.value.content.case === 'channelProperties') {
|
|
164
|
+
// TODO: currently, no support for channel properties (update name, topic)
|
|
165
|
+
}
|
|
166
|
+
else if (parsed.event.payload.value.content.case === 'inception') {
|
|
167
|
+
// TODO: is there any use case for this?
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
logNever(parsed.event.payload.value.content);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'memberPayload': {
|
|
175
|
+
if (parsed.event.payload.value.content.case === 'membership') {
|
|
176
|
+
const membership = parsed.event.payload.value.content.value;
|
|
177
|
+
const isChannel = isChannelStreamId(streamId);
|
|
178
|
+
// TODO: do we want Bot to listen to onSpaceJoin/onSpaceLeave?
|
|
179
|
+
if (!isChannel)
|
|
180
|
+
continue;
|
|
181
|
+
if (membership.op === MembershipOp.SO_JOIN) {
|
|
182
|
+
this.emit('channelJoin', this.client, {
|
|
183
|
+
userId: userIdFromAddress(membership.userAddress),
|
|
184
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
185
|
+
channelId: streamId,
|
|
186
|
+
eventId: parsed.hashStr,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (membership.op === MembershipOp.SO_LEAVE) {
|
|
190
|
+
this.emit('channelLeave', this.client, {
|
|
191
|
+
userId: userIdFromAddress(membership.userAddress),
|
|
192
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
193
|
+
channelId: streamId,
|
|
194
|
+
eventId: parsed.hashStr,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (appEvent.payload.case === 'solicitation') {
|
|
203
|
+
const missingSessionIds = appEvent.payload.value.sessionIds.filter((sessionId) => sessionId !== '');
|
|
204
|
+
await this.client.sendKeySolicitation(streamId, missingSessionIds);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
logNever(appEvent.payload);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async handleChannelMessage(streamId, parsed, { payload }) {
|
|
211
|
+
if (!payload.case) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
switch (payload.case) {
|
|
215
|
+
case 'post': {
|
|
216
|
+
if (payload.value.content.case === 'text') {
|
|
217
|
+
const hasBotMention = payload.value.content.value.mentions.some((m) => m.userId === this.botId);
|
|
218
|
+
const userId = userIdFromAddress(parsed.event.creatorAddress);
|
|
219
|
+
const replyId = payload.value.replyId;
|
|
220
|
+
const threadId = payload.value.threadId;
|
|
221
|
+
const forwardPayload = {
|
|
222
|
+
userId,
|
|
223
|
+
eventId: parsed.hashStr,
|
|
224
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
225
|
+
channelId: streamId,
|
|
226
|
+
message: payload.value.content.value.body,
|
|
227
|
+
isDm: isDMChannelStreamId(streamId),
|
|
228
|
+
isGdm: isGDMChannelStreamId(streamId),
|
|
229
|
+
};
|
|
230
|
+
if (replyId) {
|
|
231
|
+
this.emit('reply', this.client, forwardPayload);
|
|
232
|
+
}
|
|
233
|
+
else if (threadId) {
|
|
234
|
+
this.emit('threadMessage', this.client, {
|
|
235
|
+
...forwardPayload,
|
|
236
|
+
threadId,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
else if (hasBotMention) {
|
|
240
|
+
this.emit('mentioned', this.client, forwardPayload);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.emit('message', this.client, forwardPayload);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'reaction': {
|
|
249
|
+
this.emit('reaction', this.client, {
|
|
250
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
251
|
+
eventId: parsed.hashStr,
|
|
252
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
253
|
+
channelId: streamId,
|
|
254
|
+
reaction: payload.value.reaction,
|
|
255
|
+
messageId: payload.value.refEventId,
|
|
256
|
+
});
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case 'edit': {
|
|
260
|
+
// TODO: framework doesnt handle non-text edits
|
|
261
|
+
if (payload.value.post?.content.case !== 'text')
|
|
262
|
+
break;
|
|
263
|
+
this.emit('messageEdit', this.client, {
|
|
264
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
265
|
+
eventId: parsed.hashStr,
|
|
266
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
267
|
+
channelId: streamId,
|
|
268
|
+
refEventId: payload.value.refEventId,
|
|
269
|
+
message: payload.value.post?.content.value.body,
|
|
270
|
+
});
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case 'redaction': {
|
|
274
|
+
this.emit('redaction', this.client, {
|
|
275
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
276
|
+
eventId: parsed.hashStr,
|
|
277
|
+
spaceId: spaceIdFromChannelId(streamId),
|
|
278
|
+
channelId: streamId,
|
|
279
|
+
refEventId: payload.value.refEventId,
|
|
280
|
+
});
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
default:
|
|
284
|
+
logNever(payload);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Send a message to a stream
|
|
289
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
290
|
+
* @param message - The cleartext of the message
|
|
291
|
+
*/
|
|
292
|
+
async sendMessage(streamId, message, opts) {
|
|
293
|
+
const result = await this.client.sendMessage(streamId, message, opts, this.currentMessageTags);
|
|
294
|
+
this.currentMessageTags = undefined;
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Send a reaction to a stream
|
|
299
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
300
|
+
* @param refEventId - The eventId of the event to react to
|
|
301
|
+
* @param reaction - The reaction to send
|
|
302
|
+
*/
|
|
303
|
+
async sendReaction(streamId, refEventId, reaction) {
|
|
304
|
+
const result = await this.client.sendReaction(streamId, refEventId, reaction, this.currentMessageTags);
|
|
305
|
+
this.currentMessageTags = undefined;
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Remove an specific event from a stream
|
|
310
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
311
|
+
* @param refEventId - The eventId of the event to remove
|
|
312
|
+
*/
|
|
313
|
+
async removeEvent(streamId, refEventId) {
|
|
314
|
+
const result = await this.client.removeEvent(streamId, refEventId, this.currentMessageTags);
|
|
315
|
+
this.currentMessageTags = undefined;
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Edit an specific message from a stream
|
|
320
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
321
|
+
* @param messageId - The eventId of the message to edit
|
|
322
|
+
* @param message - The new message text
|
|
323
|
+
*/
|
|
324
|
+
async editMessage(streamId, messageId, message) {
|
|
325
|
+
const result = await this.client.editMessage(streamId, messageId, message, this.currentMessageTags);
|
|
326
|
+
this.currentMessageTags = undefined;
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
writeContract(tx) {
|
|
330
|
+
return writeContract(this.viemClient, tx);
|
|
331
|
+
}
|
|
332
|
+
readContract(parameters) {
|
|
333
|
+
return readContract(this.viemClient, parameters);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Triggered when someone sends a message.
|
|
337
|
+
* This is triggered for all messages, including direct messages and group messages.
|
|
338
|
+
*/
|
|
339
|
+
onMessage(fn) {
|
|
340
|
+
this.on('message', fn);
|
|
341
|
+
}
|
|
342
|
+
onRedaction(fn) {
|
|
343
|
+
this.on('redaction', fn);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Triggered when a message gets edited
|
|
347
|
+
*/
|
|
348
|
+
onMessageEdit(fn) {
|
|
349
|
+
this.on('messageEdit', fn);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Triggered when someone mentions the bot in a message
|
|
353
|
+
*/
|
|
354
|
+
onMentioned(fn) {
|
|
355
|
+
this.on('mentioned', fn);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Triggered when someone replies to a message
|
|
359
|
+
*/
|
|
360
|
+
onReply(fn) {
|
|
361
|
+
this.on('reply', fn);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Triggered when someone reacts to a message
|
|
365
|
+
*/
|
|
366
|
+
onReaction(fn) {
|
|
367
|
+
this.on('reaction', fn);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Triggered when a message is revoked by a moderator
|
|
371
|
+
*/
|
|
372
|
+
onEventRevoke(fn) {
|
|
373
|
+
this.on('eventRevoke', fn);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Triggered when someone tips the bot
|
|
377
|
+
* TODO: impl
|
|
378
|
+
*/
|
|
379
|
+
onTip(fn) {
|
|
380
|
+
this.on('tip', fn);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Triggered when someone joins a channel
|
|
384
|
+
*/
|
|
385
|
+
onChannelJoin(fn) {
|
|
386
|
+
this.on('channelJoin', fn);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Triggered when someone leaves a channel
|
|
390
|
+
*/
|
|
391
|
+
onChannelLeave(fn) {
|
|
392
|
+
this.on('channelLeave', fn);
|
|
393
|
+
}
|
|
394
|
+
onStreamEvent(fn) {
|
|
395
|
+
this.on('streamEvent', fn);
|
|
396
|
+
}
|
|
397
|
+
onThreadMessage(fn) {
|
|
398
|
+
this.on('threadMessage', fn);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export const makeTownsBot = async (appPrivateDataBase64, jwtSecretBase64, env, baseRpcUrl) => {
|
|
402
|
+
const { privateKey, encryptionDevice } = fromBinary(AppPrivateDataSchema, bin_fromBase64(appPrivateDataBase64));
|
|
403
|
+
const baseConfig = makeBaseChainConfig(env);
|
|
404
|
+
const viemClient = createViemClient({
|
|
405
|
+
transport: baseRpcUrl
|
|
406
|
+
? http(baseRpcUrl, { batch: true })
|
|
407
|
+
: http(baseConfig.rpcUrl, { batch: true }),
|
|
408
|
+
// TODO: would be nice if makeBaseChainConfig returned a viem chain
|
|
409
|
+
chain: baseConfig.chainConfig.chainId === base.id ? base : baseSepolia,
|
|
410
|
+
});
|
|
411
|
+
const client = await createTownsClient({
|
|
412
|
+
privateKey,
|
|
413
|
+
env,
|
|
414
|
+
encryptionDevice: {
|
|
415
|
+
fromExportedDevice: encryptionDevice,
|
|
416
|
+
},
|
|
417
|
+
}).then((x) => x.extend((townsClient) => buildBotActions(townsClient, viemClient)));
|
|
418
|
+
return new Bot(client, viemClient, jwtSecretBase64);
|
|
419
|
+
};
|
|
420
|
+
const buildBotActions = (client, viemClient) => {
|
|
421
|
+
const sendMessageEvent = async ({ streamId, payload, tags, }) => {
|
|
422
|
+
const stream = await client.getStream(streamId);
|
|
423
|
+
const { hash: prevMiniblockHash, miniblockNum: prevMiniblockNum } = await client.rpc.getLastMiniblockHash({
|
|
424
|
+
streamId: streamIdAsBytes(streamId),
|
|
425
|
+
});
|
|
426
|
+
const eventTags = {
|
|
427
|
+
...unsafe_makeTags(payload),
|
|
428
|
+
participatingUserAddresses: tags?.participatingUserAddresses || [],
|
|
429
|
+
threadId: tags?.threadId || undefined,
|
|
430
|
+
};
|
|
431
|
+
const encryptionAlgorithm = stream.snapshot.members?.encryptionAlgorithm?.algorithm;
|
|
432
|
+
const message = await client.crypto.encryptGroupEvent(streamId, toBinary(ChannelMessageSchema, payload), encryptionAlgorithm ||
|
|
433
|
+
client.defaultGroupEncryptionAlgorithm);
|
|
434
|
+
message.refEventId = getRefEventIdFromChannelMessage(payload);
|
|
435
|
+
let event;
|
|
436
|
+
if (isChannelStreamId(streamId)) {
|
|
437
|
+
event = await makeEvent(client.signer, make_ChannelPayload_Message(message), prevMiniblockHash, prevMiniblockNum, eventTags);
|
|
438
|
+
}
|
|
439
|
+
else if (isDMChannelStreamId(streamId)) {
|
|
440
|
+
event = await makeEvent(client.signer, make_DMChannelPayload_Message(message), prevMiniblockHash, prevMiniblockNum, eventTags);
|
|
441
|
+
}
|
|
442
|
+
else if (isGDMChannelStreamId(streamId)) {
|
|
443
|
+
event = await makeEvent(client.signer, make_GDMChannelPayload_Message(message), prevMiniblockHash, prevMiniblockNum, eventTags);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
throw new Error(`Invalid stream ID type: ${streamId}`);
|
|
447
|
+
}
|
|
448
|
+
const eventId = bin_toHexString(event.hash);
|
|
449
|
+
const { error } = await client.rpc.addEvent({
|
|
450
|
+
streamId: streamIdAsBytes(streamId),
|
|
451
|
+
event,
|
|
452
|
+
});
|
|
453
|
+
return {
|
|
454
|
+
error,
|
|
455
|
+
eventId,
|
|
456
|
+
prevMiniblockHash,
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
const sendKeySolicitation = async (streamId, sessionIds) => {
|
|
460
|
+
const encryptionDevice = client.crypto.getUserDevice();
|
|
461
|
+
const missingSessionIds = sessionIds.filter((sessionId) => sessionId !== '');
|
|
462
|
+
const { hash: prevMiniblockHash } = await client.rpc.getLastMiniblockHash({
|
|
463
|
+
streamId: streamIdAsBytes(streamId),
|
|
464
|
+
});
|
|
465
|
+
const event = await makeEvent(client.signer, make_MemberPayload_KeySolicitation({
|
|
466
|
+
deviceKey: encryptionDevice.deviceKey,
|
|
467
|
+
fallbackKey: encryptionDevice.fallbackKey,
|
|
468
|
+
isNewDevice: missingSessionIds.length === 0,
|
|
469
|
+
sessionIds: missingSessionIds,
|
|
470
|
+
}), prevMiniblockHash);
|
|
471
|
+
const eventId = bin_toHexString(event.hash);
|
|
472
|
+
const { error } = await client.rpc.addEvent({
|
|
473
|
+
streamId: streamIdAsBytes(streamId),
|
|
474
|
+
event,
|
|
475
|
+
});
|
|
476
|
+
return { eventId, error };
|
|
477
|
+
};
|
|
478
|
+
const uploadDeviceKeys = async () => {
|
|
479
|
+
const streamId = streamIdAsBytes(makeUserMetadataStreamId(client.userId));
|
|
480
|
+
const { hash: prevMiniblockHash } = await client.rpc.getLastMiniblockHash({
|
|
481
|
+
streamId,
|
|
482
|
+
});
|
|
483
|
+
const encryptionDevice = client.crypto.getUserDevice();
|
|
484
|
+
const event = await makeEvent(client.signer, make_UserMetadataPayload_EncryptionDevice({
|
|
485
|
+
...encryptionDevice,
|
|
486
|
+
}), prevMiniblockHash);
|
|
487
|
+
const eventId = bin_toHexString(event.hash);
|
|
488
|
+
const { error } = await client.rpc.addEvent({ streamId, event });
|
|
489
|
+
return { eventId, error };
|
|
490
|
+
};
|
|
491
|
+
const sendMessage = async (streamId, message, opts, tags) => {
|
|
492
|
+
const payload = create(ChannelMessageSchema, {
|
|
493
|
+
payload: {
|
|
494
|
+
case: 'post',
|
|
495
|
+
value: {
|
|
496
|
+
threadId: opts?.threadId,
|
|
497
|
+
replyId: opts?.replyId,
|
|
498
|
+
replyPreview: opts?.replyId ? '🙈' : undefined,
|
|
499
|
+
threadPreview: opts?.threadId ? '🙉' : undefined,
|
|
500
|
+
content: {
|
|
501
|
+
case: 'text',
|
|
502
|
+
value: {
|
|
503
|
+
body: message,
|
|
504
|
+
attachments: opts?.attachments || [],
|
|
505
|
+
mentions: opts?.mentions || [],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
512
|
+
};
|
|
513
|
+
const editMessage = async (streamId, messageId, message, tags) => {
|
|
514
|
+
const payload = create(ChannelMessageSchema, {
|
|
515
|
+
payload: {
|
|
516
|
+
case: 'edit',
|
|
517
|
+
value: {
|
|
518
|
+
refEventId: messageId,
|
|
519
|
+
post: {
|
|
520
|
+
content: { case: 'text', value: { body: message } },
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
526
|
+
};
|
|
527
|
+
const sendDm = (userId, message, opts) => sendMessage(userId, message, opts);
|
|
528
|
+
const sendReaction = async (streamId, messageId, reaction, tags) => {
|
|
529
|
+
const payload = create(ChannelMessageSchema, {
|
|
530
|
+
payload: { case: 'reaction', value: { refEventId: messageId, reaction } },
|
|
531
|
+
});
|
|
532
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
533
|
+
};
|
|
534
|
+
const removeEvent = async (streamId, messageId, tags) => {
|
|
535
|
+
const payload = create(ChannelMessageSchema, {
|
|
536
|
+
payload: { case: 'redaction', value: { refEventId: messageId } },
|
|
537
|
+
});
|
|
538
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
539
|
+
};
|
|
540
|
+
const setUsername = async (streamId, username) => {
|
|
541
|
+
const encryptedData = await client.crypto.encryptGroupEvent(streamId, new TextEncoder().encode(username), client.defaultGroupEncryptionAlgorithm);
|
|
542
|
+
encryptedData.checksum = usernameChecksum(username, streamId);
|
|
543
|
+
const { hash: prevMiniblockHash } = await client.rpc.getLastMiniblockHash({
|
|
544
|
+
streamId: streamIdAsBytes(streamId),
|
|
545
|
+
});
|
|
546
|
+
const event = await makeEvent(client.signer, make_MemberPayload_Username(encryptedData), prevMiniblockHash);
|
|
547
|
+
const eventId = bin_toHexString(event.hash);
|
|
548
|
+
const { error } = await client.rpc.addEvent({
|
|
549
|
+
streamId: streamIdAsBytes(streamId),
|
|
550
|
+
event,
|
|
551
|
+
});
|
|
552
|
+
return { eventId, error };
|
|
553
|
+
};
|
|
554
|
+
const setDisplayName = async (streamId, displayName) => {
|
|
555
|
+
const encryptedData = await client.crypto.encryptGroupEvent(streamId, new TextEncoder().encode(displayName), client.defaultGroupEncryptionAlgorithm);
|
|
556
|
+
const { hash: prevMiniblockHash } = await client.rpc.getLastMiniblockHash({
|
|
557
|
+
streamId: streamIdAsBytes(streamId),
|
|
558
|
+
});
|
|
559
|
+
const event = await makeEvent(client.signer, make_MemberPayload_DisplayName(encryptedData), prevMiniblockHash);
|
|
560
|
+
const eventId = bin_toHexString(event.hash);
|
|
561
|
+
const { error } = await client.rpc.addEvent({
|
|
562
|
+
streamId: streamIdAsBytes(streamId),
|
|
563
|
+
event,
|
|
564
|
+
});
|
|
565
|
+
return { eventId, error };
|
|
566
|
+
};
|
|
567
|
+
const setUserProfileImage = async (chunkedMediaInfo) => {
|
|
568
|
+
const streamId = makeUserMetadataStreamId(client.userId);
|
|
569
|
+
const { key, iv } = await deriveKeyAndIV(client.userId);
|
|
570
|
+
const { ciphertext } = await encryptAESGCM(toBinary(ChunkedMediaSchema, create(ChunkedMediaSchema, chunkedMediaInfo)), key, iv);
|
|
571
|
+
const encryptedData = create(EncryptedDataSchema, {
|
|
572
|
+
ciphertext: uint8ArrayToBase64(ciphertext),
|
|
573
|
+
algorithm: AES_GCM_DERIVED_ALGORITHM,
|
|
574
|
+
});
|
|
575
|
+
const { hash: prevMiniblockHash } = await client.rpc.getLastMiniblockHash({
|
|
576
|
+
streamId: streamIdAsBytes(streamId),
|
|
577
|
+
});
|
|
578
|
+
const event = await makeEvent(client.signer, make_UserMetadataPayload_ProfileImage(encryptedData), prevMiniblockHash);
|
|
579
|
+
const eventId = bin_toHexString(event.hash);
|
|
580
|
+
const { error } = await client.rpc.addEvent({
|
|
581
|
+
streamId: streamIdAsBytes(streamId),
|
|
582
|
+
event,
|
|
583
|
+
});
|
|
584
|
+
return { eventId, error };
|
|
585
|
+
};
|
|
586
|
+
const decryptSessions = async (streamId, sessions) => {
|
|
587
|
+
const { deviceKey } = client.crypto.getUserDevice();
|
|
588
|
+
const ciphertext = sessions.ciphertexts[deviceKey];
|
|
589
|
+
if (!ciphertext) {
|
|
590
|
+
throw new Error('No ciphertext found for device key');
|
|
591
|
+
}
|
|
592
|
+
const parsed = parseGroupEncryptionAlgorithmId(sessions.algorithm, GroupEncryptionAlgorithmId.GroupEncryption);
|
|
593
|
+
if (parsed.kind === 'unrecognized') {
|
|
594
|
+
throw new Error('Invalid algorithm');
|
|
595
|
+
}
|
|
596
|
+
const algorithm = parsed.value;
|
|
597
|
+
// decrypt the session keys
|
|
598
|
+
const cleartext = await client.crypto.decryptWithDeviceKey(ciphertext, sessions.senderKey);
|
|
599
|
+
const sessionKeys = fromJsonString(SessionKeysSchema, cleartext);
|
|
600
|
+
check(sessionKeys.keys.length === sessions.sessionIds.length, 'bad sessionKeys');
|
|
601
|
+
// make group sessions that can be used to decrypt events
|
|
602
|
+
return sessions.sessionIds.map((sessionId, i) => ({
|
|
603
|
+
streamId: streamId,
|
|
604
|
+
sessionId,
|
|
605
|
+
sessionKey: sessionKeys.keys[i],
|
|
606
|
+
algorithm,
|
|
607
|
+
}));
|
|
608
|
+
};
|
|
609
|
+
/**
|
|
610
|
+
* Fetches and attempts to decrypt member-specific data (username, display name, nft, ensAddress)
|
|
611
|
+
* for a given user within a specific stream (channel/space).
|
|
612
|
+
* It requires the data to be in the stream snapshot.
|
|
613
|
+
*
|
|
614
|
+
* NOTE: Decryption relies on the bot having the necessary group session keys for the
|
|
615
|
+
* specified stream. If somehow the keys are missing, decryption will fail, and null values will be returned for username/displayName.
|
|
616
|
+
*
|
|
617
|
+
* @deprecated Not planned for now
|
|
618
|
+
* @param streamId - The ID of the channel or space stream.
|
|
619
|
+
* @param userId - The ID of the member whose data is being requested.
|
|
620
|
+
*/
|
|
621
|
+
const getUserData = async (streamId, userId) => {
|
|
622
|
+
try {
|
|
623
|
+
const stream = await client.getStream(streamId);
|
|
624
|
+
const members = stream.snapshot.members?.joined;
|
|
625
|
+
if (!members) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const member = members.find((m) => userIdFromAddress(m.userAddress) === userId);
|
|
629
|
+
if (!member) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
let displayName = null;
|
|
633
|
+
let username = null;
|
|
634
|
+
const [usernameDecrypted, displayNameDecrypted] = await Promise.all([
|
|
635
|
+
member.username?.data
|
|
636
|
+
? client.crypto.decryptGroupEvent(streamId, member.username.data)
|
|
637
|
+
: null,
|
|
638
|
+
member.displayName?.data
|
|
639
|
+
? client.crypto.decryptGroupEvent(streamId, member.displayName.data)
|
|
640
|
+
: null,
|
|
641
|
+
]);
|
|
642
|
+
if (usernameDecrypted) {
|
|
643
|
+
username =
|
|
644
|
+
typeof usernameDecrypted === 'string'
|
|
645
|
+
? usernameDecrypted
|
|
646
|
+
: bin_toString(usernameDecrypted);
|
|
647
|
+
}
|
|
648
|
+
if (displayNameDecrypted) {
|
|
649
|
+
displayName =
|
|
650
|
+
typeof displayNameDecrypted === 'string'
|
|
651
|
+
? displayNameDecrypted
|
|
652
|
+
: bin_toString(displayNameDecrypted);
|
|
653
|
+
}
|
|
654
|
+
let ensAddress = undefined;
|
|
655
|
+
if (member.ensAddress) {
|
|
656
|
+
ensAddress = `0x${bin_toHexString(member.ensAddress)}`;
|
|
657
|
+
}
|
|
658
|
+
let nft = undefined;
|
|
659
|
+
if (member.nft) {
|
|
660
|
+
nft = {
|
|
661
|
+
tokenId: bin_toString(member.nft.tokenId),
|
|
662
|
+
contractAddress: `0x${bin_toHexString(member.nft.contractAddress)}`,
|
|
663
|
+
chainId: member.nft.chainId,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const bio = await fetch(`${getStreamMetadataUrl(client.env)}/user/${userId}/bio`)
|
|
667
|
+
.then((res) => res.json())
|
|
668
|
+
.then((data) => data.bio)
|
|
669
|
+
.catch(() => null);
|
|
670
|
+
const profilePictureUrl = `${getStreamMetadataUrl(client.env)}/user/${userId}/image`;
|
|
671
|
+
return {
|
|
672
|
+
userId,
|
|
673
|
+
username,
|
|
674
|
+
displayName,
|
|
675
|
+
ensAddress,
|
|
676
|
+
nft,
|
|
677
|
+
bio,
|
|
678
|
+
profilePictureUrl,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
return {
|
|
686
|
+
// Is it those enough?
|
|
687
|
+
// TODO: think about a web3 use case..
|
|
688
|
+
writeContract: (tx) => writeContract(viemClient, tx),
|
|
689
|
+
readContract: (parameters) => readContract(viemClient, parameters),
|
|
690
|
+
sendMessage,
|
|
691
|
+
editMessage,
|
|
692
|
+
sendDm,
|
|
693
|
+
sendReaction,
|
|
694
|
+
removeEvent,
|
|
695
|
+
sendKeySolicitation,
|
|
696
|
+
uploadDeviceKeys,
|
|
697
|
+
decryptSessions,
|
|
698
|
+
setUsername,
|
|
699
|
+
setDisplayName,
|
|
700
|
+
setUserProfileImage,
|
|
701
|
+
/** @deprecated Not planned for now */
|
|
702
|
+
getUserData,
|
|
703
|
+
};
|
|
704
|
+
};
|
|
705
|
+
//# sourceMappingURL=bot.js.map
|