@towns-labs/app-framework 4.0.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/README.md +147 -0
- package/dist/app.d.ts +680 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +2324 -0
- package/dist/app.js.map +1 -0
- package/dist/app.test.d.ts +2 -0
- package/dist/app.test.d.ts.map +1 -0
- package/dist/app.test.js +2070 -0
- package/dist/app.test.js.map +1 -0
- package/dist/identity-types.d.ts +43 -0
- package/dist/identity-types.d.ts.map +1 -0
- package/dist/identity-types.js +2 -0
- package/dist/identity-types.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/eventDedup.d.ts +73 -0
- package/dist/modules/eventDedup.d.ts.map +1 -0
- package/dist/modules/eventDedup.js +105 -0
- package/dist/modules/eventDedup.js.map +1 -0
- package/dist/modules/eventDedup.test.d.ts +2 -0
- package/dist/modules/eventDedup.test.d.ts.map +1 -0
- package/dist/modules/eventDedup.test.js +222 -0
- package/dist/modules/eventDedup.test.js.map +1 -0
- package/dist/modules/interaction-api.d.ts +101 -0
- package/dist/modules/interaction-api.d.ts.map +1 -0
- package/dist/modules/interaction-api.js +213 -0
- package/dist/modules/interaction-api.js.map +1 -0
- package/dist/modules/payments.d.ts +89 -0
- package/dist/modules/payments.d.ts.map +1 -0
- package/dist/modules/payments.js +139 -0
- package/dist/modules/payments.js.map +1 -0
- package/dist/modules/user.d.ts +17 -0
- package/dist/modules/user.d.ts.map +1 -0
- package/dist/modules/user.js +54 -0
- package/dist/modules/user.js.map +1 -0
- package/dist/snapshot-getter.d.ts +21 -0
- package/dist/snapshot-getter.d.ts.map +1 -0
- package/dist/snapshot-getter.js +27 -0
- package/dist/snapshot-getter.js.map +1 -0
- package/package.json +66 -0
package/dist/app.test.js
ADDED
|
@@ -0,0 +1,2070 @@
|
|
|
1
|
+
import { makeBaseProvider, makeUserStreamId, townsEnv, RiverTimelineEvent, Bot as SyncAgentTest, AppRegistryService, MessageType, genIdBlob, makeUniqueMediaStreamId, createApp, streamIdAsBytes, isDefined, genId, } from '@towns-labs/sdk';
|
|
2
|
+
import { waitFor } from '@towns-labs/sdk/src/tests/testUtils';
|
|
3
|
+
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
|
|
4
|
+
import { bin_fromHexString, check, dlog } from '@towns-labs/utils';
|
|
5
|
+
import { makeTownsApp } from './app';
|
|
6
|
+
import { ethers } from 'ethers';
|
|
7
|
+
import { createPublicClient, http } from 'viem';
|
|
8
|
+
import { relayerActions } from '@towns-labs/relayer-client';
|
|
9
|
+
import { getAddressesWithFallback } from '@towns-labs/contracts/deployments';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { stringify as superjsonStringify } from 'superjson';
|
|
12
|
+
import { ForwardSettingValue, InteractionRequestPayload_Signature_SignatureType, InteractionRequestPayload_PreparedCalls_Permission_Type, MediaInfoSchema, PositionType, SupportedApi, } from '@towns-labs/proto';
|
|
13
|
+
import { ETH_ADDRESS } from '@towns-labs/web3';
|
|
14
|
+
import { serve } from '@hono/node-server';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { nanoid } from 'nanoid';
|
|
17
|
+
import { create } from '@bufbuild/protobuf';
|
|
18
|
+
import { deriveKeyAndIV } from '@towns-labs/sdk-crypto';
|
|
19
|
+
const log = dlog('test:bot');
|
|
20
|
+
const WEBHOOK_URL = `http://localhost:${process.env.BOT_PORT}/webhook`;
|
|
21
|
+
const SLASH_COMMANDS = [
|
|
22
|
+
{ name: 'help', description: 'Get help with bot commands' },
|
|
23
|
+
{ name: 'status', description: 'Check bot status' },
|
|
24
|
+
{ name: 'set_positions', description: 'Set positions for the caller' },
|
|
25
|
+
];
|
|
26
|
+
describe('Bot', { sequential: true }, () => {
|
|
27
|
+
const subscriptions = [];
|
|
28
|
+
const townsConfig = townsEnv().makeTownsConfig();
|
|
29
|
+
const bob = new SyncAgentTest(undefined, townsConfig);
|
|
30
|
+
let bobClient;
|
|
31
|
+
const alice = new SyncAgentTest(undefined, townsConfig);
|
|
32
|
+
let aliceClient;
|
|
33
|
+
const carol = new SyncAgentTest(undefined, townsConfig);
|
|
34
|
+
let carolClient;
|
|
35
|
+
const BOT_USERNAME = `witness_${genId(8)}`;
|
|
36
|
+
const BOT_DISPLAY_NAME = 'Uber Test Bot';
|
|
37
|
+
const BOT_DESCRIPTION = 'I shall witness everything';
|
|
38
|
+
const bob_username = `bob_${genId(12)}`; // unique across multiple non-concurrent runs
|
|
39
|
+
let bot;
|
|
40
|
+
let channelId;
|
|
41
|
+
let botClientAddress;
|
|
42
|
+
let appPrivateData;
|
|
43
|
+
let jwtSecretBase64;
|
|
44
|
+
let appRegistryRpcClient;
|
|
45
|
+
let appAddress;
|
|
46
|
+
let bobDefaultGdm;
|
|
47
|
+
let ethersProvider;
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
ethersProvider = makeBaseProvider(townsConfig);
|
|
50
|
+
await shouldInitializeBotOwner();
|
|
51
|
+
await shouldSetupBotUser();
|
|
52
|
+
await shouldRegisterBotInAppRegistry();
|
|
53
|
+
await appRegistryRpcClient.setUserName({
|
|
54
|
+
username: bob_username,
|
|
55
|
+
displayName: 'Bob Display',
|
|
56
|
+
});
|
|
57
|
+
await shouldRunBotServerAndRegisterWebhook();
|
|
58
|
+
await bobClient.riverConnection.call((client) => Promise.all([client.debugForceMakeMiniblock(channelId, { forceSnapshot: true })]));
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
subscriptions.forEach((unsub) => unsub());
|
|
62
|
+
subscriptions.splice(0, subscriptions.length);
|
|
63
|
+
});
|
|
64
|
+
const setForwardSetting = async (forwardSetting) => {
|
|
65
|
+
const appId = bin_fromHexString(botClientAddress);
|
|
66
|
+
await appRegistryRpcClient.updateAppSettings({
|
|
67
|
+
appId,
|
|
68
|
+
forwardSetting,
|
|
69
|
+
updateMask: ['forward_setting'],
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
const shouldInitializeBotOwner = async () => {
|
|
73
|
+
await Promise.all([bob.fundWallet(), alice.fundWallet(), carol.fundWallet()]);
|
|
74
|
+
bobClient = await bob.makeSyncAgent();
|
|
75
|
+
aliceClient = await alice.makeSyncAgent();
|
|
76
|
+
carolClient = await carol.makeSyncAgent();
|
|
77
|
+
await Promise.all([bobClient.start(), aliceClient.start(), carolClient.start()]);
|
|
78
|
+
const { streamId: gdmId } = await bobClient.gdms.createGDM([alice.userId, carol.userId]);
|
|
79
|
+
channelId = gdmId;
|
|
80
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
|
|
81
|
+
bobDefaultGdm = bobClient.gdms.getGdm(gdmId);
|
|
82
|
+
expect(channelId).toBeDefined();
|
|
83
|
+
};
|
|
84
|
+
const shouldSetupBotUser = async () => {
|
|
85
|
+
const result = await createAppForOwner(bobClient, {
|
|
86
|
+
username: BOT_USERNAME,
|
|
87
|
+
displayName: BOT_DISPLAY_NAME,
|
|
88
|
+
description: BOT_DESCRIPTION,
|
|
89
|
+
imageUrl: 'https://placehold.co/600x600',
|
|
90
|
+
slashCommands: SLASH_COMMANDS,
|
|
91
|
+
});
|
|
92
|
+
botClientAddress = result.appAddress;
|
|
93
|
+
appPrivateData = result.appPrivateData;
|
|
94
|
+
appAddress = result.appAddress;
|
|
95
|
+
jwtSecretBase64 = result.jwtSecretBase64;
|
|
96
|
+
expect(appPrivateData).toBeDefined();
|
|
97
|
+
expect(jwtSecretBase64).toBeDefined();
|
|
98
|
+
await bobClient.riverConnection.call((client) => client.joinUser(channelId, botClientAddress));
|
|
99
|
+
};
|
|
100
|
+
const createAppForOwner = async (ownerClient, metadata) => {
|
|
101
|
+
const chainId = townsConfig.base.chainConfig.chainId;
|
|
102
|
+
const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
|
|
103
|
+
if (!addresses?.accountProxy) {
|
|
104
|
+
throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
|
|
105
|
+
}
|
|
106
|
+
const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
|
|
107
|
+
const relayerClient = createPublicClient({
|
|
108
|
+
chain: {
|
|
109
|
+
id: chainId,
|
|
110
|
+
name: 'Test Chain',
|
|
111
|
+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
|
112
|
+
rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
|
|
113
|
+
},
|
|
114
|
+
transport: http(townsConfig.base.rpcUrl),
|
|
115
|
+
}).extend(relayerActions({ relayerUrl }));
|
|
116
|
+
return createApp({
|
|
117
|
+
owner: ownerClient.riverConnection.signerContext,
|
|
118
|
+
metadata,
|
|
119
|
+
relayerClient,
|
|
120
|
+
accountProxy: addresses.accountProxy,
|
|
121
|
+
townsConfig,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
const shouldRegisterBotInAppRegistry = async () => {
|
|
125
|
+
const appRegistryUrl = townsEnv().getAppRegistryUrl(process.env.RIVER_ENV);
|
|
126
|
+
const { appRegistryRpcClient: rpcClient } = await AppRegistryService.authenticateWithSigner(bob.userId, bob.signer, appRegistryUrl);
|
|
127
|
+
appRegistryRpcClient = rpcClient;
|
|
128
|
+
};
|
|
129
|
+
const shouldRunBotServerAndRegisterWebhook = async () => {
|
|
130
|
+
bot = await makeTownsApp(appPrivateData, {
|
|
131
|
+
jwtSecret: jwtSecretBase64,
|
|
132
|
+
commands: SLASH_COMMANDS,
|
|
133
|
+
});
|
|
134
|
+
expect(bot).toBeDefined();
|
|
135
|
+
expect(bot.agentUserId).toBe(botClientAddress);
|
|
136
|
+
expect(bot.appAddress).toBe(appAddress);
|
|
137
|
+
const app = bot.start();
|
|
138
|
+
serve({
|
|
139
|
+
port: Number(process.env.BOT_PORT),
|
|
140
|
+
fetch: app.fetch,
|
|
141
|
+
});
|
|
142
|
+
await appRegistryRpcClient.registerWebhook({
|
|
143
|
+
appId: bin_fromHexString(botClientAddress),
|
|
144
|
+
webhookUrl: WEBHOOK_URL,
|
|
145
|
+
});
|
|
146
|
+
const { isRegistered, validResponse } = await appRegistryRpcClient.getStatus({
|
|
147
|
+
appId: bin_fromHexString(botClientAddress),
|
|
148
|
+
});
|
|
149
|
+
expect(isRegistered).toBe(true);
|
|
150
|
+
expect(validResponse).toBe(true);
|
|
151
|
+
};
|
|
152
|
+
it('should have app_address defined in user stream for bot (app registry only)', async () => {
|
|
153
|
+
const botUserStreamId = makeUserStreamId(botClientAddress);
|
|
154
|
+
const streamView = await bobClient.riverConnection.call(async (client) => {
|
|
155
|
+
return await client.getStream(botUserStreamId);
|
|
156
|
+
});
|
|
157
|
+
const userStream = streamView.userContent.userStreamModel;
|
|
158
|
+
expect(userStream.appAddress).toBeDefined();
|
|
159
|
+
// expect(userStream.appAddress).toBe(botClientAddress)
|
|
160
|
+
});
|
|
161
|
+
it('should show bot in member list and apps set', async () => {
|
|
162
|
+
const channelStreamView = await bobClient.riverConnection.call(async (client) => {
|
|
163
|
+
return await client.getStream(channelId);
|
|
164
|
+
});
|
|
165
|
+
const { apps, joined } = channelStreamView.getMembers();
|
|
166
|
+
expect(apps.has(botClientAddress)).toBe(true);
|
|
167
|
+
expect(joined.has(botClientAddress)).toBe(true);
|
|
168
|
+
expect(joined.get(botClientAddress)?.appAddress).toBe(appAddress);
|
|
169
|
+
});
|
|
170
|
+
it('should receive a message forwarded', async () => {
|
|
171
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
172
|
+
const timeBeforeSendMessage = Date.now();
|
|
173
|
+
let receivedMessages = [];
|
|
174
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
175
|
+
receivedMessages.push(e);
|
|
176
|
+
}));
|
|
177
|
+
const TEST_MESSAGE = 'Hello bot!';
|
|
178
|
+
const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE);
|
|
179
|
+
await waitFor(() => receivedMessages.length > 0, { timeoutMS: 15_000 });
|
|
180
|
+
const event = receivedMessages.find((x) => x.eventId === eventId);
|
|
181
|
+
expect(event?.message).toBe(TEST_MESSAGE);
|
|
182
|
+
expect(event?.createdAt).toBeDefined();
|
|
183
|
+
expect(event?.createdAt).toBeInstanceOf(Date);
|
|
184
|
+
expect(event?.createdAt.getTime()).toBeGreaterThanOrEqual(timeBeforeSendMessage);
|
|
185
|
+
receivedMessages = [];
|
|
186
|
+
});
|
|
187
|
+
it('should not receive messages when forwarding is set to no messages', async () => {
|
|
188
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_NO_MESSAGES);
|
|
189
|
+
const receivedMessages = [];
|
|
190
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
191
|
+
receivedMessages.push(e);
|
|
192
|
+
}));
|
|
193
|
+
const TEST_MESSAGE = 'This message should not be forwarded';
|
|
194
|
+
await bobDefaultGdm.sendMessage(TEST_MESSAGE);
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
196
|
+
expect(receivedMessages).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
it('should receive slash command messages', async () => {
|
|
199
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
200
|
+
const receivedMessages = [];
|
|
201
|
+
subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
|
|
202
|
+
receivedMessages.push(e);
|
|
203
|
+
}));
|
|
204
|
+
const { eventId } = await bobDefaultGdm.sendMessage('/help', {
|
|
205
|
+
appClientAddress: bot.agentUserId,
|
|
206
|
+
});
|
|
207
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
208
|
+
const event = receivedMessages.find((x) => x.eventId === eventId);
|
|
209
|
+
expect(event?.command).toBe('help');
|
|
210
|
+
expect(event?.args).toStrictEqual([]);
|
|
211
|
+
});
|
|
212
|
+
it('should receive slash command in a thread', async () => {
|
|
213
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
214
|
+
const receivedMessages = [];
|
|
215
|
+
subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
|
|
216
|
+
receivedMessages.push(e);
|
|
217
|
+
}));
|
|
218
|
+
const { eventId: threadId } = await bobDefaultGdm.sendMessage('starting a thread');
|
|
219
|
+
const { eventId } = await bobDefaultGdm.sendMessage('/help', {
|
|
220
|
+
appClientAddress: bot.agentUserId,
|
|
221
|
+
threadId: threadId,
|
|
222
|
+
});
|
|
223
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
224
|
+
const event = receivedMessages.find((x) => x.eventId === eventId);
|
|
225
|
+
expect(event?.command).toBe('help');
|
|
226
|
+
expect(event?.args).toStrictEqual([]);
|
|
227
|
+
expect(event?.threadId).toBe(threadId);
|
|
228
|
+
});
|
|
229
|
+
it('should receive slash command as a reply', async () => {
|
|
230
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
231
|
+
const receivedMessages = [];
|
|
232
|
+
subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
|
|
233
|
+
receivedMessages.push(e);
|
|
234
|
+
}));
|
|
235
|
+
const { eventId: replyId } = await bobDefaultGdm.sendMessage('yo');
|
|
236
|
+
const { eventId } = await bobDefaultGdm.sendMessage('/help', {
|
|
237
|
+
appClientAddress: bot.agentUserId,
|
|
238
|
+
replyId: replyId,
|
|
239
|
+
});
|
|
240
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
241
|
+
const event = receivedMessages.find((x) => x.eventId === eventId);
|
|
242
|
+
expect(event?.command).toBe('help');
|
|
243
|
+
expect(event?.args).toStrictEqual([]);
|
|
244
|
+
expect(event?.replyId).toBe(replyId);
|
|
245
|
+
});
|
|
246
|
+
it('should receive slash command with arguments', async () => {
|
|
247
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
248
|
+
const receivedMessages = [];
|
|
249
|
+
subscriptions.push(bot.onSlashCommand('status', (_h, e) => {
|
|
250
|
+
receivedMessages.push(e);
|
|
251
|
+
}));
|
|
252
|
+
const { eventId } = await bobDefaultGdm.sendMessage('/status detailed info', {
|
|
253
|
+
appClientAddress: bot.agentUserId,
|
|
254
|
+
});
|
|
255
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
256
|
+
const event = receivedMessages.find((x) => x.eventId === eventId);
|
|
257
|
+
expect(event?.command).toBe('status');
|
|
258
|
+
expect(event?.args).toStrictEqual(['detailed', 'info']);
|
|
259
|
+
});
|
|
260
|
+
it('should store positions from /set_positions and return them via onPositions', async () => {
|
|
261
|
+
// regression test, userIds were cased differently in events vs over the positions webhook request
|
|
262
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
263
|
+
await appRegistryRpcClient.updateAppSettings({
|
|
264
|
+
appId: bin_fromHexString(botClientAddress),
|
|
265
|
+
supportedApis: [SupportedApi.POSITIONS],
|
|
266
|
+
updateMask: ['supported_apis'],
|
|
267
|
+
});
|
|
268
|
+
const positionsByUserId = new Map();
|
|
269
|
+
let slashCommandUserId;
|
|
270
|
+
let onPositionsUserId;
|
|
271
|
+
subscriptions.push(bot.onSlashCommand('set_positions', (_h, e) => {
|
|
272
|
+
const value = e.args[0];
|
|
273
|
+
if (!value) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
slashCommandUserId = e.userId;
|
|
277
|
+
positionsByUserId.set(e.userId, value);
|
|
278
|
+
}));
|
|
279
|
+
subscriptions.push(bot.onPositions((_h, e) => {
|
|
280
|
+
onPositionsUserId = e.userId;
|
|
281
|
+
const balanceUsd = positionsByUserId.get(e.userId);
|
|
282
|
+
return {
|
|
283
|
+
supported: true,
|
|
284
|
+
positions: balanceUsd
|
|
285
|
+
? [
|
|
286
|
+
{
|
|
287
|
+
label: 'Stored Position',
|
|
288
|
+
symbol: 'TEST',
|
|
289
|
+
type: PositionType.PERPETUAL,
|
|
290
|
+
balanceUsd,
|
|
291
|
+
pnlUsd: '0',
|
|
292
|
+
},
|
|
293
|
+
]
|
|
294
|
+
: [],
|
|
295
|
+
timestamp: BigInt(Date.now()),
|
|
296
|
+
};
|
|
297
|
+
}));
|
|
298
|
+
const storedBalanceUsd = '123.45';
|
|
299
|
+
await bobDefaultGdm.sendMessage(`/set_positions ${storedBalanceUsd}`, {
|
|
300
|
+
appClientAddress: bot.agentUserId,
|
|
301
|
+
});
|
|
302
|
+
await waitFor(() => positionsByUserId.size > 0);
|
|
303
|
+
const positionsResp = await appRegistryRpcClient.getAppPositions({
|
|
304
|
+
appId: bin_fromHexString(botClientAddress),
|
|
305
|
+
});
|
|
306
|
+
expect(positionsResp.positions?.supported).toBe(true);
|
|
307
|
+
expect(positionsResp.positions?.positions).toHaveLength(1);
|
|
308
|
+
expect(positionsResp.positions?.positions[0]?.balanceUsd).toBe(storedBalanceUsd);
|
|
309
|
+
expect(slashCommandUserId).toBeDefined();
|
|
310
|
+
expect(onPositionsUserId).toBeDefined();
|
|
311
|
+
expect(onPositionsUserId).toBe(slashCommandUserId);
|
|
312
|
+
});
|
|
313
|
+
it('onMessageEdit should be triggered when a message is edited', async () => {
|
|
314
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
315
|
+
const receivedEditEvents = [];
|
|
316
|
+
subscriptions.push(bot.onMessageEdit((_h, e) => {
|
|
317
|
+
receivedEditEvents.push(e);
|
|
318
|
+
}));
|
|
319
|
+
const originalMessage = 'Original message to delete';
|
|
320
|
+
const editedMessage = 'Edited message content';
|
|
321
|
+
const { eventId: originalMessageId } = await bobDefaultGdm.sendMessage(originalMessage);
|
|
322
|
+
await bobClient.riverConnection.call((client) => client.sendChannelMessage_Edit_Text(channelId, originalMessageId, {
|
|
323
|
+
content: {
|
|
324
|
+
body: editedMessage,
|
|
325
|
+
mentions: [],
|
|
326
|
+
attachments: [],
|
|
327
|
+
},
|
|
328
|
+
}));
|
|
329
|
+
await waitFor(() => receivedEditEvents.length > 0);
|
|
330
|
+
const editEvent = receivedEditEvents.find((e) => e.refEventId === originalMessageId);
|
|
331
|
+
expect(editEvent).toBeDefined();
|
|
332
|
+
expect(editEvent?.message).toBe(editedMessage);
|
|
333
|
+
});
|
|
334
|
+
it('onMessage should be triggered with threadId when a message is sent in a thread', async () => {
|
|
335
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
336
|
+
const receivedThreadMessages = [];
|
|
337
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
338
|
+
if (e.threadId) {
|
|
339
|
+
receivedThreadMessages.push(e);
|
|
340
|
+
}
|
|
341
|
+
}));
|
|
342
|
+
const initialMessage = 'Starting a thread';
|
|
343
|
+
const threadReply = 'Replying in thread';
|
|
344
|
+
const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
|
|
345
|
+
const { eventId: replyEventId } = await bobDefaultGdm.sendMessage(threadReply, {
|
|
346
|
+
threadId: initialMessageId,
|
|
347
|
+
});
|
|
348
|
+
await waitFor(() => receivedThreadMessages.length > 0);
|
|
349
|
+
const threadEvent = receivedThreadMessages.find((e) => e.eventId === replyEventId);
|
|
350
|
+
expect(threadEvent).toBeDefined();
|
|
351
|
+
expect(threadEvent?.message).toBe(threadReply);
|
|
352
|
+
expect(threadEvent?.userId).toBe(bob.userId);
|
|
353
|
+
expect(threadEvent?.threadId).toBe(initialMessageId);
|
|
354
|
+
});
|
|
355
|
+
it('onMessage should be triggered with isMentioned when a bot is mentioned', async () => {
|
|
356
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
357
|
+
const receivedMentionedEvents = [];
|
|
358
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
359
|
+
if (e.isMentioned) {
|
|
360
|
+
receivedMentionedEvents.push(e);
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
const TEST_MESSAGE = 'Hello @bot';
|
|
364
|
+
const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
|
|
365
|
+
mentions: [
|
|
366
|
+
{
|
|
367
|
+
userId: bot.agentUserId,
|
|
368
|
+
displayName: BOT_DISPLAY_NAME,
|
|
369
|
+
mentionBehavior: { case: undefined, value: undefined },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
await waitFor(() => receivedMentionedEvents.length > 0);
|
|
374
|
+
const mentionedEvent = receivedMentionedEvents.find((x) => x.eventId === eventId);
|
|
375
|
+
expect(mentionedEvent).toBeDefined();
|
|
376
|
+
expect(mentionedEvent?.isMentioned).toBe(true);
|
|
377
|
+
expect(mentionedEvent?.mentions[0].userId).toBe(bot.agentUserId);
|
|
378
|
+
expect(mentionedEvent?.mentions[0].displayName).toBe(BOT_DISPLAY_NAME);
|
|
379
|
+
});
|
|
380
|
+
it('isMentioned should be false when someone else is mentioned', async () => {
|
|
381
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
382
|
+
const receivedMessages = [];
|
|
383
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
384
|
+
receivedMessages.push(e);
|
|
385
|
+
}));
|
|
386
|
+
const TEST_MESSAGE = 'Hello @alice';
|
|
387
|
+
const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
|
|
388
|
+
mentions: [
|
|
389
|
+
{
|
|
390
|
+
userId: alice.userId,
|
|
391
|
+
displayName: 'alice',
|
|
392
|
+
mentionBehavior: { case: undefined, value: undefined },
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
});
|
|
396
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
397
|
+
const message = receivedMessages.find((x) => x.eventId === eventId);
|
|
398
|
+
expect(message).toBeDefined();
|
|
399
|
+
expect(message?.isMentioned).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
it('onMessage should be triggered with both threadId and isMentioned when bot is mentioned in a thread', async () => {
|
|
402
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
403
|
+
const receivedMentionedInThreadEvents = [];
|
|
404
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
405
|
+
receivedMentionedInThreadEvents.push(e);
|
|
406
|
+
}));
|
|
407
|
+
const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage('starting a thread');
|
|
408
|
+
const { eventId: threadMentionEventId } = await bobDefaultGdm.sendMessage('yo @bot check this thread', {
|
|
409
|
+
threadId: initialMessageId,
|
|
410
|
+
mentions: [
|
|
411
|
+
{
|
|
412
|
+
userId: bot.agentUserId,
|
|
413
|
+
displayName: bot.agentUserId,
|
|
414
|
+
mentionBehavior: { case: undefined, value: undefined },
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
});
|
|
418
|
+
await waitFor(() => receivedMentionedInThreadEvents.length > 0);
|
|
419
|
+
const threadMentionEvent = receivedMentionedInThreadEvents.find((e) => e.eventId === threadMentionEventId);
|
|
420
|
+
expect(threadMentionEvent).toBeDefined();
|
|
421
|
+
expect(threadMentionEvent?.userId).toBe(bob.userId);
|
|
422
|
+
expect(threadMentionEvent?.threadId).toBe(initialMessageId);
|
|
423
|
+
expect(threadMentionEvent?.isMentioned).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
it('thread message without bot mention should have isMentioned false', async () => {
|
|
426
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
427
|
+
const receivedMessages = [];
|
|
428
|
+
subscriptions.push(bot.onMessage((_h, e) => {
|
|
429
|
+
receivedMessages.push(e);
|
|
430
|
+
}));
|
|
431
|
+
const initialMessage = 'Starting another thread';
|
|
432
|
+
const threadMessageWithoutMention = 'Thread message without mention';
|
|
433
|
+
const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
|
|
434
|
+
const { eventId: threadEventId } = await bobDefaultGdm.sendMessage(threadMessageWithoutMention, {
|
|
435
|
+
threadId: initialMessageId,
|
|
436
|
+
});
|
|
437
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
438
|
+
const message = receivedMessages.find((x) => x.eventId === threadEventId);
|
|
439
|
+
expect(message).toBeDefined();
|
|
440
|
+
expect(message?.threadId).toBe(initialMessageId);
|
|
441
|
+
expect(message?.isMentioned).toBe(false);
|
|
442
|
+
});
|
|
443
|
+
it('onReaction should be triggered when a reaction is added', async () => {
|
|
444
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
445
|
+
const receivedReactionEvents = [];
|
|
446
|
+
subscriptions.push(bot.onReaction((_h, e) => {
|
|
447
|
+
receivedReactionEvents.push(e);
|
|
448
|
+
}));
|
|
449
|
+
const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
|
|
450
|
+
const { eventId: reactionId } = await bobDefaultGdm.sendReaction(messageId, '👍');
|
|
451
|
+
await waitFor(() => receivedReactionEvents.length > 0);
|
|
452
|
+
expect(receivedReactionEvents.find((x) => x.eventId === reactionId)).toBeDefined();
|
|
453
|
+
expect(receivedReactionEvents.find((x) => x.reaction === '👍')).toBeDefined();
|
|
454
|
+
expect(receivedReactionEvents.find((x) => x.messageId === messageId)).toBeDefined();
|
|
455
|
+
expect(receivedReactionEvents.find((x) => x.userId === bob.userId)).toBeDefined();
|
|
456
|
+
});
|
|
457
|
+
it('onRedaction should be triggered when a message is redacted', async () => {
|
|
458
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
459
|
+
const receivedRedactionEvents = [];
|
|
460
|
+
subscriptions.push(bot.onRedaction((_h, e) => {
|
|
461
|
+
receivedRedactionEvents.push(e);
|
|
462
|
+
}));
|
|
463
|
+
const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
|
|
464
|
+
const { eventId: redactionId } = await bobDefaultGdm.redact(messageId);
|
|
465
|
+
await waitFor(() => receivedRedactionEvents.length > 0);
|
|
466
|
+
expect(receivedRedactionEvents.find((x) => x.eventId === redactionId)).toBeDefined();
|
|
467
|
+
});
|
|
468
|
+
it('bot can redact his own message', async () => {
|
|
469
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
470
|
+
const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello');
|
|
471
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
|
|
472
|
+
?.kind).toBe(RiverTimelineEvent.ChannelMessage));
|
|
473
|
+
const { eventId: redactionId } = await bot.removeEvent(channelId, messageId);
|
|
474
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === redactionId)?.content
|
|
475
|
+
?.kind).toBe(RiverTimelineEvent.RedactionActionEvent));
|
|
476
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
|
|
477
|
+
?.kind).toBe(RiverTimelineEvent.RedactedEvent));
|
|
478
|
+
});
|
|
479
|
+
it('bot can mention bob', async () => {
|
|
480
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
481
|
+
const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello @bob', {
|
|
482
|
+
mentions: [
|
|
483
|
+
{
|
|
484
|
+
userId: bob.userId,
|
|
485
|
+
displayName: 'bob',
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
});
|
|
489
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
|
|
490
|
+
?.kind).toBe(RiverTimelineEvent.ChannelMessage));
|
|
491
|
+
const channelMessage = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content;
|
|
492
|
+
expect(channelMessage.mentions).toBeDefined();
|
|
493
|
+
expect(channelMessage.mentions?.length).toBe(1);
|
|
494
|
+
expect(channelMessage.mentions?.[0].userId).toBe(bob.userId);
|
|
495
|
+
expect(channelMessage.mentions?.[0].displayName).toBe('bob');
|
|
496
|
+
});
|
|
497
|
+
it('bot.getUser returns entity name set via app registry', async () => {
|
|
498
|
+
const userInfo = await bot.getUser(bob.userId);
|
|
499
|
+
expect(userInfo).toBeDefined();
|
|
500
|
+
expect(userInfo.username).toBe(bob_username);
|
|
501
|
+
expect(userInfo.displayName).toBe('Bob Display');
|
|
502
|
+
});
|
|
503
|
+
it('bot can fetch existing decryption keys when sending a message', async () => {
|
|
504
|
+
// on a fresh boot the bot won't have any keys in cache, so it should fetch them from the app server if they exist
|
|
505
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
506
|
+
const { eventId: messageId1 } = await bot.sendMessage(channelId, 'Hello message 1');
|
|
507
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1)?.content
|
|
508
|
+
?.kind).toBe(RiverTimelineEvent.ChannelMessage));
|
|
509
|
+
// DELETE OUTBOUND GROUP SESSIONS to simulate fresh server start
|
|
510
|
+
await bot['client'].crypto.cryptoStore.deleteHybridGroupSessions(channelId);
|
|
511
|
+
const { eventId: messageId2 } = await bot.sendMessage(channelId, 'Hello message 2');
|
|
512
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2)?.content
|
|
513
|
+
?.kind).toBe(RiverTimelineEvent.ChannelMessage));
|
|
514
|
+
const event1 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1);
|
|
515
|
+
const event2 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2);
|
|
516
|
+
expect(event1?.sessionId).toEqual(event2?.sessionId);
|
|
517
|
+
});
|
|
518
|
+
it('bot shares new decrytion keys with users created while sending a message', async () => {
|
|
519
|
+
// the bot should almost never have to create a new key - usually they will get a message in the gdm first and can use that key to encrypt
|
|
520
|
+
// but in the case where they've never received one and want to send a message, they will create a new key and share it with the users in the gdm
|
|
521
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
522
|
+
const { streamId: gdmId } = await bobClient.gdms.createGDM([alice.userId, bot.agentUserId]);
|
|
523
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
|
|
524
|
+
const bobGdm = bobClient.gdms.getGdm(gdmId);
|
|
525
|
+
// bot sends message to the gdm
|
|
526
|
+
const { eventId: messageId } = await bot.sendMessage(gdmId, 'Hello');
|
|
527
|
+
log('bot sends message to gdm', messageId);
|
|
528
|
+
// bob should see the DECRYPTED message
|
|
529
|
+
await waitFor(() => {
|
|
530
|
+
expect(bobGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
|
|
531
|
+
?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
532
|
+
}, { timeoutMS: 20000 });
|
|
533
|
+
});
|
|
534
|
+
it.skip('TODO: FIX: onTip should be triggered when a tip is received', async () => {
|
|
535
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
536
|
+
const receivedTipEvents = [];
|
|
537
|
+
subscriptions.push(bot.onTip((_h, e) => {
|
|
538
|
+
receivedTipEvents.push(e);
|
|
539
|
+
}));
|
|
540
|
+
// Bot sends a message to the GDM
|
|
541
|
+
const { eventId: messageId } = await bot.sendMessage(channelId, 'Tip me!');
|
|
542
|
+
const balanceBefore = (await ethersProvider.getBalance(appAddress)).toBigInt();
|
|
543
|
+
// Bob tips the bot using GDM sendTip (type: 'any' tipping)
|
|
544
|
+
await bobDefaultGdm.sendTip(messageId, {
|
|
545
|
+
receiver: appAddress,
|
|
546
|
+
amount: ethers.utils.parseUnits('0.01').toBigInt(),
|
|
547
|
+
currency: ETH_ADDRESS,
|
|
548
|
+
chainId: townsConfig.base.chainConfig.chainId,
|
|
549
|
+
}, bob.signer);
|
|
550
|
+
// Verify the bot's balance increased (no protocol fee for type: 'any' tips)
|
|
551
|
+
const balanceAfter = (await ethersProvider.getBalance(appAddress)).toBigInt();
|
|
552
|
+
const expectedTipAmount = ethers.utils.parseUnits('0.01').toBigInt();
|
|
553
|
+
expect(balanceAfter).toEqual(balanceBefore + expectedTipAmount);
|
|
554
|
+
// Verify the onTip event was triggered
|
|
555
|
+
await waitFor(() => receivedTipEvents.length > 0);
|
|
556
|
+
const tipEvent = receivedTipEvents.find((x) => x.messageId === messageId);
|
|
557
|
+
expect(tipEvent).toBeDefined();
|
|
558
|
+
expect(tipEvent?.userId).toBe(bob.userId);
|
|
559
|
+
expect(tipEvent?.channelId).toBe(channelId);
|
|
560
|
+
expect(tipEvent?.amount).toBe(expectedTipAmount);
|
|
561
|
+
expect(tipEvent?.currency).toBe(ETH_ADDRESS);
|
|
562
|
+
expect(tipEvent?.senderAddress).toBe(bob.userId);
|
|
563
|
+
expect(tipEvent?.receiverAddress).toBe(appAddress);
|
|
564
|
+
});
|
|
565
|
+
it.skip('TODO: FIX: bot can use sendTip() to send tips using app balance', async () => {
|
|
566
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
567
|
+
const receivedMessages = [];
|
|
568
|
+
let tipPromise;
|
|
569
|
+
subscriptions.push(bot.onMessage((handler, event) => {
|
|
570
|
+
receivedMessages.push(event);
|
|
571
|
+
tipPromise = handler.sendTip({
|
|
572
|
+
userId: bob.userId,
|
|
573
|
+
amount: ethers.utils.parseUnits('0.005').toBigInt(),
|
|
574
|
+
messageId: event.eventId,
|
|
575
|
+
channelId: event.channelId,
|
|
576
|
+
});
|
|
577
|
+
}));
|
|
578
|
+
const bobBalanceBefore = (await ethersProvider.getBalance(bob.userId)).toBigInt();
|
|
579
|
+
// Bob sends a message asking for a tip
|
|
580
|
+
const { eventId: bobMessageId } = await bobDefaultGdm.sendMessage('Tip me please!');
|
|
581
|
+
await waitFor(() => receivedMessages.some((x) => x.eventId === bobMessageId));
|
|
582
|
+
expect(tipPromise).toBeDefined();
|
|
583
|
+
const result = await tipPromise;
|
|
584
|
+
expect(result.txHash).toBeDefined();
|
|
585
|
+
expect(result.eventId).toBeDefined();
|
|
586
|
+
// Verify bob's balance increased
|
|
587
|
+
const bobBalanceAfter = (await ethersProvider.getBalance(bob.userId)).toBigInt();
|
|
588
|
+
expect(bobBalanceAfter).toBeGreaterThan(bobBalanceBefore);
|
|
589
|
+
});
|
|
590
|
+
it('onEventRevoke (FORWARD_SETTING_ALL_MESSAGES) should be triggered when a message is revoked', async () => {
|
|
591
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
592
|
+
const receivedEventRevokeEvents = [];
|
|
593
|
+
subscriptions.push(bot.onEventRevoke((_h, e) => {
|
|
594
|
+
receivedEventRevokeEvents.push(e);
|
|
595
|
+
}));
|
|
596
|
+
const { eventId: messageId } = await bot.sendMessage(channelId, 'hii');
|
|
597
|
+
await bobDefaultGdm.adminRedact(messageId);
|
|
598
|
+
await waitFor(() => receivedEventRevokeEvents.length > 0);
|
|
599
|
+
expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
|
|
600
|
+
});
|
|
601
|
+
it.fails('onEventRevoke (FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS) should be triggered when a message that mentions the bot is revoked', async () => {
|
|
602
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
|
|
603
|
+
const receivedEventRevokeEvents = [];
|
|
604
|
+
subscriptions.push(bot.onEventRevoke((_h, e) => {
|
|
605
|
+
receivedEventRevokeEvents.push(e);
|
|
606
|
+
}));
|
|
607
|
+
const { eventId: messageId } = await bobDefaultGdm.sendMessage('hii @bot', {
|
|
608
|
+
mentions: [
|
|
609
|
+
{
|
|
610
|
+
userId: bot.agentUserId,
|
|
611
|
+
displayName: bot.agentUserId,
|
|
612
|
+
mentionBehavior: { case: undefined, value: undefined },
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
await bobDefaultGdm.adminRedact(messageId);
|
|
617
|
+
await waitFor(() => receivedEventRevokeEvents.length > 0);
|
|
618
|
+
expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
|
|
619
|
+
});
|
|
620
|
+
it('should send message with image attachment from URL with alt text', async () => {
|
|
621
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
622
|
+
const imageUrl = 'https://placehold.co/800x600.png';
|
|
623
|
+
const altText = 'A beautiful placeholder image';
|
|
624
|
+
const { eventId } = await bot.sendMessage(channelId, 'Image with alt text', {
|
|
625
|
+
attachments: [{ type: 'image', url: imageUrl, alt: altText }],
|
|
626
|
+
});
|
|
627
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
628
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
629
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
630
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
631
|
+
? message?.content?.attachments
|
|
632
|
+
: undefined;
|
|
633
|
+
expect(attachments).toHaveLength(1);
|
|
634
|
+
expect(attachments?.[0].type).toBe('image');
|
|
635
|
+
const image = attachments?.[0].type === 'image' ? attachments?.[0] : undefined;
|
|
636
|
+
expect(image).toBeDefined();
|
|
637
|
+
expect(image?.info.url).toBe(imageUrl);
|
|
638
|
+
});
|
|
639
|
+
it('should gracefully handle non-image URL (skip with warning)', async () => {
|
|
640
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
641
|
+
// Use a URL that returns non-image content type
|
|
642
|
+
const nonImageUrl = 'https://httpbin.org/json';
|
|
643
|
+
const { eventId } = await bot.sendMessage(channelId, 'This should skip the attachment', {
|
|
644
|
+
attachments: [{ type: 'image', url: nonImageUrl }],
|
|
645
|
+
});
|
|
646
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
647
|
+
// Message should still be sent, just without the attachment
|
|
648
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
649
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
650
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
651
|
+
? message?.content?.attachments
|
|
652
|
+
: undefined;
|
|
653
|
+
expect(attachments).toHaveLength(0);
|
|
654
|
+
});
|
|
655
|
+
it('should gracefully handle invalid URL (404)', async () => {
|
|
656
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
657
|
+
// Use a URL that returns 404
|
|
658
|
+
const invalidUrl = 'https://httpbin.org/status/404';
|
|
659
|
+
const { eventId } = await bot.sendMessage(channelId, 'This should handle 404 gracefully', {
|
|
660
|
+
attachments: [{ type: 'image', url: invalidUrl }],
|
|
661
|
+
});
|
|
662
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
663
|
+
// Message should still be sent, just without the attachment
|
|
664
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
665
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
666
|
+
});
|
|
667
|
+
function createTestPNG(width, height) {
|
|
668
|
+
// PNG signature
|
|
669
|
+
const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
670
|
+
// IHDR chunk
|
|
671
|
+
const ihdrData = new Uint8Array(13);
|
|
672
|
+
const view = new DataView(ihdrData.buffer);
|
|
673
|
+
view.setUint32(0, width, false); // Width
|
|
674
|
+
view.setUint32(4, height, false); // Height
|
|
675
|
+
ihdrData[8] = 8; // Bit depth
|
|
676
|
+
ihdrData[9] = 2; // Color type (truecolor)
|
|
677
|
+
ihdrData[10] = 0; // Compression
|
|
678
|
+
ihdrData[11] = 0; // Filter
|
|
679
|
+
ihdrData[12] = 0; // Interlace
|
|
680
|
+
// Create IHDR chunk with CRC
|
|
681
|
+
const ihdrChunk = new Uint8Array(12 + 13);
|
|
682
|
+
new DataView(ihdrChunk.buffer).setUint32(0, 13, false);
|
|
683
|
+
ihdrChunk.set([73, 72, 68, 82], 4); // 'IHDR'
|
|
684
|
+
ihdrChunk.set(ihdrData, 8);
|
|
685
|
+
new DataView(ihdrChunk.buffer).setUint32(21, 0, false); // CRC placeholder
|
|
686
|
+
// IDAT chunk (minimal data)
|
|
687
|
+
const idatData = new Uint8Array(100); // Minimal compressed data
|
|
688
|
+
const idatChunk = new Uint8Array(12 + 100);
|
|
689
|
+
new DataView(idatChunk.buffer).setUint32(0, 100, false);
|
|
690
|
+
idatChunk.set([73, 68, 65, 84], 4); // 'IDAT'
|
|
691
|
+
idatChunk.set(idatData, 8);
|
|
692
|
+
new DataView(idatChunk.buffer).setUint32(108, 0, false); // CRC placeholder
|
|
693
|
+
// IEND chunk
|
|
694
|
+
const iendChunk = new Uint8Array([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]);
|
|
695
|
+
// Combine all chunks
|
|
696
|
+
const png = new Uint8Array(signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length);
|
|
697
|
+
png.set(signature, 0);
|
|
698
|
+
png.set(ihdrChunk, signature.length);
|
|
699
|
+
png.set(idatChunk, signature.length + ihdrChunk.length);
|
|
700
|
+
png.set(iendChunk, signature.length + ihdrChunk.length + idatChunk.length);
|
|
701
|
+
return png;
|
|
702
|
+
}
|
|
703
|
+
it('should send chunked media with Uint8Array data', async () => {
|
|
704
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
705
|
+
const testData = createTestPNG(100, 100);
|
|
706
|
+
const { eventId } = await bot.sendMessage(channelId, 'Chunked media test', {
|
|
707
|
+
attachments: [
|
|
708
|
+
{
|
|
709
|
+
type: 'chunked',
|
|
710
|
+
data: testData,
|
|
711
|
+
filename: 'test.png',
|
|
712
|
+
mimetype: 'image/png',
|
|
713
|
+
width: 100,
|
|
714
|
+
height: 100,
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
});
|
|
718
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
719
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
720
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
721
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
722
|
+
? message?.content?.attachments
|
|
723
|
+
: undefined;
|
|
724
|
+
expect(attachments).toHaveLength(1);
|
|
725
|
+
expect(attachments?.[0].type).toBe('chunked_media');
|
|
726
|
+
const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
|
|
727
|
+
expect(chunkedMedia).toBeDefined();
|
|
728
|
+
expect(chunkedMedia?.info.filename).toBe('test.png');
|
|
729
|
+
expect(chunkedMedia?.info.mimetype).toBe('image/png');
|
|
730
|
+
expect(chunkedMedia?.info.widthPixels).toBe(100);
|
|
731
|
+
expect(chunkedMedia?.info.heightPixels).toBe(100);
|
|
732
|
+
expect(chunkedMedia?.streamId).toBeDefined();
|
|
733
|
+
expect(chunkedMedia?.encryption).toBeDefined();
|
|
734
|
+
});
|
|
735
|
+
it('should send chunked media with Blob data and auto-detect dimensions', async () => {
|
|
736
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
737
|
+
const testData = createTestPNG(200, 150);
|
|
738
|
+
const blob = new Blob([testData.buffer], { type: 'image/png' });
|
|
739
|
+
const { eventId } = await bot.sendMessage(channelId, 'Blob test', {
|
|
740
|
+
attachments: [
|
|
741
|
+
{
|
|
742
|
+
type: 'chunked',
|
|
743
|
+
data: blob,
|
|
744
|
+
filename: 'blob-test.png',
|
|
745
|
+
},
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
749
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
750
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
751
|
+
? message?.content?.attachments
|
|
752
|
+
: undefined;
|
|
753
|
+
expect(attachments).toHaveLength(1);
|
|
754
|
+
const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
|
|
755
|
+
expect(chunkedMedia?.info.widthPixels).toBe(200);
|
|
756
|
+
expect(chunkedMedia?.info.heightPixels).toBe(150);
|
|
757
|
+
});
|
|
758
|
+
it('should handle large chunked media (multiple chunks)', async () => {
|
|
759
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
760
|
+
// Create 2.5MB of data - will create mulitple chunks
|
|
761
|
+
const largeData = new Uint8Array(2500000);
|
|
762
|
+
for (let i = 0; i < largeData.length; i++) {
|
|
763
|
+
largeData[i] = i % 256;
|
|
764
|
+
}
|
|
765
|
+
const { eventId } = await bot.sendMessage(channelId, 'Large media test', {
|
|
766
|
+
attachments: [
|
|
767
|
+
{
|
|
768
|
+
type: 'chunked',
|
|
769
|
+
data: largeData,
|
|
770
|
+
filename: 'large-file.bin',
|
|
771
|
+
mimetype: 'application/octet-stream',
|
|
772
|
+
},
|
|
773
|
+
],
|
|
774
|
+
});
|
|
775
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined(), { timeoutMS: 30000 });
|
|
776
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
777
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
778
|
+
? message?.content?.attachments
|
|
779
|
+
: undefined;
|
|
780
|
+
expect(attachments).toHaveLength(1);
|
|
781
|
+
const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
|
|
782
|
+
expect(chunkedMedia?.info.sizeBytes).toBe(BigInt(2500000));
|
|
783
|
+
});
|
|
784
|
+
// @miguel-nascimento 2025-12-08 flaky test
|
|
785
|
+
it.skip('should send mixed attachments (URL + chunked)', async () => {
|
|
786
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
787
|
+
const testData = createTestPNG(50, 50);
|
|
788
|
+
const imageUrl = 'https://placehold.co/100x100.png';
|
|
789
|
+
const { eventId } = await bot.sendMessage(channelId, 'Mixed attachments', {
|
|
790
|
+
attachments: [
|
|
791
|
+
{ type: 'image', url: imageUrl },
|
|
792
|
+
{
|
|
793
|
+
type: 'chunked',
|
|
794
|
+
data: testData,
|
|
795
|
+
filename: 'generated.png',
|
|
796
|
+
mimetype: 'image/png',
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
});
|
|
800
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
801
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
802
|
+
const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
|
|
803
|
+
? message?.content?.attachments
|
|
804
|
+
: undefined;
|
|
805
|
+
expect(attachments).toHaveLength(2);
|
|
806
|
+
expect(attachments?.[0].type).toBe('image');
|
|
807
|
+
expect(attachments?.[1].type).toBe('chunked_media');
|
|
808
|
+
});
|
|
809
|
+
it('should send and receive GM messages with schema validation', async () => {
|
|
810
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
811
|
+
const messageSchema = z.object({ text: z.string(), count: z.number() });
|
|
812
|
+
const receivedGmEvents = [];
|
|
813
|
+
subscriptions.push(bot.onGmMessage('test.typed.v1', messageSchema, (_h, e) => {
|
|
814
|
+
receivedGmEvents.push({ typeUrl: e.typeUrl, data: e.data });
|
|
815
|
+
}));
|
|
816
|
+
const testData = { text: 'Hello', count: 42 };
|
|
817
|
+
// Bob sends the message so bot receives it (bot filters its own messages)
|
|
818
|
+
const jsonString = superjsonStringify(testData);
|
|
819
|
+
await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
|
|
820
|
+
content: {
|
|
821
|
+
typeUrl: 'test.typed.v1',
|
|
822
|
+
value: new TextEncoder().encode(jsonString),
|
|
823
|
+
},
|
|
824
|
+
}));
|
|
825
|
+
await waitFor(() => receivedGmEvents.length > 0);
|
|
826
|
+
const event = receivedGmEvents[0];
|
|
827
|
+
expect(event).toBeDefined();
|
|
828
|
+
expect(event.typeUrl).toBe('test.typed.v1');
|
|
829
|
+
expect(event.data).toEqual(testData);
|
|
830
|
+
});
|
|
831
|
+
it('should handle GM with Date objects (superjson serialization)', async () => {
|
|
832
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
833
|
+
const eventSchema = z.object({
|
|
834
|
+
eventType: z.string(),
|
|
835
|
+
timestamp: z.date(),
|
|
836
|
+
});
|
|
837
|
+
const testDate = new Date('2025-01-15T12:00:00Z');
|
|
838
|
+
const { eventId } = await bot.sendGM(channelId, 'test.date.v1', eventSchema, {
|
|
839
|
+
eventType: 'test',
|
|
840
|
+
timestamp: testDate,
|
|
841
|
+
});
|
|
842
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
843
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
844
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
|
|
845
|
+
const gmData = message?.content?.kind === RiverTimelineEvent.ChannelMessage &&
|
|
846
|
+
message?.content?.content.msgType === MessageType.GM
|
|
847
|
+
? message?.content?.content.data
|
|
848
|
+
: undefined;
|
|
849
|
+
expect(gmData).toBeDefined();
|
|
850
|
+
expect(gmData).toStrictEqual(new TextEncoder().encode(superjsonStringify({ eventType: 'test', timestamp: testDate })));
|
|
851
|
+
});
|
|
852
|
+
it('should handle multiple handlers for different typeUrls', async () => {
|
|
853
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
854
|
+
const schema1 = z.object({ type: z.literal('type1'), value: z.number() });
|
|
855
|
+
const schema2 = z.object({ type: z.literal('type2'), text: z.string() });
|
|
856
|
+
const receivedType1 = [];
|
|
857
|
+
const receivedType2 = [];
|
|
858
|
+
subscriptions.push(bot.onGmMessage('test.multi.type1', schema1, (_h, e) => {
|
|
859
|
+
receivedType1.push(e.data);
|
|
860
|
+
}));
|
|
861
|
+
subscriptions.push(bot.onGmMessage('test.multi.type2', schema2, (_h, e) => {
|
|
862
|
+
receivedType2.push(e.data);
|
|
863
|
+
}));
|
|
864
|
+
const data1 = { type: 'type1', value: 123 };
|
|
865
|
+
const data2 = { type: 'type2', text: 'hello' };
|
|
866
|
+
await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
|
|
867
|
+
content: {
|
|
868
|
+
typeUrl: 'test.multi.type1',
|
|
869
|
+
value: new TextEncoder().encode(superjsonStringify(data1)),
|
|
870
|
+
},
|
|
871
|
+
}));
|
|
872
|
+
await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
|
|
873
|
+
content: {
|
|
874
|
+
typeUrl: 'test.multi.type2',
|
|
875
|
+
value: new TextEncoder().encode(superjsonStringify(data2)),
|
|
876
|
+
},
|
|
877
|
+
}));
|
|
878
|
+
await waitFor(() => receivedType1.length > 0 && receivedType2.length > 0);
|
|
879
|
+
expect(receivedType1[0]).toEqual(data1);
|
|
880
|
+
expect(receivedType2[0]).toEqual(data2);
|
|
881
|
+
});
|
|
882
|
+
it('should handle raw GM messages', async () => {
|
|
883
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
884
|
+
const receivedMessages = [];
|
|
885
|
+
subscriptions.push(bot.onRawGmMessage((_h, e) => {
|
|
886
|
+
receivedMessages.push({ typeUrl: e.typeUrl, message: e.message });
|
|
887
|
+
}));
|
|
888
|
+
const message = new TextEncoder().encode('Hello, world!');
|
|
889
|
+
await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
|
|
890
|
+
content: {
|
|
891
|
+
typeUrl: 'test.raw.v1',
|
|
892
|
+
value: message,
|
|
893
|
+
},
|
|
894
|
+
}));
|
|
895
|
+
await waitFor(() => receivedMessages.length > 0);
|
|
896
|
+
expect(receivedMessages[0].typeUrl).toBe('test.raw.v1');
|
|
897
|
+
expect(receivedMessages[0].message).toEqual(message);
|
|
898
|
+
});
|
|
899
|
+
it('should log error and continue processing if throws an error when handling an event', async () => {
|
|
900
|
+
const consoleErrorSpy = vi.spyOn(console, 'error');
|
|
901
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
902
|
+
subscriptions.push(bot.onMessage(() => {
|
|
903
|
+
throw new Error('test error');
|
|
904
|
+
}));
|
|
905
|
+
await bobDefaultGdm.sendMessage('lol');
|
|
906
|
+
await waitFor(() => consoleErrorSpy.mock.calls.length > 0);
|
|
907
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toContain('[@towns-labs/app-framework] Error while handling event');
|
|
908
|
+
consoleErrorSpy.mockRestore();
|
|
909
|
+
});
|
|
910
|
+
it('bot should be able to send encrypted interaction request and user should send encrypted response', async () => {
|
|
911
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
|
|
912
|
+
const requestId = randomUUID();
|
|
913
|
+
const interactionRequestContent = {
|
|
914
|
+
case: 'signature',
|
|
915
|
+
value: {
|
|
916
|
+
id: requestId,
|
|
917
|
+
data: '0x1234567890',
|
|
918
|
+
chainId: '1',
|
|
919
|
+
type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
|
|
923
|
+
// Wait for Bob to receive the interaction request
|
|
924
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
925
|
+
// Wait for decryption to complete
|
|
926
|
+
await waitFor(() => {
|
|
927
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
928
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
return event?.content?.payload !== undefined;
|
|
932
|
+
});
|
|
933
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
934
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
935
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
936
|
+
throw new Error('Event is not an InteractionRequest');
|
|
937
|
+
}
|
|
938
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
939
|
+
const encryptionDevice = decryptedPayload?.encryptionDevice;
|
|
940
|
+
expect(decryptedPayload).toBeDefined();
|
|
941
|
+
expect(encryptionDevice).toBeDefined();
|
|
942
|
+
expect(decryptedPayload?.content.case).toBe('signature');
|
|
943
|
+
if (decryptedPayload?.content.case === 'signature') {
|
|
944
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
945
|
+
expect(decryptedPayload.content.value.data).toBe('0x1234567890');
|
|
946
|
+
}
|
|
947
|
+
// bob should be able to send interaction response to the bot using the decrypted encryption device
|
|
948
|
+
const recipient = bin_fromHexString(botClientAddress);
|
|
949
|
+
const interactionResponsePayload = {
|
|
950
|
+
salt: genIdBlob(),
|
|
951
|
+
content: {
|
|
952
|
+
case: 'signature',
|
|
953
|
+
value: {
|
|
954
|
+
requestId: requestId,
|
|
955
|
+
signature: '0x123222222222',
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
const receivedInteractionResponses = [];
|
|
960
|
+
subscriptions.push(bot.onInteractionResponse((_h, e) => {
|
|
961
|
+
receivedInteractionResponses.push(e.response);
|
|
962
|
+
}));
|
|
963
|
+
await bobClient.riverConnection.call(async (client) => {
|
|
964
|
+
// from the client, to the channel, encrypted so that only the bot can read it
|
|
965
|
+
return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, encryptionDevice);
|
|
966
|
+
});
|
|
967
|
+
await waitFor(() => receivedInteractionResponses.length > 0);
|
|
968
|
+
expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
|
|
969
|
+
expect(receivedInteractionResponses[0].payload.content.value?.requestId).toEqual(requestId);
|
|
970
|
+
});
|
|
971
|
+
it('user should NOT be able to send interaction request', async () => {
|
|
972
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
973
|
+
const interactionRequestContent = {
|
|
974
|
+
case: 'signature',
|
|
975
|
+
value: {
|
|
976
|
+
id: randomUUID(),
|
|
977
|
+
data: '0x1234567890',
|
|
978
|
+
chainId: '1',
|
|
979
|
+
type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
|
|
980
|
+
},
|
|
981
|
+
};
|
|
982
|
+
await bobClient.riverConnection.call(async (client) => {
|
|
983
|
+
// Try to send an interaction request as a user (should fail)
|
|
984
|
+
// Note: Even if we try to use the proper sendInteractionRequest method,
|
|
985
|
+
// it should fail because only apps can send interaction requests
|
|
986
|
+
await expect(client.sendInteractionRequest(channelId, interactionRequestContent)).rejects.toThrow(/creator is not an app/);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
it('bot should be able to send form interaction request', async () => {
|
|
990
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
991
|
+
const interactionRequestContent = {
|
|
992
|
+
case: 'form',
|
|
993
|
+
value: {
|
|
994
|
+
id: randomUUID(),
|
|
995
|
+
components: [
|
|
996
|
+
{ id: '1', component: { case: 'button', value: { label: 'Button' } } },
|
|
997
|
+
{
|
|
998
|
+
id: '2',
|
|
999
|
+
component: { case: 'textInput', value: { placeholder: 'Text Input' } },
|
|
1000
|
+
},
|
|
1001
|
+
],
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
|
|
1005
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1006
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1007
|
+
expect(message).toBeDefined();
|
|
1008
|
+
});
|
|
1009
|
+
it('bot should be able to send signature interaction request using flattened API', async () => {
|
|
1010
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1011
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
|
|
1012
|
+
type: 'signature',
|
|
1013
|
+
data: '0xabcdef1234567890',
|
|
1014
|
+
chainId: '8453',
|
|
1015
|
+
method: 'personal_sign',
|
|
1016
|
+
signerWallet: botClientAddress,
|
|
1017
|
+
});
|
|
1018
|
+
// Wait for Bob to receive the interaction request
|
|
1019
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1020
|
+
// Wait for decryption to complete
|
|
1021
|
+
await waitFor(() => {
|
|
1022
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1023
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
return event?.content?.payload !== undefined;
|
|
1027
|
+
});
|
|
1028
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1029
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1030
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1031
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1032
|
+
}
|
|
1033
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1034
|
+
expect(decryptedPayload).toBeDefined();
|
|
1035
|
+
expect(decryptedPayload?.content.case).toBe('signature');
|
|
1036
|
+
if (decryptedPayload?.content.case === 'signature') {
|
|
1037
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1038
|
+
expect(decryptedPayload.content.value.data).toBe('0xabcdef1234567890');
|
|
1039
|
+
expect(decryptedPayload.content.value.chainId).toBe('8453');
|
|
1040
|
+
expect(decryptedPayload.content.value.type).toBe(InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
it('bot should be able to send form interaction request using flattened API', async () => {
|
|
1044
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1045
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
|
|
1046
|
+
type: 'form',
|
|
1047
|
+
components: [
|
|
1048
|
+
{ id: 'btn-1', type: 'button', label: 'Click Me' },
|
|
1049
|
+
{ id: 'input-1', type: 'textInput', placeholder: 'Enter text here' },
|
|
1050
|
+
],
|
|
1051
|
+
});
|
|
1052
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1053
|
+
// Wait for decryption to complete
|
|
1054
|
+
await waitFor(() => {
|
|
1055
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1056
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
return event?.content?.payload !== undefined;
|
|
1060
|
+
});
|
|
1061
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1062
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1063
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1064
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1065
|
+
}
|
|
1066
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1067
|
+
expect(decryptedPayload).toBeDefined();
|
|
1068
|
+
expect(decryptedPayload?.content.case).toBe('form');
|
|
1069
|
+
if (decryptedPayload?.content.case === 'form') {
|
|
1070
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1071
|
+
expect(decryptedPayload.content.value.components).toHaveLength(2);
|
|
1072
|
+
expect(decryptedPayload.content.value.components[0].id).toBe('btn-1');
|
|
1073
|
+
expect(decryptedPayload.content.value.components[0].component.case).toBe('button');
|
|
1074
|
+
expect(decryptedPayload.content.value.components[1].id).toBe('input-1');
|
|
1075
|
+
expect(decryptedPayload.content.value.components[1].component.case).toBe('textInput');
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
it('bot should be able to send transaction interaction request using flattened API', async () => {
|
|
1079
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1080
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
|
|
1081
|
+
type: 'transaction',
|
|
1082
|
+
title: 'Send USDC',
|
|
1083
|
+
subtitle: 'Send 50 USDC to recipient',
|
|
1084
|
+
chainId: '8453',
|
|
1085
|
+
signerWallet: botClientAddress,
|
|
1086
|
+
txs: [
|
|
1087
|
+
{
|
|
1088
|
+
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
|
|
1089
|
+
value: '0',
|
|
1090
|
+
data: '0xa9059cbb', // transfer function selector
|
|
1091
|
+
},
|
|
1092
|
+
],
|
|
1093
|
+
});
|
|
1094
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1095
|
+
// Wait for decryption to complete
|
|
1096
|
+
await waitFor(() => {
|
|
1097
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1098
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
return event?.content?.payload !== undefined;
|
|
1102
|
+
});
|
|
1103
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1104
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1105
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1106
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1107
|
+
}
|
|
1108
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1109
|
+
expect(decryptedPayload).toBeDefined();
|
|
1110
|
+
expect(decryptedPayload?.content.case).toBe('transaction');
|
|
1111
|
+
if (decryptedPayload?.content.case === 'transaction') {
|
|
1112
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1113
|
+
expect(decryptedPayload.content.value.title).toBe('Send USDC');
|
|
1114
|
+
expect(decryptedPayload.content.value.subtitle).toBe('Send 50 USDC to recipient');
|
|
1115
|
+
expect(decryptedPayload.content.value.content.case).toBe('evm');
|
|
1116
|
+
if (decryptedPayload.content.value.content.case === 'evm') {
|
|
1117
|
+
const evmBatch = decryptedPayload.content.value.content.value;
|
|
1118
|
+
expect(evmBatch.chainId).toBe('8453');
|
|
1119
|
+
expect(evmBatch.transactions).toHaveLength(1);
|
|
1120
|
+
expect(evmBatch.transactions[0].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1121
|
+
expect(evmBatch.transactions[0].value).toBe('0');
|
|
1122
|
+
expect(evmBatch.transactions[0].data).toBe('0xa9059cbb');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
it('bot should be able to send batched transaction interaction request', async () => {
|
|
1127
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1128
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
|
|
1129
|
+
type: 'transaction',
|
|
1130
|
+
title: 'Batch Transaction',
|
|
1131
|
+
subtitle: 'Approve and transfer USDC',
|
|
1132
|
+
chainId: '8453',
|
|
1133
|
+
signerWallet: botClientAddress,
|
|
1134
|
+
txs: [
|
|
1135
|
+
{
|
|
1136
|
+
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
|
|
1137
|
+
value: '0',
|
|
1138
|
+
data: '0x095ea7b3', // approve function selector
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
|
|
1142
|
+
value: '0',
|
|
1143
|
+
data: '0xa9059cbb', // transfer function selector
|
|
1144
|
+
},
|
|
1145
|
+
],
|
|
1146
|
+
});
|
|
1147
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1148
|
+
// Wait for decryption to complete
|
|
1149
|
+
await waitFor(() => {
|
|
1150
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1151
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
return event?.content?.payload !== undefined;
|
|
1155
|
+
});
|
|
1156
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1157
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1158
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1159
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1160
|
+
}
|
|
1161
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1162
|
+
expect(decryptedPayload).toBeDefined();
|
|
1163
|
+
expect(decryptedPayload?.content.case).toBe('transaction');
|
|
1164
|
+
if (decryptedPayload?.content.case === 'transaction') {
|
|
1165
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1166
|
+
expect(decryptedPayload.content.value.title).toBe('Batch Transaction');
|
|
1167
|
+
expect(decryptedPayload.content.value.content.case).toBe('evm');
|
|
1168
|
+
if (decryptedPayload.content.value.content.case === 'evm') {
|
|
1169
|
+
const evmBatch = decryptedPayload.content.value.content.value;
|
|
1170
|
+
expect(evmBatch.chainId).toBe('8453');
|
|
1171
|
+
expect(evmBatch.signerWallet).toBe(botClientAddress);
|
|
1172
|
+
expect(evmBatch.transactions).toHaveLength(2);
|
|
1173
|
+
// First tx: approve
|
|
1174
|
+
expect(evmBatch.transactions[0].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1175
|
+
expect(evmBatch.transactions[0].data).toBe('0x095ea7b3');
|
|
1176
|
+
// Second tx: transfer
|
|
1177
|
+
expect(evmBatch.transactions[1].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1178
|
+
expect(evmBatch.transactions[1].data).toBe('0xa9059cbb');
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
it('bot should be able to send preparedCalls interaction request using flattened API', async () => {
|
|
1183
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1184
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
|
|
1185
|
+
type: 'preparedCalls',
|
|
1186
|
+
chainId: '8453',
|
|
1187
|
+
typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
|
|
1188
|
+
context: '{"some":"context"}',
|
|
1189
|
+
title: 'Approve Token Spend',
|
|
1190
|
+
subtitle: 'Allow the app to spend up to 100 USDC',
|
|
1191
|
+
authorizeKeys: [
|
|
1192
|
+
{
|
|
1193
|
+
expiry: '1700000000',
|
|
1194
|
+
type: 'secp256k1',
|
|
1195
|
+
role: 'normal',
|
|
1196
|
+
publicKey: '0x04abcdef',
|
|
1197
|
+
permissions: [
|
|
1198
|
+
{
|
|
1199
|
+
type: 'spend',
|
|
1200
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
1201
|
+
limit: '100000000',
|
|
1202
|
+
period: '86400',
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
});
|
|
1208
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1209
|
+
await waitFor(() => {
|
|
1210
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1211
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
return event?.content?.payload !== undefined;
|
|
1215
|
+
});
|
|
1216
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1217
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1218
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1219
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1220
|
+
}
|
|
1221
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1222
|
+
expect(decryptedPayload).toBeDefined();
|
|
1223
|
+
expect(decryptedPayload?.content.case).toBe('preparedCalls');
|
|
1224
|
+
if (decryptedPayload?.content.case === 'preparedCalls') {
|
|
1225
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1226
|
+
expect(decryptedPayload.content.value.chainId).toBe('8453');
|
|
1227
|
+
expect(decryptedPayload.content.value.typedData).toBe('{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}');
|
|
1228
|
+
expect(decryptedPayload.content.value.context).toBe('{"some":"context"}');
|
|
1229
|
+
expect(decryptedPayload.content.value.title).toBe('Approve Token Spend');
|
|
1230
|
+
expect(decryptedPayload.content.value.subtitle).toBe('Allow the app to spend up to 100 USDC');
|
|
1231
|
+
expect(decryptedPayload.content.value.authorizeKeys).toHaveLength(1);
|
|
1232
|
+
const key = decryptedPayload.content.value.authorizeKeys[0];
|
|
1233
|
+
expect(key.expiry).toBe('1700000000');
|
|
1234
|
+
expect(key.type).toBe('secp256k1');
|
|
1235
|
+
expect(key.role).toBe('normal');
|
|
1236
|
+
expect(key.publicKey).toBe('0x04abcdef');
|
|
1237
|
+
expect(key.permissions).toHaveLength(1);
|
|
1238
|
+
expect(key.permissions[0].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.SPEND);
|
|
1239
|
+
expect(key.permissions[0].permission.case).toBe('spend');
|
|
1240
|
+
if (key.permissions[0].permission.case === 'spend') {
|
|
1241
|
+
expect(key.permissions[0].permission.value.token).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1242
|
+
expect(key.permissions[0].permission.value.limit).toBe('100000000');
|
|
1243
|
+
expect(key.permissions[0].permission.value.period).toBe('86400');
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
it('bot should be able to send preparedCalls with authorizeKeys', async () => {
|
|
1248
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1249
|
+
const { eventId } = await bot.sendInteractionRequest(channelId, {
|
|
1250
|
+
type: 'preparedCalls',
|
|
1251
|
+
chainId: '8453',
|
|
1252
|
+
typedData: '{"types":{},"primaryType":"","domain":{},"message":{}}',
|
|
1253
|
+
context: '{}',
|
|
1254
|
+
title: 'Authorize Session Key',
|
|
1255
|
+
authorizeKeys: [
|
|
1256
|
+
{
|
|
1257
|
+
expiry: '1700000000',
|
|
1258
|
+
type: 'secp256k1',
|
|
1259
|
+
role: 'normal',
|
|
1260
|
+
publicKey: '0x04abcdef',
|
|
1261
|
+
permissions: [
|
|
1262
|
+
{
|
|
1263
|
+
type: 'call',
|
|
1264
|
+
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
1265
|
+
selector: '0xa9059cbb',
|
|
1266
|
+
},
|
|
1267
|
+
{
|
|
1268
|
+
type: 'spend',
|
|
1269
|
+
token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
1270
|
+
limit: '1000000',
|
|
1271
|
+
period: 'day',
|
|
1272
|
+
},
|
|
1273
|
+
],
|
|
1274
|
+
},
|
|
1275
|
+
],
|
|
1276
|
+
});
|
|
1277
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1278
|
+
await waitFor(() => {
|
|
1279
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1280
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
return event?.content?.payload !== undefined;
|
|
1284
|
+
});
|
|
1285
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1286
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1287
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1288
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1289
|
+
}
|
|
1290
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1291
|
+
expect(decryptedPayload).toBeDefined();
|
|
1292
|
+
expect(decryptedPayload?.content.case).toBe('preparedCalls');
|
|
1293
|
+
if (decryptedPayload?.content.case === 'preparedCalls') {
|
|
1294
|
+
expect(decryptedPayload.content.value.authorizeKeys).toHaveLength(1);
|
|
1295
|
+
const key = decryptedPayload.content.value.authorizeKeys[0];
|
|
1296
|
+
expect(key.expiry).toBe('1700000000');
|
|
1297
|
+
expect(key.type).toBe('secp256k1');
|
|
1298
|
+
expect(key.role).toBe('normal');
|
|
1299
|
+
expect(key.publicKey).toBe('0x04abcdef');
|
|
1300
|
+
expect(key.permissions).toHaveLength(2);
|
|
1301
|
+
expect(key.permissions[0].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.CALL);
|
|
1302
|
+
expect(key.permissions[0].permission.case).toBe('call');
|
|
1303
|
+
if (key.permissions[0].permission.case === 'call') {
|
|
1304
|
+
expect(key.permissions[0].permission.value.to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1305
|
+
expect(key.permissions[0].permission.value.selector).toBe('0xa9059cbb');
|
|
1306
|
+
}
|
|
1307
|
+
expect(key.permissions[1].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.SPEND);
|
|
1308
|
+
expect(key.permissions[1].permission.case).toBe('spend');
|
|
1309
|
+
if (key.permissions[1].permission.case === 'spend') {
|
|
1310
|
+
expect(key.permissions[1].permission.value.token).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
1311
|
+
expect(key.permissions[1].permission.value.limit).toBe('1000000');
|
|
1312
|
+
expect(key.permissions[1].permission.value.period).toBe('day');
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
it('bot should be able to send addMember interaction request and user should respond', async () => {
|
|
1317
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1318
|
+
// Create a new GDM with bob, carol, and the bot (alice is NOT in it)
|
|
1319
|
+
const { streamId: addMemberGdmId } = await bobClient.gdms.createGDM([
|
|
1320
|
+
carol.userId,
|
|
1321
|
+
bot.agentUserId,
|
|
1322
|
+
]);
|
|
1323
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(addMemberGdmId));
|
|
1324
|
+
const bobAddMemberGdm = bobClient.gdms.getGdm(addMemberGdmId);
|
|
1325
|
+
// Bot sends addMember interaction request to bob
|
|
1326
|
+
const { eventId, requestId } = await bot.sendInteractionRequest(addMemberGdmId, {
|
|
1327
|
+
type: 'addMember',
|
|
1328
|
+
userId: alice.userId,
|
|
1329
|
+
message: 'Please add Alice to the group, she has great intel!',
|
|
1330
|
+
});
|
|
1331
|
+
// Wait for Bob to receive the interaction request
|
|
1332
|
+
await waitFor(() => expect(bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1333
|
+
// Wait for decryption to complete
|
|
1334
|
+
await waitFor(() => {
|
|
1335
|
+
const event = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1336
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
return event?.content?.payload !== undefined;
|
|
1340
|
+
});
|
|
1341
|
+
// Bob decrypts the request and verifies
|
|
1342
|
+
const decryptedEvent = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1343
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1344
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1345
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1346
|
+
}
|
|
1347
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1348
|
+
expect(decryptedPayload).toBeDefined();
|
|
1349
|
+
expect(decryptedPayload?.content.case).toBe('addMember');
|
|
1350
|
+
if (decryptedPayload?.content.case === 'addMember') {
|
|
1351
|
+
expect(decryptedPayload.content.value.id).toBe(requestId);
|
|
1352
|
+
expect(decryptedPayload.content.value.userId).toEqual(bin_fromHexString(alice.userId));
|
|
1353
|
+
expect(decryptedPayload.content.value.message).toBe('Please add Alice to the group, she has great intel!');
|
|
1354
|
+
}
|
|
1355
|
+
const encryptionDevice = decryptedPayload?.encryptionDevice;
|
|
1356
|
+
check(isDefined(encryptionDevice), 'encryptionDevice is defined');
|
|
1357
|
+
expect(encryptionDevice?.deviceKey).toBeDefined();
|
|
1358
|
+
expect(encryptionDevice?.fallbackKey).toBeDefined();
|
|
1359
|
+
// Bob sends addMember interaction response with accepted: true
|
|
1360
|
+
const recipient = bin_fromHexString(botClientAddress);
|
|
1361
|
+
const interactionResponsePayload = {
|
|
1362
|
+
salt: genIdBlob(),
|
|
1363
|
+
content: {
|
|
1364
|
+
case: 'addMember',
|
|
1365
|
+
value: {
|
|
1366
|
+
requestId: requestId,
|
|
1367
|
+
accepted: true,
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
const receivedInteractionResponses = [];
|
|
1372
|
+
subscriptions.push(bot.onInteractionResponse((_h, e) => {
|
|
1373
|
+
receivedInteractionResponses.push(e.response);
|
|
1374
|
+
}));
|
|
1375
|
+
// Bob calls joinUser() to add alice to the GDM
|
|
1376
|
+
await bobClient.riverConnection.call((client) => client.joinUser(addMemberGdmId, alice.userId));
|
|
1377
|
+
await bobClient.riverConnection.call(async (client) => {
|
|
1378
|
+
return await client.sendInteractionResponse(addMemberGdmId, recipient, interactionResponsePayload, encryptionDevice);
|
|
1379
|
+
});
|
|
1380
|
+
// Bot receives the response and verifies
|
|
1381
|
+
await waitFor(() => receivedInteractionResponses.length > 0);
|
|
1382
|
+
expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
|
|
1383
|
+
expect(receivedInteractionResponses[0].payload.content.case).toBe('addMember');
|
|
1384
|
+
if (receivedInteractionResponses[0].payload.content.case === 'addMember') {
|
|
1385
|
+
expect(receivedInteractionResponses[0].payload.content.value.requestId).toBe(requestId);
|
|
1386
|
+
expect(receivedInteractionResponses[0].payload.content.value.accepted).toBe(true);
|
|
1387
|
+
}
|
|
1388
|
+
// Verify alice is now a member of the GDM
|
|
1389
|
+
await waitFor(() => {
|
|
1390
|
+
expect(bobAddMemberGdm.members.value.userIds).toContain(alice.userId);
|
|
1391
|
+
});
|
|
1392
|
+
});
|
|
1393
|
+
it('bot should be able to send batch interaction request with mixed steps using flattened API', async () => {
|
|
1394
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1395
|
+
const { eventId, requestId: batchId } = await bot.sendInteractionRequest(channelId, {
|
|
1396
|
+
type: 'batch',
|
|
1397
|
+
title: 'Cross-chain Swap',
|
|
1398
|
+
subtitle: 'Approve on Base, swap on Mainnet, confirm signature',
|
|
1399
|
+
steps: [
|
|
1400
|
+
{
|
|
1401
|
+
type: 'preparedCalls',
|
|
1402
|
+
chainId: '8453',
|
|
1403
|
+
typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
|
|
1404
|
+
context: '{"step":"approve"}',
|
|
1405
|
+
title: 'Approve USDC on Base',
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
type: 'preparedCalls',
|
|
1409
|
+
chainId: '1',
|
|
1410
|
+
typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
|
|
1411
|
+
context: '{"step":"swap"}',
|
|
1412
|
+
title: 'Swap on Mainnet',
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
type: 'signature',
|
|
1416
|
+
data: '0xdeadbeef',
|
|
1417
|
+
chainId: '1',
|
|
1418
|
+
method: 'typed_data',
|
|
1419
|
+
signerWallet: botClientAddress,
|
|
1420
|
+
title: 'Confirm swap',
|
|
1421
|
+
},
|
|
1422
|
+
],
|
|
1423
|
+
});
|
|
1424
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1425
|
+
await waitFor(() => {
|
|
1426
|
+
const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1427
|
+
if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
return event?.content?.payload !== undefined;
|
|
1431
|
+
});
|
|
1432
|
+
const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1433
|
+
expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
|
|
1434
|
+
if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
|
|
1435
|
+
throw new Error('Event is not an InteractionRequest');
|
|
1436
|
+
}
|
|
1437
|
+
const decryptedPayload = decryptedEvent.content.payload;
|
|
1438
|
+
expect(decryptedPayload).toBeDefined();
|
|
1439
|
+
expect(decryptedPayload?.content.case).toBe('batch');
|
|
1440
|
+
if (decryptedPayload?.content.case === 'batch') {
|
|
1441
|
+
expect(decryptedPayload.content.value.id).toBe(batchId);
|
|
1442
|
+
expect(decryptedPayload.content.value.title).toBe('Cross-chain Swap');
|
|
1443
|
+
expect(decryptedPayload.content.value.subtitle).toBe('Approve on Base, swap on Mainnet, confirm signature');
|
|
1444
|
+
expect(decryptedPayload.content.value.steps).toHaveLength(3);
|
|
1445
|
+
// Step 1: PreparedCalls on Base
|
|
1446
|
+
const step1 = decryptedPayload.content.value.steps[0];
|
|
1447
|
+
expect(step1.content.case).toBe('preparedCalls');
|
|
1448
|
+
if (step1.content.case === 'preparedCalls') {
|
|
1449
|
+
expect(step1.content.value.id).toEqual(expect.any(String));
|
|
1450
|
+
expect(step1.content.value.chainId).toBe('8453');
|
|
1451
|
+
expect(step1.content.value.context).toBe('{"step":"approve"}');
|
|
1452
|
+
expect(step1.content.value.title).toBe('Approve USDC on Base');
|
|
1453
|
+
}
|
|
1454
|
+
// Step 2: PreparedCalls on Mainnet
|
|
1455
|
+
const step2 = decryptedPayload.content.value.steps[1];
|
|
1456
|
+
expect(step2.content.case).toBe('preparedCalls');
|
|
1457
|
+
if (step2.content.case === 'preparedCalls') {
|
|
1458
|
+
expect(step2.content.value.id).toEqual(expect.any(String));
|
|
1459
|
+
expect(step2.content.value.chainId).toBe('1');
|
|
1460
|
+
expect(step2.content.value.context).toBe('{"step":"swap"}');
|
|
1461
|
+
expect(step2.content.value.title).toBe('Swap on Mainnet');
|
|
1462
|
+
}
|
|
1463
|
+
// Step 3: Signature on Mainnet
|
|
1464
|
+
const step3 = decryptedPayload.content.value.steps[2];
|
|
1465
|
+
expect(step3.content.case).toBe('signature');
|
|
1466
|
+
if (step3.content.case === 'signature') {
|
|
1467
|
+
expect(step3.content.value.id).toEqual(expect.any(String));
|
|
1468
|
+
expect(step3.content.value.data).toBe('0xdeadbeef');
|
|
1469
|
+
expect(step3.content.value.chainId).toBe('1');
|
|
1470
|
+
expect(step3.content.value.type).toBe(InteractionRequestPayload_Signature_SignatureType.TYPED_DATA);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
it('joinUser should set appAddress when adding bot to GDM', async () => {
|
|
1475
|
+
// Create a GDM with bob and carol (no bot yet)
|
|
1476
|
+
const { streamId: testGdmId } = await bobClient.gdms.createGDM([carol.userId]);
|
|
1477
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(testGdmId));
|
|
1478
|
+
// Add the bot to the GDM with appAddress
|
|
1479
|
+
await bobClient.riverConnection.call((client) => client.joinUser(testGdmId, botClientAddress));
|
|
1480
|
+
// Verify the bot's membership has appAddress set
|
|
1481
|
+
await waitFor(async () => {
|
|
1482
|
+
const gdmStreamView = await bobClient.riverConnection.call((client) => client.getStream(testGdmId));
|
|
1483
|
+
const { joined } = gdmStreamView.getMembers();
|
|
1484
|
+
expect(joined.get(botClientAddress)?.appAddress).toBe(appAddress);
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
it('user should be able to send form interaction response', async () => {
|
|
1488
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
|
|
1489
|
+
const recipient = bin_fromHexString(botClientAddress);
|
|
1490
|
+
const interactionResponsePayload = {
|
|
1491
|
+
salt: genIdBlob(),
|
|
1492
|
+
content: {
|
|
1493
|
+
case: 'form',
|
|
1494
|
+
value: {
|
|
1495
|
+
requestId: randomUUID(),
|
|
1496
|
+
components: [
|
|
1497
|
+
{ id: '1', component: { case: 'button', value: {} } },
|
|
1498
|
+
{
|
|
1499
|
+
id: '2',
|
|
1500
|
+
component: { case: 'textInput', value: { value: 'Text Input' } },
|
|
1501
|
+
},
|
|
1502
|
+
],
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
};
|
|
1506
|
+
const receivedInteractionResponses = [];
|
|
1507
|
+
subscriptions.push(bot.onInteractionResponse((_h, e) => {
|
|
1508
|
+
receivedInteractionResponses.push(e.response);
|
|
1509
|
+
}));
|
|
1510
|
+
await bobClient.riverConnection.call(async (client) => {
|
|
1511
|
+
return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, bot.getUserDevice());
|
|
1512
|
+
});
|
|
1513
|
+
await waitFor(() => receivedInteractionResponses.length > 0);
|
|
1514
|
+
});
|
|
1515
|
+
it('bot should be able to join another app to a GDM via joinUser', async () => {
|
|
1516
|
+
const { streamId: gdmId } = await bobClient.gdms.createGDM([carol.userId, botClientAddress]);
|
|
1517
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
|
|
1518
|
+
const gdm = bobClient.gdms.getGdm(gdmId);
|
|
1519
|
+
const app2Result = await createAppForOwner(bobClient, {
|
|
1520
|
+
username: `app2_${genId(8)}`,
|
|
1521
|
+
displayName: 'App 2',
|
|
1522
|
+
description: 'Second app for joinUser test',
|
|
1523
|
+
imageUrl: 'https://placehold.co/600x600',
|
|
1524
|
+
});
|
|
1525
|
+
const app2Address = app2Result.appAddress;
|
|
1526
|
+
await bot.joinUser(gdmId, app2Address);
|
|
1527
|
+
await waitFor(() => {
|
|
1528
|
+
expect(gdm.members.value.userIds).toContain(app2Address);
|
|
1529
|
+
});
|
|
1530
|
+
const app2Id = bin_fromHexString(app2Address);
|
|
1531
|
+
await appRegistryRpcClient.updateAppSettings({
|
|
1532
|
+
appId: app2Id,
|
|
1533
|
+
forwardSetting: ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES,
|
|
1534
|
+
updateMask: ['forward_setting'],
|
|
1535
|
+
});
|
|
1536
|
+
const app2Agent = await makeTownsApp(app2Result.appPrivateData, {
|
|
1537
|
+
jwtSecret: app2Result.jwtSecretBase64,
|
|
1538
|
+
commands: [],
|
|
1539
|
+
});
|
|
1540
|
+
const app2Port = Number(process.env.BOT_PORT) + 2;
|
|
1541
|
+
const app2WebhookUrl = `http://localhost:${app2Port}/webhook`;
|
|
1542
|
+
const app2 = app2Agent.start();
|
|
1543
|
+
const app2Server = serve({
|
|
1544
|
+
port: app2Port,
|
|
1545
|
+
fetch: app2.fetch,
|
|
1546
|
+
});
|
|
1547
|
+
await appRegistryRpcClient.registerWebhook({
|
|
1548
|
+
appId: bin_fromHexString(app2Address),
|
|
1549
|
+
webhookUrl: app2WebhookUrl,
|
|
1550
|
+
});
|
|
1551
|
+
const receivedByApp2 = [];
|
|
1552
|
+
subscriptions.push(app2Agent.onMessage((_h, e) => {
|
|
1553
|
+
receivedByApp2.push(e);
|
|
1554
|
+
}));
|
|
1555
|
+
try {
|
|
1556
|
+
const messageText = 'Hi app2, welcome to the GDM.';
|
|
1557
|
+
const { eventId } = await gdm.sendMessage(messageText);
|
|
1558
|
+
await waitFor(() => receivedByApp2.length > 0, { timeoutMS: 15_000 });
|
|
1559
|
+
const receivedMessage = receivedByApp2.find((x) => x.eventId === eventId);
|
|
1560
|
+
expect(receivedMessage).toBeDefined();
|
|
1561
|
+
expect(receivedMessage?.message).toBe(messageText);
|
|
1562
|
+
expect(receivedMessage?.userId).toBe(bob.userId);
|
|
1563
|
+
}
|
|
1564
|
+
finally {
|
|
1565
|
+
app2Server.close();
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
it('bot should be able to pin and unpin their own messages', async () => {
|
|
1569
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1570
|
+
const { eventId, envelope } = await bot.sendMessage(channelId, 'Hello');
|
|
1571
|
+
const parsedEvnet = await bot.client.unpackEnvelope(envelope);
|
|
1572
|
+
const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, parsedEvnet.event);
|
|
1573
|
+
log('pinned event', pinEventId);
|
|
1574
|
+
const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId);
|
|
1575
|
+
log('unpinned event', unpinEventId);
|
|
1576
|
+
});
|
|
1577
|
+
// @miguel-nascimento 2025-12-08 flaky test
|
|
1578
|
+
it.skip('bot should be able to pin and unpin other users messages', async () => {
|
|
1579
|
+
// Skipped: marked flaky in prior runs.
|
|
1580
|
+
/*
|
|
1581
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES)
|
|
1582
|
+
const { eventId } = await bobDefaultGdm.sendMessage('Hello')
|
|
1583
|
+
const receivedMessages: OnMessageType[] = []
|
|
1584
|
+
subscriptions.push(
|
|
1585
|
+
bot.onMessage((_h, e) => {
|
|
1586
|
+
receivedMessages.push(e)
|
|
1587
|
+
}),
|
|
1588
|
+
)
|
|
1589
|
+
await waitFor(() => receivedMessages.length > 0)
|
|
1590
|
+
const message = receivedMessages.find((x) => x.eventId === eventId)
|
|
1591
|
+
check(isDefined(message), 'message is defined')
|
|
1592
|
+
expect(message).toBeDefined()
|
|
1593
|
+
expect(message?.event).toBeDefined()
|
|
1594
|
+
|
|
1595
|
+
const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, message.event)
|
|
1596
|
+
log('pinned event', pinEventId)
|
|
1597
|
+
const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId)
|
|
1598
|
+
log('unpinned event', unpinEventId)
|
|
1599
|
+
*/
|
|
1600
|
+
});
|
|
1601
|
+
it('bob (bot owner) should be able to update bot profile image', async () => {
|
|
1602
|
+
// Create mock chunked media info (following pattern from client.test.ts:1010-1047)
|
|
1603
|
+
const mediaStreamId = makeUniqueMediaStreamId();
|
|
1604
|
+
const image = create(MediaInfoSchema, {
|
|
1605
|
+
mimetype: 'image/png',
|
|
1606
|
+
filename: 'bot-avatar.png',
|
|
1607
|
+
});
|
|
1608
|
+
const { key, iv } = await deriveKeyAndIV(nanoid(128));
|
|
1609
|
+
const chunkedMediaInfo = {
|
|
1610
|
+
info: image,
|
|
1611
|
+
streamId: mediaStreamId,
|
|
1612
|
+
encryption: {
|
|
1613
|
+
case: 'aesgcm',
|
|
1614
|
+
value: { secretKey: key, iv },
|
|
1615
|
+
},
|
|
1616
|
+
thumbnail: undefined,
|
|
1617
|
+
};
|
|
1618
|
+
// Bob (bot owner) updates the bot's profile image using setUserProfileImageFor
|
|
1619
|
+
await bobClient.riverConnection.call(async (client) => {
|
|
1620
|
+
await client.setUserProfileImage(chunkedMediaInfo, botClientAddress);
|
|
1621
|
+
});
|
|
1622
|
+
await waitFor(async () => {
|
|
1623
|
+
// Verify the bot's profile image was updated
|
|
1624
|
+
// in waitFor because sometimes it takes a second before you can getStream on a media stream
|
|
1625
|
+
const decrypted = await bobClient.riverConnection.call(async (client) => {
|
|
1626
|
+
return await client.getUserProfileImage(botClientAddress);
|
|
1627
|
+
});
|
|
1628
|
+
expect(decrypted).toBeDefined();
|
|
1629
|
+
expect(decrypted?.info?.mimetype).toBe('image/png');
|
|
1630
|
+
expect(decrypted?.info?.filename).toBe('bot-avatar.png');
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
it('agent should receive messages from another agent', async () => {
|
|
1634
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1635
|
+
// Get contract addresses for this environment
|
|
1636
|
+
const chainId = townsConfig.base.chainConfig.chainId;
|
|
1637
|
+
const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
|
|
1638
|
+
if (!addresses?.accountProxy) {
|
|
1639
|
+
throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
|
|
1640
|
+
}
|
|
1641
|
+
// Create relayer client for agent2
|
|
1642
|
+
const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
|
|
1643
|
+
const relayerClient = createPublicClient({
|
|
1644
|
+
chain: {
|
|
1645
|
+
id: chainId,
|
|
1646
|
+
name: 'Test Chain',
|
|
1647
|
+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
|
1648
|
+
rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
|
|
1649
|
+
},
|
|
1650
|
+
transport: http(townsConfig.base.rpcUrl),
|
|
1651
|
+
}).extend(relayerActions({ relayerUrl }));
|
|
1652
|
+
// Create agent2 using relayer
|
|
1653
|
+
const agent2Result = await createApp({
|
|
1654
|
+
owner: bobClient.riverConnection.signerContext,
|
|
1655
|
+
metadata: {
|
|
1656
|
+
username: `agent2_${genId(8)}`,
|
|
1657
|
+
displayName: 'Agent 2',
|
|
1658
|
+
description: 'Second test agent',
|
|
1659
|
+
imageUrl: 'https://placehold.co/600x600',
|
|
1660
|
+
},
|
|
1661
|
+
relayerClient,
|
|
1662
|
+
accountProxy: addresses.accountProxy,
|
|
1663
|
+
townsConfig,
|
|
1664
|
+
});
|
|
1665
|
+
const agent2Address = agent2Result.appAddress;
|
|
1666
|
+
// Join agent2 to the channel
|
|
1667
|
+
await bobClient.riverConnection.call((client) => client.joinUser(channelId, agent2Address));
|
|
1668
|
+
// Set forward setting for agent2 (registration already done by createApp)
|
|
1669
|
+
const agent2Id = bin_fromHexString(agent2Address);
|
|
1670
|
+
await appRegistryRpcClient.updateAppSettings({
|
|
1671
|
+
appId: agent2Id,
|
|
1672
|
+
forwardSetting: ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES,
|
|
1673
|
+
updateMask: ['forward_setting'],
|
|
1674
|
+
});
|
|
1675
|
+
const agent2 = await makeTownsApp(agent2Result.appPrivateData, {
|
|
1676
|
+
jwtSecret: agent2Result.jwtSecretBase64,
|
|
1677
|
+
commands: [],
|
|
1678
|
+
});
|
|
1679
|
+
// Start agent2 server on a different port
|
|
1680
|
+
const agent2Port = Number(process.env.BOT_PORT) + 1;
|
|
1681
|
+
const agent2WebhookUrl = `http://localhost:${agent2Port}/webhook`;
|
|
1682
|
+
const app2 = agent2.start();
|
|
1683
|
+
serve({
|
|
1684
|
+
port: agent2Port,
|
|
1685
|
+
fetch: app2.fetch,
|
|
1686
|
+
});
|
|
1687
|
+
await appRegistryRpcClient.registerWebhook({
|
|
1688
|
+
appId: bin_fromHexString(agent2Address),
|
|
1689
|
+
webhookUrl: agent2WebhookUrl,
|
|
1690
|
+
});
|
|
1691
|
+
// Subscribe to messages on agent2
|
|
1692
|
+
const receivedByAgent2 = [];
|
|
1693
|
+
subscriptions.push(agent2.onMessage((_h, e) => {
|
|
1694
|
+
receivedByAgent2.push(e);
|
|
1695
|
+
}));
|
|
1696
|
+
// Agent1 sends a message mentioning Agent2
|
|
1697
|
+
const testMessage = 'Hey @agent2, can you help with this?';
|
|
1698
|
+
const { eventId } = await bot.sendMessage(channelId, testMessage, {
|
|
1699
|
+
mentions: [
|
|
1700
|
+
{
|
|
1701
|
+
userId: agent2Address,
|
|
1702
|
+
displayName: 'Agent 2',
|
|
1703
|
+
},
|
|
1704
|
+
],
|
|
1705
|
+
});
|
|
1706
|
+
// Verify Agent2 received the message from Agent1
|
|
1707
|
+
await waitFor(() => receivedByAgent2.length > 0, { timeoutMS: 15_000 });
|
|
1708
|
+
const receivedMessage = receivedByAgent2.find((x) => x.eventId === eventId);
|
|
1709
|
+
expect(receivedMessage).toBeDefined();
|
|
1710
|
+
expect(receivedMessage?.message).toBe(testMessage);
|
|
1711
|
+
expect(receivedMessage?.userId).toBe(botClientAddress); // Sent by agent1
|
|
1712
|
+
expect(receivedMessage?.isMentioned).toBe(true); // Agent2 was mentioned
|
|
1713
|
+
});
|
|
1714
|
+
it('bot should send conversationSeed and bob should receive it', async () => {
|
|
1715
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1716
|
+
const testSeed = {
|
|
1717
|
+
id: 'seed-001',
|
|
1718
|
+
createdAtMs: BigInt(Date.now()),
|
|
1719
|
+
proposalTimeoutMs: 30000n,
|
|
1720
|
+
conciergeTimeoutMs: 60000n,
|
|
1721
|
+
conciergeId: new Uint8Array(20),
|
|
1722
|
+
version: 1n,
|
|
1723
|
+
query: 'What is the best swap for ETH to USDC?',
|
|
1724
|
+
responseText: 'Here are some options for swapping ETH to USDC.',
|
|
1725
|
+
proposals: [
|
|
1726
|
+
{
|
|
1727
|
+
id: 'prop-001',
|
|
1728
|
+
targetAgentId: new Uint8Array(20),
|
|
1729
|
+
targetAgentName: 'Swap Agent',
|
|
1730
|
+
capabilityName: 'swap',
|
|
1731
|
+
parameters: '{"from":"ETH","to":"USDC"}',
|
|
1732
|
+
explanation: 'Swap ETH to USDC via Uniswap',
|
|
1733
|
+
confidence: 0.95,
|
|
1734
|
+
expiresAtMs: 0n,
|
|
1735
|
+
warnings: [],
|
|
1736
|
+
title: 'Swap ETH to USDC',
|
|
1737
|
+
},
|
|
1738
|
+
],
|
|
1739
|
+
invocations: [],
|
|
1740
|
+
invocationsUpdatedAtMs: 0n,
|
|
1741
|
+
proposalsByAgent: {},
|
|
1742
|
+
selectedProposalIds: [],
|
|
1743
|
+
clarifyingQuestions: ['How much ETH do you want to swap?'],
|
|
1744
|
+
conciergeRespondedAtMs: 0n,
|
|
1745
|
+
statusUpdates: [],
|
|
1746
|
+
userTimezone: 'America/New_York',
|
|
1747
|
+
conciergeFailedAtMs: 0n,
|
|
1748
|
+
};
|
|
1749
|
+
const { eventId } = await bot.sendConversationSeed(channelId, testSeed);
|
|
1750
|
+
await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
|
|
1751
|
+
const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
|
|
1752
|
+
expect(message?.content?.kind).toBe(RiverTimelineEvent.ConversationSeed);
|
|
1753
|
+
});
|
|
1754
|
+
it('bot should receive conversationSeedResponse sent by bob', async () => {
|
|
1755
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1756
|
+
const receivedResponses = [];
|
|
1757
|
+
subscriptions.push(bot.onConversationSeedResponse((_h, e) => {
|
|
1758
|
+
receivedResponses.push(e);
|
|
1759
|
+
}));
|
|
1760
|
+
// Bob sends a conversationSeedResponse with a userMessage
|
|
1761
|
+
const { eventId } = await bobClient.riverConnection.call((client) => client.sendChannelMessage_ConversationSeedResponse(channelId, {
|
|
1762
|
+
content: {
|
|
1763
|
+
seedId: 'seed-001',
|
|
1764
|
+
response: {
|
|
1765
|
+
case: 'userMessage',
|
|
1766
|
+
value: 'I want to swap 1 ETH',
|
|
1767
|
+
},
|
|
1768
|
+
},
|
|
1769
|
+
}));
|
|
1770
|
+
await waitFor(() => receivedResponses.length > 0);
|
|
1771
|
+
const event = receivedResponses.find((x) => x.eventId === eventId);
|
|
1772
|
+
expect(event).toBeDefined();
|
|
1773
|
+
expect(event?.seedId).toBe('seed-001');
|
|
1774
|
+
expect(event?.seed).toBeUndefined();
|
|
1775
|
+
expect(event?.response.case).toBe('userMessage');
|
|
1776
|
+
expect(event?.response.value).toBe('I want to swap 1 ETH');
|
|
1777
|
+
});
|
|
1778
|
+
it('bot should receive conversationSeedResponse with selectedProposal', async () => {
|
|
1779
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
1780
|
+
const receivedResponses = [];
|
|
1781
|
+
subscriptions.push(bot.onConversationSeedResponse((_h, e) => {
|
|
1782
|
+
receivedResponses.push(e);
|
|
1783
|
+
}));
|
|
1784
|
+
// Bob sends a conversationSeedResponse selecting a proposal
|
|
1785
|
+
const { eventId } = await bobClient.riverConnection.call((client) => client.sendChannelMessage_ConversationSeedResponse(channelId, {
|
|
1786
|
+
content: {
|
|
1787
|
+
seedId: 'seed-001',
|
|
1788
|
+
response: {
|
|
1789
|
+
case: 'selectedProposal',
|
|
1790
|
+
value: {
|
|
1791
|
+
id: 'prop-001',
|
|
1792
|
+
targetAgentId: new Uint8Array(20),
|
|
1793
|
+
targetAgentName: 'Swap Agent',
|
|
1794
|
+
capabilityName: 'swap',
|
|
1795
|
+
parameters: '{"from":"ETH","to":"USDC"}',
|
|
1796
|
+
explanation: 'Swap ETH to USDC via Uniswap',
|
|
1797
|
+
confidence: 0.95,
|
|
1798
|
+
expiresAtMs: 0n,
|
|
1799
|
+
warnings: [],
|
|
1800
|
+
title: 'Swap ETH to USDC',
|
|
1801
|
+
},
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
}));
|
|
1805
|
+
await waitFor(() => receivedResponses.length > 0);
|
|
1806
|
+
const event = receivedResponses.find((x) => x.eventId === eventId);
|
|
1807
|
+
expect(event).toBeDefined();
|
|
1808
|
+
expect(event?.seedId).toBe('seed-001');
|
|
1809
|
+
expect(event?.response.case).toBe('selectedProposal');
|
|
1810
|
+
if (event?.response.case === 'selectedProposal') {
|
|
1811
|
+
expect(event.response.value.id).toBe('prop-001');
|
|
1812
|
+
expect(event.response.value.targetAgentName).toBe('Swap Agent');
|
|
1813
|
+
expect(event.response.value.capabilityName).toBe('swap');
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
it.skip('bot should handle proposals request and return proposals', async () => {
|
|
1817
|
+
const unsub = bot.onCapability('swap', (_handler, event) => {
|
|
1818
|
+
expect(event.capabilityName).toBe('swap');
|
|
1819
|
+
expect(event.userQuery).toBe('Swap 1 ETH to USDC');
|
|
1820
|
+
return [
|
|
1821
|
+
{
|
|
1822
|
+
title: 'Swap ETH to USDC',
|
|
1823
|
+
explanation: 'Swap 1 ETH for ~2000 USDC via Uniswap',
|
|
1824
|
+
confidence: 0.95,
|
|
1825
|
+
parameters: JSON.stringify({ from: 'ETH', to: 'USDC', amount: '1' }),
|
|
1826
|
+
warnings: ['Slippage may apply'],
|
|
1827
|
+
expectedOutcome: '~2000 USDC',
|
|
1828
|
+
},
|
|
1829
|
+
];
|
|
1830
|
+
});
|
|
1831
|
+
subscriptions.push(unsub);
|
|
1832
|
+
const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
|
|
1833
|
+
query: 'Swap 1 ETH to USDC',
|
|
1834
|
+
});
|
|
1835
|
+
const resp = await appRegistryRpcClient.getBulkAgentProposals({
|
|
1836
|
+
conversationSeedId,
|
|
1837
|
+
requests: [
|
|
1838
|
+
{
|
|
1839
|
+
agentId: bin_fromHexString(botClientAddress),
|
|
1840
|
+
capabilityName: 'swap',
|
|
1841
|
+
parameters: '{"from":"ETH","to":"USDC","amount":"1"}',
|
|
1842
|
+
},
|
|
1843
|
+
],
|
|
1844
|
+
});
|
|
1845
|
+
expect(resp.accepted).toBe(true);
|
|
1846
|
+
});
|
|
1847
|
+
it.skip('bot should return accepted for unknown capability in proposals request', async () => {
|
|
1848
|
+
const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
|
|
1849
|
+
query: 'Do something unknown',
|
|
1850
|
+
});
|
|
1851
|
+
const resp = await appRegistryRpcClient.getBulkAgentProposals({
|
|
1852
|
+
conversationSeedId,
|
|
1853
|
+
requests: [
|
|
1854
|
+
{
|
|
1855
|
+
agentId: bin_fromHexString(botClientAddress),
|
|
1856
|
+
capabilityName: 'nonexistent_capability',
|
|
1857
|
+
parameters: '{}',
|
|
1858
|
+
},
|
|
1859
|
+
],
|
|
1860
|
+
});
|
|
1861
|
+
expect(resp.accepted).toBe(true);
|
|
1862
|
+
});
|
|
1863
|
+
it.skip('bot should return multiple proposals from a single capability handler', async () => {
|
|
1864
|
+
const unsub = bot.onCapability('multi_swap', (_handler, _event) => {
|
|
1865
|
+
return [
|
|
1866
|
+
{
|
|
1867
|
+
title: 'Uniswap Direct',
|
|
1868
|
+
explanation: 'Route A: Uniswap direct',
|
|
1869
|
+
confidence: 0.9,
|
|
1870
|
+
parameters: '{"route":"uniswap"}',
|
|
1871
|
+
},
|
|
1872
|
+
{
|
|
1873
|
+
title: '1inch Aggregator',
|
|
1874
|
+
explanation: 'Route B: 1inch aggregator',
|
|
1875
|
+
confidence: 0.85,
|
|
1876
|
+
parameters: '{"route":"1inch"}',
|
|
1877
|
+
},
|
|
1878
|
+
];
|
|
1879
|
+
});
|
|
1880
|
+
subscriptions.push(unsub);
|
|
1881
|
+
const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
|
|
1882
|
+
query: 'Best route for ETH to USDC',
|
|
1883
|
+
});
|
|
1884
|
+
const resp = await appRegistryRpcClient.getBulkAgentProposals({
|
|
1885
|
+
conversationSeedId,
|
|
1886
|
+
requests: [
|
|
1887
|
+
{
|
|
1888
|
+
agentId: bin_fromHexString(botClientAddress),
|
|
1889
|
+
capabilityName: 'multi_swap',
|
|
1890
|
+
parameters: '{}',
|
|
1891
|
+
},
|
|
1892
|
+
],
|
|
1893
|
+
});
|
|
1894
|
+
expect(resp.accepted).toBe(true);
|
|
1895
|
+
});
|
|
1896
|
+
it('bot should handle positions request and return positions via webhook', async () => {
|
|
1897
|
+
const unsub = bot.onPositions((_handler, event) => {
|
|
1898
|
+
expect(event.userId).toBeDefined();
|
|
1899
|
+
return {
|
|
1900
|
+
supported: true,
|
|
1901
|
+
positions: [
|
|
1902
|
+
{
|
|
1903
|
+
label: 'ETH-PERP Long',
|
|
1904
|
+
symbol: 'ETH',
|
|
1905
|
+
type: PositionType.PERPETUAL,
|
|
1906
|
+
balanceUsd: '5000.00',
|
|
1907
|
+
pnlUsd: '+250.00',
|
|
1908
|
+
description: '10x leverage',
|
|
1909
|
+
},
|
|
1910
|
+
],
|
|
1911
|
+
timestamp: BigInt(Date.now()),
|
|
1912
|
+
};
|
|
1913
|
+
});
|
|
1914
|
+
subscriptions.push(unsub);
|
|
1915
|
+
// Enable positions API so app registry will forward positions requests
|
|
1916
|
+
const posAppId = bin_fromHexString(botClientAddress);
|
|
1917
|
+
await appRegistryRpcClient.updateAppSettings({
|
|
1918
|
+
appId: posAppId,
|
|
1919
|
+
supportedApis: [SupportedApi.POSITIONS],
|
|
1920
|
+
updateMask: ['supported_apis'],
|
|
1921
|
+
});
|
|
1922
|
+
// Request positions through app registry (which proxies to bot webhook)
|
|
1923
|
+
const result = await appRegistryRpcClient.getAppPositions({
|
|
1924
|
+
appId: bin_fromHexString(botClientAddress),
|
|
1925
|
+
});
|
|
1926
|
+
expect(result.positions).toBeDefined();
|
|
1927
|
+
expect(result.positions.supported).toBe(true);
|
|
1928
|
+
expect(result.positions.positions).toHaveLength(1);
|
|
1929
|
+
expect(result.positions.positions[0].label).toBe('ETH-PERP Long');
|
|
1930
|
+
expect(result.positions.positions[0].symbol).toBe('ETH');
|
|
1931
|
+
expect(result.positions.positions[0].balanceUsd).toBe('5000.00');
|
|
1932
|
+
expect(result.positions.positions[0].pnlUsd).toBe('+250.00');
|
|
1933
|
+
});
|
|
1934
|
+
it('getBulkAgentCapabilities should return capabilities for registered bot', async () => {
|
|
1935
|
+
// Register a capability on the bot
|
|
1936
|
+
const unsub = bot.onCapability('e2e_swap', (_handler, _event) => {
|
|
1937
|
+
return [
|
|
1938
|
+
{
|
|
1939
|
+
title: 'Test Swap',
|
|
1940
|
+
explanation: 'test',
|
|
1941
|
+
confidence: 1.0,
|
|
1942
|
+
parameters: '{}',
|
|
1943
|
+
},
|
|
1944
|
+
];
|
|
1945
|
+
});
|
|
1946
|
+
subscriptions.push(unsub);
|
|
1947
|
+
// Update capabilities via the app registry
|
|
1948
|
+
await appRegistryRpcClient.updateAppMetadata({
|
|
1949
|
+
appId: bin_fromHexString(botClientAddress),
|
|
1950
|
+
metadata: {
|
|
1951
|
+
capabilities: [
|
|
1952
|
+
{
|
|
1953
|
+
name: 'e2e_swap',
|
|
1954
|
+
description: 'Swap tokens',
|
|
1955
|
+
inputSchema: '{"type":"object"}',
|
|
1956
|
+
examples: [{ userQuery: 'swap ETH', parameters: '{}' }],
|
|
1957
|
+
},
|
|
1958
|
+
],
|
|
1959
|
+
},
|
|
1960
|
+
updateMask: ['capabilities'],
|
|
1961
|
+
});
|
|
1962
|
+
const resp = await appRegistryRpcClient.getBulkAgentCapabilities({
|
|
1963
|
+
agentIds: [bin_fromHexString(botClientAddress)],
|
|
1964
|
+
});
|
|
1965
|
+
expect(resp.manifests.length).toBe(1);
|
|
1966
|
+
const manifest = resp.manifests[0];
|
|
1967
|
+
expect(manifest.agentName).toBeTruthy();
|
|
1968
|
+
expect(manifest.capabilities.length).toBe(1);
|
|
1969
|
+
expect(manifest.capabilities[0].name).toBe('e2e_swap');
|
|
1970
|
+
expect(manifest.capabilities[0].description).toBe('Swap tokens');
|
|
1971
|
+
expect(manifest.capabilities[0].inputSchema).toBe('{"type":"object"}');
|
|
1972
|
+
});
|
|
1973
|
+
it.skip('getBulkAgentProposals should fan out to bot and return proposals', async () => {
|
|
1974
|
+
const unsub = bot.onCapability('e2e_propose', (_handler, event) => {
|
|
1975
|
+
expect(event.userQuery).toBe('What can you do?');
|
|
1976
|
+
return [
|
|
1977
|
+
{
|
|
1978
|
+
title: 'Demo Action',
|
|
1979
|
+
explanation: 'I can do many things',
|
|
1980
|
+
confidence: 0.9,
|
|
1981
|
+
parameters: JSON.stringify({ action: 'demo' }),
|
|
1982
|
+
warnings: ['This is a test'],
|
|
1983
|
+
},
|
|
1984
|
+
];
|
|
1985
|
+
});
|
|
1986
|
+
subscriptions.push(unsub);
|
|
1987
|
+
// Update capabilities so the bot is discoverable
|
|
1988
|
+
await appRegistryRpcClient.updateAppMetadata({
|
|
1989
|
+
appId: bin_fromHexString(botClientAddress),
|
|
1990
|
+
metadata: {
|
|
1991
|
+
capabilities: [
|
|
1992
|
+
{
|
|
1993
|
+
name: 'e2e_propose',
|
|
1994
|
+
description: 'Demo capability',
|
|
1995
|
+
inputSchema: '{"type":"object"}',
|
|
1996
|
+
examples: [],
|
|
1997
|
+
},
|
|
1998
|
+
],
|
|
1999
|
+
},
|
|
2000
|
+
updateMask: ['capabilities'],
|
|
2001
|
+
});
|
|
2002
|
+
const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
|
|
2003
|
+
query: 'What can you do?',
|
|
2004
|
+
});
|
|
2005
|
+
const resp = await appRegistryRpcClient.getBulkAgentProposals({
|
|
2006
|
+
conversationSeedId,
|
|
2007
|
+
requests: [
|
|
2008
|
+
{
|
|
2009
|
+
agentId: bin_fromHexString(botClientAddress),
|
|
2010
|
+
capabilityName: 'e2e_propose',
|
|
2011
|
+
parameters: '{"action":"demo"}',
|
|
2012
|
+
},
|
|
2013
|
+
],
|
|
2014
|
+
});
|
|
2015
|
+
expect(resp.accepted).toBe(true);
|
|
2016
|
+
});
|
|
2017
|
+
it.skip('getBulkAgentProposals should return accepted for unknown capability', async () => {
|
|
2018
|
+
const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
|
|
2019
|
+
query: 'Test unknown',
|
|
2020
|
+
});
|
|
2021
|
+
const resp = await appRegistryRpcClient.getBulkAgentProposals({
|
|
2022
|
+
conversationSeedId,
|
|
2023
|
+
requests: [
|
|
2024
|
+
{
|
|
2025
|
+
agentId: bin_fromHexString(botClientAddress),
|
|
2026
|
+
capabilityName: 'nonexistent_capability',
|
|
2027
|
+
parameters: '{}',
|
|
2028
|
+
},
|
|
2029
|
+
],
|
|
2030
|
+
});
|
|
2031
|
+
expect(resp.accepted).toBe(true);
|
|
2032
|
+
});
|
|
2033
|
+
it('agent should participate in key exchange', async () => {
|
|
2034
|
+
await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
|
|
2035
|
+
// create a new gdm with bob and bot
|
|
2036
|
+
const { streamId: gdmId } = await bobClient.gdms.createGDM([bot.agentUserId]);
|
|
2037
|
+
await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
|
|
2038
|
+
const gdm = bobClient.gdms.getGdm(gdmId);
|
|
2039
|
+
// bob sends a message "hello world this is bob"
|
|
2040
|
+
const { eventId: messageId } = await gdm.sendMessage('hello world this is bob');
|
|
2041
|
+
// bob creates an invite link
|
|
2042
|
+
const { appRegistryRpcClient } = await AppRegistryService.authenticate(bobClient.riverConnection.signerContext, townsEnv().getAppRegistryUrl());
|
|
2043
|
+
const createResponse = await appRegistryRpcClient.createInviteLink({
|
|
2044
|
+
streamId: streamIdAsBytes(gdmId),
|
|
2045
|
+
inviteEncryptionData: await bobClient.riverConnection.call((client) => client.exportInviteEncryptionData(gdmId)),
|
|
2046
|
+
});
|
|
2047
|
+
const inviteCode = createResponse.inviteCode;
|
|
2048
|
+
expect(inviteCode).toBeDefined();
|
|
2049
|
+
expect(inviteCode.length).toBeGreaterThan(0);
|
|
2050
|
+
// bob leaves the gdm
|
|
2051
|
+
await gdm.leave();
|
|
2052
|
+
// delete the keys from the bot to simulate a server restart
|
|
2053
|
+
await bot['client'].crypto.cryptoStore.deleteHybridGroupSessions(gdmId);
|
|
2054
|
+
// alice joins the gdm
|
|
2055
|
+
const { appRegistryRpcClient: aliceAppRegistryRpcClient } = await AppRegistryService.authenticate(aliceClient.riverConnection.signerContext, townsEnv().getAppRegistryUrl());
|
|
2056
|
+
const redeemResponse = await aliceAppRegistryRpcClient.redeemInviteLink({ inviteCode });
|
|
2057
|
+
// NOTE - we redeemed the invite link, but we didn't import the encryption data into the bot
|
|
2058
|
+
// forcing alice to use key exchange to decrypt the message
|
|
2059
|
+
redeemResponse.inviteEncryptionData = undefined; // clear it to force key exchange
|
|
2060
|
+
log('joining gdm', gdmId);
|
|
2061
|
+
const stream = await aliceClient.joinStream(gdmId, {
|
|
2062
|
+
redeemedInvite: redeemResponse,
|
|
2063
|
+
});
|
|
2064
|
+
log('stream', stream);
|
|
2065
|
+
// alice sees the message decrypted in the gdm
|
|
2066
|
+
const aliceGdm = aliceClient.gdms.getGdm(gdmId);
|
|
2067
|
+
await waitFor(() => expect(aliceGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage));
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
//# sourceMappingURL=app.test.js.map
|