@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/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