@towns-labs/agent 2.0.1
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 +151 -0
- package/dist/agent.d.ts +480 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +1758 -0
- package/dist/agent.js.map +1 -0
- package/dist/agent.test.d.ts +2 -0
- package/dist/agent.test.d.ts.map +1 -0
- package/dist/agent.test.js +1315 -0
- package/dist/agent.test.js.map +1 -0
- package/dist/eventDedup.d.ts +73 -0
- package/dist/eventDedup.d.ts.map +1 -0
- package/dist/eventDedup.js +105 -0
- package/dist/eventDedup.js.map +1 -0
- package/dist/eventDedup.test.d.ts +2 -0
- package/dist/eventDedup.test.d.ts.map +1 -0
- package/dist/eventDedup.test.js +222 -0
- package/dist/eventDedup.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 +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction-api.d.ts +61 -0
- package/dist/interaction-api.d.ts.map +1 -0
- package/dist/interaction-api.js +95 -0
- package/dist/interaction-api.js.map +1 -0
- package/dist/payments.d.ts +89 -0
- package/dist/payments.d.ts.map +1 -0
- package/dist/payments.js +144 -0
- package/dist/payments.js.map +1 -0
- package/dist/re-exports.d.ts +2 -0
- package/dist/re-exports.d.ts.map +1 -0
- package/dist/re-exports.js +2 -0
- package/dist/re-exports.js.map +1 -0
- package/dist/smart-account.d.ts +54 -0
- package/dist/smart-account.d.ts.map +1 -0
- package/dist/smart-account.js +132 -0
- package/dist/smart-account.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 +67 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,1758 @@
|
|
|
1
|
+
import { create, fromBinary, fromJsonString, toBinary } from '@bufbuild/protobuf';
|
|
2
|
+
import { utils, ethers } from 'ethers';
|
|
3
|
+
import { SpaceDapp, SpaceAddressFromSpaceId, TipRecipientType, ETH_ADDRESS, } from '@towns-labs/web3';
|
|
4
|
+
import { stringify as superjsonStringify, parse as superjsonParse } from 'superjson';
|
|
5
|
+
import tippingFacetAbi from '@towns-labs/generated/dev/abis/ITipping.abi';
|
|
6
|
+
import { getRefEventIdFromChannelMessage, isChannelStreamId, make_ChannelPayload_Message, createTownsClient, streamIdAsString, make_MemberPayload_KeySolicitation, make_UserMetadataPayload_EncryptionDevice, logNever, userIdFromAddress, makeUserMetadataStreamId, unsafe_makeTags, townsEnv, spaceIdFromChannelId, parseAppPrivateData, makeEvent, make_MediaPayload_Inception, make_MediaPayload_Chunk, makeUniqueMediaStreamId, streamIdAsBytes, addressFromUserId, make_payload_InteractionRequest, userIdToAddress, unpackEnvelope, make_UserPayload_BlockchainTransaction, makeUserStreamId, make_MemberPayload_Pin, make_MemberPayload_Unpin, isDMChannelStreamId, isGDMChannelStreamId, make_DMChannelPayload_Message, make_GDMChannelPayload_Message, } from '@towns-labs/sdk';
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { logger } from 'hono/logger';
|
|
9
|
+
import { createMiddleware } from 'hono/factory';
|
|
10
|
+
import { default as jwt } from 'jsonwebtoken';
|
|
11
|
+
import { createNanoEvents } from 'nanoevents';
|
|
12
|
+
import imageSize from 'image-size';
|
|
13
|
+
import { ChannelMessageSchema, AppServiceRequestSchema, AppServiceResponseSchema, MembershipOp, MessageInteractionType, ChannelMessage_Post_Content_ImageSchema, ChannelMessage_Post_Content_Image_InfoSchema, ChunkedMediaSchema, CreationCookieSchema, BlockchainTransactionSchema, InteractionRequestPayloadSchema, InteractionResponsePayloadSchema, ChannelMessage_Post_AttachmentSchema, ChannelMessage_Post_MentionSchema, } from '@towns-labs/proto';
|
|
14
|
+
import { bin_equal, bin_fromBase64, bin_fromHexString, bin_toHexString, check, dlog, } from '@towns-labs/utils';
|
|
15
|
+
import { encryptChunkedAESGCM } from '@towns-labs/sdk-crypto';
|
|
16
|
+
import { EventDedup } from './eventDedup';
|
|
17
|
+
import { isFlattenedRequest, flattenedToPayloadContent, } from './interaction-api';
|
|
18
|
+
import { chainIdToNetwork, createPaymentRequest } from './payments';
|
|
19
|
+
import { useFacilitator } from 'x402/verify';
|
|
20
|
+
import { http, createWalletClient, encodeAbiParameters, zeroAddress, parseEventLogs, formatUnits, erc20Abi, } from 'viem';
|
|
21
|
+
import { readContract, waitForTransactionReceipt, writeContract } from 'viem/actions';
|
|
22
|
+
import { base, baseSepolia, foundry } from 'viem/chains';
|
|
23
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
24
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
25
|
+
import appRegistryAbi from '@towns-labs/generated/dev/abis/IAppRegistry.abi';
|
|
26
|
+
import { getSmartAccountFromUserIdImpl } from './smart-account';
|
|
27
|
+
import { EmptySchema } from '@bufbuild/protobuf/wkt';
|
|
28
|
+
const debug = dlog('csb:agent');
|
|
29
|
+
export class Agent {
|
|
30
|
+
client;
|
|
31
|
+
appAddress;
|
|
32
|
+
agentUserId;
|
|
33
|
+
viem;
|
|
34
|
+
jwtSecret;
|
|
35
|
+
currentMessageTags;
|
|
36
|
+
emitter = createNanoEvents();
|
|
37
|
+
slashCommandHandlers = new Map();
|
|
38
|
+
gmTypedHandlers = new Map();
|
|
39
|
+
commands;
|
|
40
|
+
identityConfig;
|
|
41
|
+
eventDedup;
|
|
42
|
+
// Payment related members
|
|
43
|
+
paymentConfig;
|
|
44
|
+
pendingPayments = new Map();
|
|
45
|
+
paymentCommands = new Map();
|
|
46
|
+
constructor(clientV2, viem, jwtSecretBase64, appAddress, commands, identityConfig, dedupConfig, paymentConfig) {
|
|
47
|
+
this.client = clientV2;
|
|
48
|
+
this.agentUserId = clientV2.userId;
|
|
49
|
+
this.viem = viem;
|
|
50
|
+
this.jwtSecret = bin_fromBase64(jwtSecretBase64);
|
|
51
|
+
this.currentMessageTags = undefined;
|
|
52
|
+
this.commands = commands;
|
|
53
|
+
this.appAddress = appAddress;
|
|
54
|
+
this.identityConfig = identityConfig;
|
|
55
|
+
this.eventDedup = new EventDedup(dedupConfig);
|
|
56
|
+
this.paymentConfig = paymentConfig;
|
|
57
|
+
if (commands && paymentConfig) {
|
|
58
|
+
for (const cmd of commands) {
|
|
59
|
+
if (cmd.paid?.price) {
|
|
60
|
+
this.paymentCommands.set(cmd.name, cmd.paid);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.onInteractionResponse(this.handlePaymentResponse.bind(this));
|
|
65
|
+
}
|
|
66
|
+
start() {
|
|
67
|
+
const jwtMiddleware = createMiddleware(this.jwtMiddleware.bind(this));
|
|
68
|
+
const handler = this.webhookHandler.bind(this);
|
|
69
|
+
const app = new Hono();
|
|
70
|
+
app.use(logger());
|
|
71
|
+
app.post('/webhook', jwtMiddleware, handler);
|
|
72
|
+
app.get('/.well-known/agent-metadata.json', async (c) => {
|
|
73
|
+
return c.json(await this.getIdentityMetadata());
|
|
74
|
+
});
|
|
75
|
+
debug('init');
|
|
76
|
+
return app;
|
|
77
|
+
}
|
|
78
|
+
async jwtMiddleware(c, next) {
|
|
79
|
+
const authHeader = c.req.header('Authorization');
|
|
80
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
81
|
+
return c.text('Unauthorized: Missing or malformed token', 401);
|
|
82
|
+
}
|
|
83
|
+
const tokenString = authHeader.substring(7);
|
|
84
|
+
try {
|
|
85
|
+
const agentAddressBytes = bin_fromHexString(this.agentUserId);
|
|
86
|
+
const expectedAudience = bin_toHexString(agentAddressBytes);
|
|
87
|
+
jwt.verify(tokenString, Buffer.from(this.jwtSecret), {
|
|
88
|
+
algorithms: ['HS256'],
|
|
89
|
+
audience: expectedAudience,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
let errorMessage = 'Unauthorized: Token verification failed';
|
|
94
|
+
if (err instanceof jwt.TokenExpiredError) {
|
|
95
|
+
errorMessage = 'Unauthorized: Token expired';
|
|
96
|
+
}
|
|
97
|
+
else if (err instanceof jwt.JsonWebTokenError) {
|
|
98
|
+
errorMessage = `Unauthorized: Invalid token (${err.message})`;
|
|
99
|
+
}
|
|
100
|
+
return c.text(errorMessage, 401);
|
|
101
|
+
}
|
|
102
|
+
await next();
|
|
103
|
+
}
|
|
104
|
+
async webhookHandler(c) {
|
|
105
|
+
const body = await c.req.arrayBuffer();
|
|
106
|
+
const encryptionDevice = this.client.crypto.getUserDevice();
|
|
107
|
+
const request = fromBinary(AppServiceRequestSchema, new Uint8Array(body));
|
|
108
|
+
debug('webhook', request);
|
|
109
|
+
const statusResponse = create(AppServiceResponseSchema, {
|
|
110
|
+
payload: {
|
|
111
|
+
case: 'status',
|
|
112
|
+
value: {
|
|
113
|
+
frameworkVersion: 1,
|
|
114
|
+
clientVersion: `javascript:${packageJson.name}:${packageJson.version}`,
|
|
115
|
+
deviceKey: encryptionDevice.deviceKey,
|
|
116
|
+
fallbackKey: encryptionDevice.fallbackKey,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
let response = statusResponse;
|
|
121
|
+
if (request.payload.case === 'initialize') {
|
|
122
|
+
response = create(AppServiceResponseSchema, {
|
|
123
|
+
payload: {
|
|
124
|
+
case: 'initialize',
|
|
125
|
+
value: {
|
|
126
|
+
encryptionDevice,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else if (request.payload.case === 'events') {
|
|
132
|
+
for (const event of request.payload.value.events) {
|
|
133
|
+
try {
|
|
134
|
+
await this.handleEvent(event);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.error('[@towns-labs/agent] Error while handling event', err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
response = statusResponse;
|
|
142
|
+
}
|
|
143
|
+
else if (request.payload.case === 'status') {
|
|
144
|
+
response = statusResponse;
|
|
145
|
+
}
|
|
146
|
+
c.header('Content-Type', 'application/x-protobuf');
|
|
147
|
+
return c.body(toBinary(AppServiceResponseSchema, response), 200);
|
|
148
|
+
}
|
|
149
|
+
async handleEvent(appEvent) {
|
|
150
|
+
if (!appEvent.payload.case || !appEvent.payload.value)
|
|
151
|
+
return;
|
|
152
|
+
const streamId = streamIdAsString(appEvent.payload.value.streamId);
|
|
153
|
+
if (appEvent.payload.case === 'messages') {
|
|
154
|
+
const groupEncryptionSessionsMessages = await this.client
|
|
155
|
+
.unpackEnvelopes(appEvent.payload.value.groupEncryptionSessionsMessages)
|
|
156
|
+
.then((x) => x.flatMap((e) => {
|
|
157
|
+
if (e.event.payload.case === 'userInboxPayload' &&
|
|
158
|
+
e.event.payload.value.content.case === 'groupEncryptionSessions') {
|
|
159
|
+
return e.event.payload.value.content.value;
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}));
|
|
163
|
+
const events = await this.client.unpackEnvelopes(appEvent.payload.value.messages);
|
|
164
|
+
const zip = events.map((m, i) => [m, groupEncryptionSessionsMessages[i]]);
|
|
165
|
+
for (const [parsed, groupEncryptionSession] of zip) {
|
|
166
|
+
if (parsed.creatorUserId === this.client.userId) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!parsed.event.payload.case) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Skip duplicate events (App Registry may replay events during restarts)
|
|
173
|
+
if (this.eventDedup.checkAndAdd(streamId, parsed.hashStr)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
|
|
177
|
+
this.currentMessageTags = parsed.event.tags;
|
|
178
|
+
debug('emit:streamEvent', {
|
|
179
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
180
|
+
channelId: streamId,
|
|
181
|
+
eventId: parsed.hashStr,
|
|
182
|
+
});
|
|
183
|
+
this.emitter.emit('streamEvent', this.client, {
|
|
184
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
185
|
+
parsed: parsed,
|
|
186
|
+
});
|
|
187
|
+
switch (parsed.event.payload.case) {
|
|
188
|
+
case 'channelPayload':
|
|
189
|
+
case 'dmChannelPayload':
|
|
190
|
+
case 'gdmChannelPayload': {
|
|
191
|
+
if (!parsed.event.payload.value.content.case)
|
|
192
|
+
return;
|
|
193
|
+
if (parsed.event.payload.value.content.case === 'message') {
|
|
194
|
+
await this.client.importGroupEncryptionSessions({
|
|
195
|
+
streamId,
|
|
196
|
+
sessions: groupEncryptionSession,
|
|
197
|
+
});
|
|
198
|
+
const eventCleartext = await this.client.crypto.decryptGroupEvent(streamId, parsed.event.payload.value.content.value);
|
|
199
|
+
let channelMessage;
|
|
200
|
+
if (typeof eventCleartext === 'string') {
|
|
201
|
+
channelMessage = fromJsonString(ChannelMessageSchema, eventCleartext);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
channelMessage = fromBinary(ChannelMessageSchema, eventCleartext);
|
|
205
|
+
}
|
|
206
|
+
await this.handleChannelMessage(streamId, parsed, channelMessage);
|
|
207
|
+
}
|
|
208
|
+
else if (parsed.event.payload.value.content.case === 'redaction') {
|
|
209
|
+
const refEventId = bin_toHexString(parsed.event.payload.value.content.value.eventId);
|
|
210
|
+
debug('emit:eventRevoke', {
|
|
211
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
212
|
+
channelId: streamId,
|
|
213
|
+
refEventId,
|
|
214
|
+
});
|
|
215
|
+
this.emitter.emit('eventRevoke', this.client, {
|
|
216
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
217
|
+
refEventId,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (parsed.event.payload.value.content.case === 'channelProperties') {
|
|
221
|
+
// TODO: currently, no support for channel properties (update name, topic)
|
|
222
|
+
}
|
|
223
|
+
else if (parsed.event.payload.value.content.case === 'inception') {
|
|
224
|
+
// TODO: is there any use case for this?
|
|
225
|
+
}
|
|
226
|
+
else if (parsed.event.payload.value.content.case === 'custom') {
|
|
227
|
+
// TODO: what to do with custom payload for agent?
|
|
228
|
+
}
|
|
229
|
+
else if (parsed.event.payload.value.content.case === 'interactionRequest') {
|
|
230
|
+
// ignored for agentss
|
|
231
|
+
}
|
|
232
|
+
else if (parsed.event.payload.value.content.case === 'interactionResponse') {
|
|
233
|
+
const payload = parsed.event.payload.value.content.value;
|
|
234
|
+
if (!bin_equal(payload.recipient, bin_fromHexString(this.agentUserId))) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!payload.encryptedData) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (payload.encryptedData.deviceKey !== this.getUserDevice().deviceKey) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const decryptedBase64 = await this.client.crypto.decryptWithDeviceKey(payload.encryptedData.ciphertext, payload.encryptedData.senderKey);
|
|
244
|
+
const decrypted = bin_fromBase64(decryptedBase64);
|
|
245
|
+
const response = fromBinary(InteractionResponsePayloadSchema, decrypted);
|
|
246
|
+
this.emitter.emit('interactionResponse', this.client, {
|
|
247
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
248
|
+
response: {
|
|
249
|
+
recipient: payload.recipient,
|
|
250
|
+
payload: response,
|
|
251
|
+
},
|
|
252
|
+
threadId: payload.threadId
|
|
253
|
+
? bin_toHexString(payload.threadId)
|
|
254
|
+
: undefined,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
logNever(parsed.event.payload.value.content);
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case 'memberPayload': {
|
|
263
|
+
switch (parsed.event.payload.value.content.case) {
|
|
264
|
+
case 'membership':
|
|
265
|
+
{
|
|
266
|
+
const membership = parsed.event.payload.value.content.value;
|
|
267
|
+
const isChannel = isChannelStreamId(streamId);
|
|
268
|
+
// TODO: do we want agent to listen to onSpaceJoin/onSpaceLeave?
|
|
269
|
+
if (!isChannel)
|
|
270
|
+
continue;
|
|
271
|
+
if (membership.op === MembershipOp.SO_JOIN) {
|
|
272
|
+
debug('emit:channelJoin', {
|
|
273
|
+
userId: userIdFromAddress(membership.userAddress),
|
|
274
|
+
channelId: streamId,
|
|
275
|
+
eventId: parsed.hashStr,
|
|
276
|
+
});
|
|
277
|
+
this.emitter.emit('channelJoin', this.client, {
|
|
278
|
+
...createBasePayload(userIdFromAddress(membership.userAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (membership.op === MembershipOp.SO_LEAVE) {
|
|
282
|
+
debug('emit:channelLeave', {
|
|
283
|
+
userId: userIdFromAddress(membership.userAddress),
|
|
284
|
+
channelId: streamId,
|
|
285
|
+
eventId: parsed.hashStr,
|
|
286
|
+
});
|
|
287
|
+
this.emitter.emit('channelLeave', this.client, {
|
|
288
|
+
...createBasePayload(userIdFromAddress(membership.userAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
case 'memberBlockchainTransaction':
|
|
294
|
+
{
|
|
295
|
+
const transactionContent = parsed.event.payload.value.content.value.transaction
|
|
296
|
+
?.content;
|
|
297
|
+
const fromUserAddress = parsed.event.payload.value.content.value.fromUserAddress;
|
|
298
|
+
switch (transactionContent?.case) {
|
|
299
|
+
case 'spaceReview':
|
|
300
|
+
break;
|
|
301
|
+
case 'tokenTransfer':
|
|
302
|
+
break;
|
|
303
|
+
case 'tip':
|
|
304
|
+
{
|
|
305
|
+
const tipEvent = transactionContent.value.event;
|
|
306
|
+
if (!tipEvent) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const currency = utils.getAddress(bin_toHexString(tipEvent.currency));
|
|
310
|
+
const senderAddress = utils.getAddress(bin_toHexString(tipEvent.sender));
|
|
311
|
+
const receiverAddress = utils.getAddress(bin_toHexString(tipEvent.receiver));
|
|
312
|
+
const senderUserId = userIdFromAddress(fromUserAddress);
|
|
313
|
+
const receiverUserId = userIdFromAddress(transactionContent.value.toUserAddress);
|
|
314
|
+
debug('emit:tip', {
|
|
315
|
+
senderAddress,
|
|
316
|
+
senderUserId,
|
|
317
|
+
receiverAddress,
|
|
318
|
+
receiverUserId,
|
|
319
|
+
amount: tipEvent.amount.toString(),
|
|
320
|
+
currency,
|
|
321
|
+
messageId: bin_toHexString(tipEvent.messageId),
|
|
322
|
+
});
|
|
323
|
+
this.emitter.emit('tip', this.client, {
|
|
324
|
+
...createBasePayload(senderUserId, streamId, parsed.hashStr, createdAt, parsed.event),
|
|
325
|
+
amount: tipEvent.amount,
|
|
326
|
+
currency: currency,
|
|
327
|
+
senderAddress,
|
|
328
|
+
receiverAddress,
|
|
329
|
+
receiverUserId,
|
|
330
|
+
messageId: bin_toHexString(tipEvent.messageId),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case undefined:
|
|
335
|
+
break;
|
|
336
|
+
default:
|
|
337
|
+
logNever(transactionContent);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
case 'keySolicitation':
|
|
342
|
+
case 'keyFulfillment':
|
|
343
|
+
case 'displayName':
|
|
344
|
+
case 'username':
|
|
345
|
+
case 'ensAddress':
|
|
346
|
+
case 'nft':
|
|
347
|
+
case 'pin':
|
|
348
|
+
case 'unpin':
|
|
349
|
+
case 'encryptionAlgorithm':
|
|
350
|
+
break;
|
|
351
|
+
case undefined:
|
|
352
|
+
break;
|
|
353
|
+
default:
|
|
354
|
+
logNever(parsed.event.payload.value.content);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else if (appEvent.payload.case === 'solicitation') {
|
|
361
|
+
const missingSessionIds = appEvent.payload.value.sessionIds.filter((sessionId) => sessionId !== '');
|
|
362
|
+
await this.client.sendKeySolicitation(streamId, missingSessionIds);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
logNever(appEvent.payload);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async handleChannelMessage(streamId, parsed, { payload }) {
|
|
369
|
+
if (!payload.case) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
|
|
373
|
+
switch (payload.case) {
|
|
374
|
+
case 'post': {
|
|
375
|
+
if (payload.value.content.case === 'text') {
|
|
376
|
+
const userId = userIdFromAddress(parsed.event.creatorAddress);
|
|
377
|
+
const replyId = payload.value.replyId;
|
|
378
|
+
const threadId = payload.value.threadId;
|
|
379
|
+
const mentions = parseMentions(payload.value.content.value.mentions);
|
|
380
|
+
const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
|
|
381
|
+
const forwardPayload = {
|
|
382
|
+
...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
|
|
383
|
+
message: payload.value.content.value.body,
|
|
384
|
+
mentions,
|
|
385
|
+
isMentioned,
|
|
386
|
+
replyId,
|
|
387
|
+
threadId,
|
|
388
|
+
};
|
|
389
|
+
if (parsed.event.tags?.messageInteractionType ===
|
|
390
|
+
MessageInteractionType.SLASH_COMMAND) {
|
|
391
|
+
const { command, args } = parseSlashCommand(payload.value.content.value.body);
|
|
392
|
+
const handler = this.slashCommandHandlers.get(command);
|
|
393
|
+
if (handler) {
|
|
394
|
+
void handler(this.client, {
|
|
395
|
+
...forwardPayload,
|
|
396
|
+
command: command,
|
|
397
|
+
args,
|
|
398
|
+
replyId,
|
|
399
|
+
threadId,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
debug('emit:message', forwardPayload);
|
|
405
|
+
this.emitter.emit('message', this.client, forwardPayload);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (payload.value.content.case === 'gm') {
|
|
409
|
+
const userId = userIdFromAddress(parsed.event.creatorAddress);
|
|
410
|
+
const gmContent = payload.value.content.value;
|
|
411
|
+
const { typeUrl, value } = gmContent;
|
|
412
|
+
this.emitter.emit('rawGmMessage', this.client, {
|
|
413
|
+
...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
|
|
414
|
+
typeUrl,
|
|
415
|
+
message: value ?? new Uint8Array(),
|
|
416
|
+
});
|
|
417
|
+
const typedHandler = this.gmTypedHandlers.get(typeUrl);
|
|
418
|
+
if (typedHandler) {
|
|
419
|
+
try {
|
|
420
|
+
const possibleJsonString = new TextDecoder().decode(value);
|
|
421
|
+
const deserializedData = superjsonParse(possibleJsonString);
|
|
422
|
+
const result = await typedHandler.schema['~standard'].validate(deserializedData);
|
|
423
|
+
if ('issues' in result && result.issues) {
|
|
424
|
+
debug('GM validation failed', { typeUrl, issues: result.issues });
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
debug('emit:gmMessage', { userId, channelId: streamId });
|
|
428
|
+
void typedHandler.handler(this.client, {
|
|
429
|
+
...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
|
|
430
|
+
typeUrl,
|
|
431
|
+
data: result.value,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
debug('GM handler error', { typeUrl, error });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case 'reaction': {
|
|
443
|
+
debug('emit:reaction', {
|
|
444
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
445
|
+
channelId: streamId,
|
|
446
|
+
reaction: payload.value.reaction,
|
|
447
|
+
messageId: payload.value.refEventId,
|
|
448
|
+
});
|
|
449
|
+
this.emitter.emit('reaction', this.client, {
|
|
450
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
451
|
+
reaction: payload.value.reaction,
|
|
452
|
+
messageId: payload.value.refEventId,
|
|
453
|
+
});
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case 'edit': {
|
|
457
|
+
// TODO: framework doesnt handle non-text edits
|
|
458
|
+
if (payload.value.post?.content.case !== 'text')
|
|
459
|
+
break;
|
|
460
|
+
const mentions = parseMentions(payload.value.post?.content.value.mentions);
|
|
461
|
+
const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
|
|
462
|
+
debug('emit:messageEdit', {
|
|
463
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
464
|
+
channelId: streamId,
|
|
465
|
+
refEventId: payload.value.refEventId,
|
|
466
|
+
messagePreview: payload.value.post?.content.value.body.substring(0, 50),
|
|
467
|
+
isMentioned,
|
|
468
|
+
});
|
|
469
|
+
this.emitter.emit('messageEdit', this.client, {
|
|
470
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
471
|
+
refEventId: payload.value.refEventId,
|
|
472
|
+
message: payload.value.post?.content.value.body,
|
|
473
|
+
mentions,
|
|
474
|
+
isMentioned,
|
|
475
|
+
replyId: payload.value.post?.replyId,
|
|
476
|
+
threadId: payload.value.post?.threadId,
|
|
477
|
+
});
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case 'redaction': {
|
|
481
|
+
debug('emit:redaction', {
|
|
482
|
+
userId: userIdFromAddress(parsed.event.creatorAddress),
|
|
483
|
+
channelId: streamId,
|
|
484
|
+
refEventId: payload.value.refEventId,
|
|
485
|
+
});
|
|
486
|
+
this.emitter.emit('redaction', this.client, {
|
|
487
|
+
...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
|
|
488
|
+
refEventId: payload.value.refEventId,
|
|
489
|
+
});
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
default:
|
|
493
|
+
logNever(payload);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async handlePaymentResponse(handler, event) {
|
|
497
|
+
if (!this.paymentConfig)
|
|
498
|
+
return;
|
|
499
|
+
const { response, channelId } = event;
|
|
500
|
+
// Check if this is a signature response
|
|
501
|
+
if (response.payload?.content?.case !== 'signature') {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const signatureId = response.payload.content.value?.requestId ?? '';
|
|
505
|
+
const signature = (response.payload.content.value?.signature ?? '');
|
|
506
|
+
if (!signatureId || !signature) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Check if this is a pending payment
|
|
510
|
+
const pending = this.pendingPayments.get(signatureId);
|
|
511
|
+
if (!pending) {
|
|
512
|
+
return; // Not a payment signature
|
|
513
|
+
}
|
|
514
|
+
// Remove from pending
|
|
515
|
+
this.pendingPayments.delete(signatureId);
|
|
516
|
+
const facilitator = useFacilitator(this.paymentConfig);
|
|
517
|
+
// Build PaymentPayload for x402
|
|
518
|
+
const paymentPayload = {
|
|
519
|
+
x402Version: 1,
|
|
520
|
+
scheme: 'exact',
|
|
521
|
+
network: chainIdToNetwork(this.viem.chain.id),
|
|
522
|
+
payload: {
|
|
523
|
+
signature: signature,
|
|
524
|
+
authorization: {
|
|
525
|
+
from: pending.params.from,
|
|
526
|
+
to: pending.params.to,
|
|
527
|
+
value: pending.params.value.toString(),
|
|
528
|
+
validAfter: pending.params.validAfter.toString(),
|
|
529
|
+
validBefore: pending.params.validBefore.toString(),
|
|
530
|
+
nonce: pending.params.nonce,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
// Build PaymentRequirements for x402
|
|
535
|
+
const paymentRequirements = {
|
|
536
|
+
scheme: 'exact',
|
|
537
|
+
network: paymentPayload.network,
|
|
538
|
+
maxAmountRequired: pending.params.value.toString(),
|
|
539
|
+
resource: `https://towns.com/command/${pending.command}`,
|
|
540
|
+
description: `Payment for /${pending.command}`,
|
|
541
|
+
mimeType: 'application/json',
|
|
542
|
+
payTo: pending.params.to,
|
|
543
|
+
maxTimeoutSeconds: 300,
|
|
544
|
+
asset: pending.params.verifyingContract,
|
|
545
|
+
};
|
|
546
|
+
// Single status message that gets updated through the flow
|
|
547
|
+
const statusMsg = await handler.sendMessage(channelId, '🔍 Verifying payment...');
|
|
548
|
+
// Track settlement state to distinguish payment failures from post-payment failures
|
|
549
|
+
let settlementCompleted = false;
|
|
550
|
+
let transactionHash;
|
|
551
|
+
try {
|
|
552
|
+
const verifyResult = await facilitator.verify(paymentPayload, paymentRequirements);
|
|
553
|
+
if (!verifyResult.isValid) {
|
|
554
|
+
await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment verification failed: ${verifyResult.invalidReason || 'Unknown error'}`);
|
|
555
|
+
await handler.removeEvent(channelId, pending.interactionEventId);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// Update status: settling
|
|
559
|
+
await handler.editMessage(channelId, statusMsg.eventId, `✅ Verified • Settling $${formatUnits(pending.params.value, 6)} USDC...`);
|
|
560
|
+
const settleResult = await facilitator.settle(paymentPayload, paymentRequirements);
|
|
561
|
+
if (!settleResult.success) {
|
|
562
|
+
await handler.editMessage(channelId, statusMsg.eventId, `❌ Settlement failed: ${settleResult.errorReason || 'Unknown error'}`);
|
|
563
|
+
await handler.removeEvent(channelId, pending.interactionEventId);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// Mark settlement as complete - funds have been transferred
|
|
567
|
+
settlementCompleted = true;
|
|
568
|
+
transactionHash = settleResult.transaction;
|
|
569
|
+
// Final success - show receipt
|
|
570
|
+
await handler.editMessage(channelId, statusMsg.eventId, `✅ **Payment Complete**\n` +
|
|
571
|
+
`/${pending.command} • $${formatUnits(pending.params.value, 6)} USDC\n` +
|
|
572
|
+
`Tx: \`${transactionHash}\``);
|
|
573
|
+
// Delete the signature request now that payment is complete
|
|
574
|
+
await handler.removeEvent(channelId, pending.interactionEventId);
|
|
575
|
+
// Execute the original command handler (stored with __paid_ prefix)
|
|
576
|
+
const actualHandlerKey = `__paid_${pending.command}`;
|
|
577
|
+
const originalHandler = this.slashCommandHandlers.get(actualHandlerKey);
|
|
578
|
+
if (originalHandler) {
|
|
579
|
+
await originalHandler(this.client, pending.event);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
584
|
+
if (settlementCompleted) {
|
|
585
|
+
// Payment succeeded but command handler failed - DO NOT suggest retry
|
|
586
|
+
await handler.editMessage(channelId, statusMsg.eventId, `⚠️ **Payment succeeded but command failed**\n` +
|
|
587
|
+
`Your payment of $${formatUnits(pending.params.value, 6)} USDC was processed.\n` +
|
|
588
|
+
`Tx: \`${transactionHash}\`\n\n` +
|
|
589
|
+
`Error: ${errorMessage}\n` +
|
|
590
|
+
`Please contact support - do NOT retry to avoid double charges.`);
|
|
591
|
+
// Don't remove interaction event - payment already processed
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// Actual payment failure (verify or settle threw)
|
|
595
|
+
await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment failed: ${errorMessage}`);
|
|
596
|
+
await handler.removeEvent(channelId, pending.interactionEventId);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* get the public device key of the agent
|
|
602
|
+
* @returns the public device key of the agent
|
|
603
|
+
*/
|
|
604
|
+
getUserDevice() {
|
|
605
|
+
return this.client.crypto.getUserDevice();
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Send a message to a stream
|
|
609
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
610
|
+
* @param message - The cleartext of the message
|
|
611
|
+
* @param opts - The options for the message
|
|
612
|
+
*/
|
|
613
|
+
async sendMessage(streamId, message, opts) {
|
|
614
|
+
const result = await this.client.sendMessage(streamId, message, opts, this.currentMessageTags);
|
|
615
|
+
this.currentMessageTags = undefined;
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Send a reaction to a stream
|
|
620
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
621
|
+
* @param refEventId - The eventId of the event to react to
|
|
622
|
+
* @param reaction - The reaction to send
|
|
623
|
+
*/
|
|
624
|
+
async sendReaction(streamId, refEventId, reaction) {
|
|
625
|
+
const result = await this.client.sendReaction(streamId, refEventId, reaction, this.currentMessageTags);
|
|
626
|
+
this.currentMessageTags = undefined;
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Remove an specific event from a stream
|
|
631
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
632
|
+
* @param refEventId - The eventId of the event to remove
|
|
633
|
+
*/
|
|
634
|
+
async removeEvent(streamId, refEventId) {
|
|
635
|
+
const result = await this.client.removeEvent(streamId, refEventId, this.currentMessageTags);
|
|
636
|
+
this.currentMessageTags = undefined;
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Edit an specific message from a stream
|
|
641
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
642
|
+
* @param messageId - The eventId of the message to edit
|
|
643
|
+
* @param message - The new message text
|
|
644
|
+
*/
|
|
645
|
+
async editMessage(streamId, messageId, message, opts) {
|
|
646
|
+
const result = await this.client.editMessage(streamId, messageId, message, opts, this.currentMessageTags);
|
|
647
|
+
this.currentMessageTags = undefined;
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Send a GM (generic message) to a stream with schema validation
|
|
652
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
653
|
+
* @param typeUrl - The type URL identifying the message format
|
|
654
|
+
* @param schema - StandardSchema for validation
|
|
655
|
+
* @param data - Data to validate and send
|
|
656
|
+
*/
|
|
657
|
+
async sendGM(streamId, typeUrl, schema, data, opts) {
|
|
658
|
+
const result = await this.client.sendGM(streamId, typeUrl, schema, data, opts, this.currentMessageTags);
|
|
659
|
+
this.currentMessageTags = undefined;
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Send a raw GM (generic message) to a stream without schema validation
|
|
664
|
+
* @param streamId - Id of the stream. Usually channelId or userId
|
|
665
|
+
* @param typeUrl - The type URL identifying the message format
|
|
666
|
+
* @param message - Optional raw message data as bytes
|
|
667
|
+
* @param opts - The options for the message
|
|
668
|
+
*/
|
|
669
|
+
async sendRawGM(streamId, typeUrl, message, opts) {
|
|
670
|
+
const result = await this.client.sendRawGM(streamId, typeUrl, message, opts, this.currentMessageTags);
|
|
671
|
+
this.currentMessageTags = undefined;
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
674
|
+
// Implementation
|
|
675
|
+
async sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts) {
|
|
676
|
+
const tags = this.currentMessageTags;
|
|
677
|
+
this.currentMessageTags = undefined;
|
|
678
|
+
if (isFlattenedRequest(contentOrPayload)) {
|
|
679
|
+
// New flattened format: (streamId, payload, opts?)
|
|
680
|
+
return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, tags);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
// Old format: (streamId, content, recipient?, opts?)
|
|
684
|
+
return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts, tags);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async pinMessage(streamId, eventId, streamEvent) {
|
|
688
|
+
return this.client.pinMessage(streamId, eventId, streamEvent);
|
|
689
|
+
}
|
|
690
|
+
async unpinMessage(streamId, eventId) {
|
|
691
|
+
return this.client.unpinMessage(streamId, eventId);
|
|
692
|
+
}
|
|
693
|
+
/** Sends a tip to a user by looking up their smart account.
|
|
694
|
+
* Tip will always get funds from the app account balance.
|
|
695
|
+
* @param params - Tip parameters including userId, amount, messageId, channelId, currency.
|
|
696
|
+
* @returns The transaction hash and event ID
|
|
697
|
+
*/
|
|
698
|
+
async sendTip(params) {
|
|
699
|
+
const result = await this.client.sendTip(params, this.currentMessageTags);
|
|
700
|
+
this.currentMessageTags = undefined;
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Triggered when someone sends a message.
|
|
705
|
+
* This is triggered for all messages, including direct messages and group messages.
|
|
706
|
+
*/
|
|
707
|
+
onMessage(fn) {
|
|
708
|
+
return this.emitter.on('message', fn);
|
|
709
|
+
}
|
|
710
|
+
onRedaction(fn) {
|
|
711
|
+
return this.emitter.on('redaction', fn);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Triggered when a message gets edited
|
|
715
|
+
*/
|
|
716
|
+
onMessageEdit(fn) {
|
|
717
|
+
return this.emitter.on('messageEdit', fn);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Triggered when someone reacts to a message
|
|
721
|
+
*/
|
|
722
|
+
onReaction(fn) {
|
|
723
|
+
return this.emitter.on('reaction', fn);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Triggered when a message is revoked by a moderator
|
|
727
|
+
*/
|
|
728
|
+
onEventRevoke(fn) {
|
|
729
|
+
return this.emitter.on('eventRevoke', fn);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Triggered when someone tips the agent
|
|
733
|
+
*/
|
|
734
|
+
onTip(fn) {
|
|
735
|
+
return this.emitter.on('tip', fn);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Triggered when someone joins a channel
|
|
739
|
+
*/
|
|
740
|
+
onChannelJoin(fn) {
|
|
741
|
+
return this.emitter.on('channelJoin', fn);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Triggered when someone leaves a channel
|
|
745
|
+
*/
|
|
746
|
+
onChannelLeave(fn) {
|
|
747
|
+
return this.emitter.on('channelLeave', fn);
|
|
748
|
+
}
|
|
749
|
+
onStreamEvent(fn) {
|
|
750
|
+
return this.emitter.on('streamEvent', fn);
|
|
751
|
+
}
|
|
752
|
+
onSlashCommand(command, fn) {
|
|
753
|
+
const paymentConfig = this.paymentCommands.get(command);
|
|
754
|
+
if (!paymentConfig || !this.paymentConfig) {
|
|
755
|
+
this.slashCommandHandlers.set(command, fn);
|
|
756
|
+
const unset = () => {
|
|
757
|
+
if (this.slashCommandHandlers.has(command) &&
|
|
758
|
+
this.slashCommandHandlers.get(command) === fn) {
|
|
759
|
+
this.slashCommandHandlers.delete(command);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
return unset;
|
|
763
|
+
}
|
|
764
|
+
this.slashCommandHandlers.set(command, async (handler, event) => {
|
|
765
|
+
try {
|
|
766
|
+
const chainId = this.viem.chain.id;
|
|
767
|
+
const smartAccountAddress = await getSmartAccountFromUserIdImpl(this.client.config.base.chainConfig.addresses.spaceFactory, this.viem, event.userId);
|
|
768
|
+
const { signatureId, params, eventId } = await createPaymentRequest(handler, event, chainId, smartAccountAddress, this.appAddress, paymentConfig, command);
|
|
769
|
+
// Store pending payment
|
|
770
|
+
this.pendingPayments.set(signatureId, {
|
|
771
|
+
command: command,
|
|
772
|
+
channelId: event.channelId,
|
|
773
|
+
userId: event.userId,
|
|
774
|
+
interactionEventId: eventId,
|
|
775
|
+
event: event,
|
|
776
|
+
params: params,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
await handler.sendMessage(event.channelId, `❌ Failed to request payment: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
const actualHandlerKey = `__paid_${command}`;
|
|
784
|
+
this.slashCommandHandlers.set(actualHandlerKey, fn);
|
|
785
|
+
const unset = () => {
|
|
786
|
+
if (this.slashCommandHandlers.has(command) &&
|
|
787
|
+
this.slashCommandHandlers.get(command) === fn) {
|
|
788
|
+
this.slashCommandHandlers.delete(command);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
return unset;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Triggered when someone sends a GM (generic message) with type validation using StandardSchema
|
|
795
|
+
* @param typeUrl - The type URL to listen for
|
|
796
|
+
* @param schema - The StandardSchema to validate the message data
|
|
797
|
+
* @param handler - The handler function to call when a message is received
|
|
798
|
+
*/
|
|
799
|
+
onGmMessage(typeUrl, schema, handler) {
|
|
800
|
+
this.gmTypedHandlers.set(typeUrl, { schema, handler: handler });
|
|
801
|
+
const unset = () => {
|
|
802
|
+
if (this.gmTypedHandlers.has(typeUrl) &&
|
|
803
|
+
this.gmTypedHandlers.get(typeUrl)?.handler === handler) {
|
|
804
|
+
this.gmTypedHandlers.delete(typeUrl);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
return unset;
|
|
808
|
+
}
|
|
809
|
+
onRawGmMessage(handler) {
|
|
810
|
+
return this.emitter.on('rawGmMessage', handler);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Triggered when someone sends an interaction response
|
|
814
|
+
* @param fn - The handler function to call when an interaction response is received
|
|
815
|
+
*/
|
|
816
|
+
onInteractionResponse(fn) {
|
|
817
|
+
return this.emitter.on('interactionResponse', fn);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Get the stream view for a stream
|
|
821
|
+
* Stream views contain contextual information about the stream (space, channel, etc)
|
|
822
|
+
* Stream views contain member data for all streams - you can iterate over all members in a channel via: `streamView.getMembers().joined.keys()`
|
|
823
|
+
* note: potentially expensive operation because streams can be large, fine to use in small streams
|
|
824
|
+
* @param streamId - The stream ID to get the view for
|
|
825
|
+
* @returns The stream view
|
|
826
|
+
*/
|
|
827
|
+
async getStreamView(streamId) {
|
|
828
|
+
return this.client.getStream(streamId);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Get the ERC-8004 compliant metadata JSON
|
|
832
|
+
* This should be hosted at /.well-known/agent-metadata.json
|
|
833
|
+
* Fetches metadata from the App Registry and merges with local config
|
|
834
|
+
* @returns The ERC-8004 compliant metadata object or null
|
|
835
|
+
*/
|
|
836
|
+
async getIdentityMetadata() {
|
|
837
|
+
// Fetch metadata from App Registry
|
|
838
|
+
let appMetadata;
|
|
839
|
+
try {
|
|
840
|
+
const appRegistry = await this.client.appServiceClient();
|
|
841
|
+
const response = await appRegistry.getAppMetadata({
|
|
842
|
+
appId: bin_fromHexString(this.agentUserId),
|
|
843
|
+
});
|
|
844
|
+
appMetadata = response.metadata;
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
// eslint-disable-next-line no-console
|
|
848
|
+
console.warn('[@towns-labs/agent] Failed to fetch app metadata', err);
|
|
849
|
+
}
|
|
850
|
+
// If no config and no fetched metadata, return null
|
|
851
|
+
if (!this.identityConfig && !appMetadata)
|
|
852
|
+
return null;
|
|
853
|
+
const endpoints = [];
|
|
854
|
+
if (this.identityConfig?.endpoints) {
|
|
855
|
+
endpoints.push(...this.identityConfig.endpoints);
|
|
856
|
+
}
|
|
857
|
+
const hasAgentWallet = endpoints.some((e) => e.name === 'agentWallet');
|
|
858
|
+
if (!hasAgentWallet) {
|
|
859
|
+
const chainId = this.viem.chain.id;
|
|
860
|
+
endpoints.push({
|
|
861
|
+
name: 'agentWallet',
|
|
862
|
+
endpoint: `eip155:${chainId}:${this.appAddress}`,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const domain = this.identityConfig?.domain;
|
|
866
|
+
if (domain && !endpoints.some((e) => e.name === 'A2A')) {
|
|
867
|
+
const origin = domain.startsWith('http') ? domain : `https://${domain}`;
|
|
868
|
+
endpoints.push({
|
|
869
|
+
name: 'A2A',
|
|
870
|
+
endpoint: `${origin}/.well-known/agent-card.json`,
|
|
871
|
+
version: '0.3.0',
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
// Merge app metadata with identity config, preferring identity config
|
|
875
|
+
const name = this.identityConfig?.name || appMetadata?.displayName || 'Unknown Agent';
|
|
876
|
+
const description = this.identityConfig?.description || appMetadata?.description || '';
|
|
877
|
+
const image = this.identityConfig?.image || appMetadata?.avatarUrl || appMetadata?.imageUrl || '';
|
|
878
|
+
const motto = this.identityConfig?.motto || appMetadata?.motto;
|
|
879
|
+
return {
|
|
880
|
+
type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
|
|
881
|
+
name,
|
|
882
|
+
description,
|
|
883
|
+
image,
|
|
884
|
+
endpoints,
|
|
885
|
+
registrations: this.identityConfig?.registrations || [],
|
|
886
|
+
supportedTrust: this.identityConfig?.supportedTrust,
|
|
887
|
+
motto,
|
|
888
|
+
capabilities: this.commands?.map((c) => c.name) || [],
|
|
889
|
+
version: packageJson.version,
|
|
890
|
+
framework: `javascript:${packageJson.name}:${packageJson.version}`,
|
|
891
|
+
attributes: this.identityConfig?.attributes,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Get the tokenURI that would be used for ERC-8004 registration
|
|
896
|
+
* Returns null if no domain is configured
|
|
897
|
+
* @returns The .well-known URL or null
|
|
898
|
+
*/
|
|
899
|
+
getTokenURI() {
|
|
900
|
+
if (!this.identityConfig?.domain)
|
|
901
|
+
return null;
|
|
902
|
+
const origin = this.identityConfig.domain.startsWith('http')
|
|
903
|
+
? this.identityConfig.domain
|
|
904
|
+
: `https://${this.identityConfig.domain}`;
|
|
905
|
+
return `${origin}/.well-known/agent-metadata.json`;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
export const makeTownsAgent = async (appPrivateData, jwtSecretBase64, opts = {}) => {
|
|
909
|
+
const { baseRpcUrl, ...clientOpts } = opts;
|
|
910
|
+
let appAddress;
|
|
911
|
+
const { privateKey, encryptionDevice, env, appAddress: appAddressFromPrivateData, } = parseAppPrivateData(appPrivateData);
|
|
912
|
+
if (!env) {
|
|
913
|
+
throw new Error('Failed to parse APP_PRIVATE_DATA');
|
|
914
|
+
}
|
|
915
|
+
if (appAddressFromPrivateData) {
|
|
916
|
+
appAddress = appAddressFromPrivateData;
|
|
917
|
+
}
|
|
918
|
+
const account = privateKeyToAccount(privateKey);
|
|
919
|
+
const baseConfig = townsEnv().makeBaseChainConfig(env);
|
|
920
|
+
const getChain = (chainId) => {
|
|
921
|
+
if (chainId === base.id)
|
|
922
|
+
return base;
|
|
923
|
+
if (chainId === foundry.id)
|
|
924
|
+
return foundry;
|
|
925
|
+
return baseSepolia;
|
|
926
|
+
};
|
|
927
|
+
const chain = getChain(baseConfig.chainConfig.chainId);
|
|
928
|
+
const viem = createWalletClient({
|
|
929
|
+
account,
|
|
930
|
+
transport: baseRpcUrl
|
|
931
|
+
? http(baseRpcUrl, { batch: true })
|
|
932
|
+
: http(baseConfig.rpcUrl, { batch: true }),
|
|
933
|
+
chain,
|
|
934
|
+
});
|
|
935
|
+
const spaceDapp = new SpaceDapp(baseConfig.chainConfig, new ethers.providers.JsonRpcProvider(baseRpcUrl || baseConfig.rpcUrl));
|
|
936
|
+
if (!appAddress) {
|
|
937
|
+
appAddress = await readContract(viem, {
|
|
938
|
+
address: baseConfig.chainConfig.addresses.appRegistry,
|
|
939
|
+
abi: appRegistryAbi,
|
|
940
|
+
functionName: 'getAppByClient',
|
|
941
|
+
args: [account.address],
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
const client = await createTownsClient({
|
|
945
|
+
privateKey,
|
|
946
|
+
env,
|
|
947
|
+
encryptionDevice: {
|
|
948
|
+
fromExportedDevice: encryptionDevice,
|
|
949
|
+
},
|
|
950
|
+
...clientOpts,
|
|
951
|
+
}).then((x) => x.extend((townsClient) => buildAgentActions(townsClient, viem, spaceDapp, appAddress)));
|
|
952
|
+
if (opts.commands) {
|
|
953
|
+
client
|
|
954
|
+
.appServiceClient()
|
|
955
|
+
.then((appRegistryClient) => appRegistryClient
|
|
956
|
+
.updateAppMetadata({
|
|
957
|
+
appId: bin_fromHexString(account.address),
|
|
958
|
+
updateMask: ['slash_commands'],
|
|
959
|
+
metadata: {
|
|
960
|
+
slashCommands: opts.commands,
|
|
961
|
+
},
|
|
962
|
+
})
|
|
963
|
+
.catch((err) => {
|
|
964
|
+
// eslint-disable-next-line no-console
|
|
965
|
+
console.warn('[@towns-labs/agent] failed to update slash commands', err);
|
|
966
|
+
}))
|
|
967
|
+
.catch((err) => {
|
|
968
|
+
// eslint-disable-next-line no-console
|
|
969
|
+
console.warn('[@towns-labs/agent] failed to get app registry rpc client', err);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
await client.uploadDeviceKeys();
|
|
973
|
+
return new Agent(client, viem, jwtSecretBase64, appAddress, opts.commands, opts.identity, opts.dedup, opts.paymentConfig);
|
|
974
|
+
};
|
|
975
|
+
const buildAgentActions = (client, viem, spaceDapp, appAddress) => {
|
|
976
|
+
const CHUNK_SIZE = 1200000; // 1.2MB max per chunk (including auth tag)
|
|
977
|
+
const createChunkedMediaAttachment = async (attachment) => {
|
|
978
|
+
let data;
|
|
979
|
+
let mimetype;
|
|
980
|
+
if (attachment.data instanceof Blob) {
|
|
981
|
+
const buffer = await attachment.data.arrayBuffer();
|
|
982
|
+
data = new Uint8Array(buffer);
|
|
983
|
+
mimetype = attachment.data.type;
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
data = attachment.data;
|
|
987
|
+
if ('mimetype' in attachment) {
|
|
988
|
+
mimetype = attachment.mimetype;
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
throw new Error('mimetype is required for Uint8Array data');
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
let width = attachment.width || 0;
|
|
995
|
+
let height = attachment.height || 0;
|
|
996
|
+
if (mimetype.startsWith('image/') && (!width || !height)) {
|
|
997
|
+
const dimensions = imageSize(data);
|
|
998
|
+
width = dimensions.width || 0;
|
|
999
|
+
height = dimensions.height || 0;
|
|
1000
|
+
}
|
|
1001
|
+
const { chunks, secretKey } = await encryptChunkedAESGCM(data, CHUNK_SIZE);
|
|
1002
|
+
const chunkCount = chunks.length;
|
|
1003
|
+
if (chunkCount === 0) {
|
|
1004
|
+
throw new Error('No media chunks generated');
|
|
1005
|
+
}
|
|
1006
|
+
// TODO: Implement thumbnail generation with sharp
|
|
1007
|
+
const thumbnail = undefined;
|
|
1008
|
+
const streamId = makeUniqueMediaStreamId();
|
|
1009
|
+
const events = await Promise.all([
|
|
1010
|
+
makeEvent(client.signerContext, make_MediaPayload_Inception({
|
|
1011
|
+
streamId: streamIdAsBytes(streamId),
|
|
1012
|
+
userId: addressFromUserId(client.userId),
|
|
1013
|
+
chunkCount,
|
|
1014
|
+
perChunkEncryption: true,
|
|
1015
|
+
})),
|
|
1016
|
+
makeEvent(client.signerContext, make_MediaPayload_Chunk({
|
|
1017
|
+
data: chunks[0].ciphertext,
|
|
1018
|
+
chunkIndex: 0,
|
|
1019
|
+
iv: chunks[0].iv,
|
|
1020
|
+
})),
|
|
1021
|
+
]);
|
|
1022
|
+
const mediaStreamResponse = await client.rpc.createMediaStream({
|
|
1023
|
+
events,
|
|
1024
|
+
streamId: streamIdAsBytes(streamId),
|
|
1025
|
+
});
|
|
1026
|
+
if (!mediaStreamResponse?.nextCreationCookie) {
|
|
1027
|
+
throw new Error('Failed to create media stream');
|
|
1028
|
+
}
|
|
1029
|
+
if (chunkCount > 1) {
|
|
1030
|
+
let cc = create(CreationCookieSchema, mediaStreamResponse.nextCreationCookie);
|
|
1031
|
+
for (let chunkIndex = 1; chunkIndex < chunkCount; chunkIndex++) {
|
|
1032
|
+
const chunkEvent = await makeEvent(client.signerContext, make_MediaPayload_Chunk({
|
|
1033
|
+
data: chunks[chunkIndex].ciphertext,
|
|
1034
|
+
chunkIndex: chunkIndex,
|
|
1035
|
+
iv: chunks[chunkIndex].iv,
|
|
1036
|
+
}), cc.prevMiniblockHash);
|
|
1037
|
+
const result = await client.rpc.addMediaEvent({
|
|
1038
|
+
event: chunkEvent,
|
|
1039
|
+
creationCookie: cc,
|
|
1040
|
+
last: chunkIndex === chunkCount - 1,
|
|
1041
|
+
});
|
|
1042
|
+
if (!result?.creationCookie) {
|
|
1043
|
+
throw new Error('Failed to send media chunk');
|
|
1044
|
+
}
|
|
1045
|
+
cc = create(CreationCookieSchema, result.creationCookie);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const mediaStreamInfo = { creationCookie: mediaStreamResponse.nextCreationCookie };
|
|
1049
|
+
return {
|
|
1050
|
+
content: {
|
|
1051
|
+
case: 'chunkedMedia',
|
|
1052
|
+
value: create(ChunkedMediaSchema, {
|
|
1053
|
+
info: {
|
|
1054
|
+
filename: attachment.filename,
|
|
1055
|
+
mimetype: mimetype,
|
|
1056
|
+
widthPixels: width,
|
|
1057
|
+
heightPixels: height,
|
|
1058
|
+
sizeBytes: BigInt(data.length),
|
|
1059
|
+
},
|
|
1060
|
+
streamId: streamIdAsString(mediaStreamInfo.creationCookie.streamId),
|
|
1061
|
+
encryption: {
|
|
1062
|
+
case: 'aesgcm',
|
|
1063
|
+
value: {
|
|
1064
|
+
iv: new Uint8Array(0),
|
|
1065
|
+
secretKey: secretKey,
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
thumbnail,
|
|
1069
|
+
}),
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
1073
|
+
const createImageAttachmentFromURL = async (attachment) => {
|
|
1074
|
+
try {
|
|
1075
|
+
const response = await fetch(attachment.url);
|
|
1076
|
+
if (!response.ok) {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
const contentType = response.headers.get('content-type');
|
|
1080
|
+
if (!contentType || !contentType.startsWith('image/')) {
|
|
1081
|
+
// eslint-disable-next-line no-console
|
|
1082
|
+
console.warn(`A non-image URL attachment was provided. ${attachment.url} (Content-Type: ${contentType || 'unknown'})`);
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
const bytes = await response.bytes();
|
|
1086
|
+
const dimensions = imageSize(bytes);
|
|
1087
|
+
const width = dimensions.width || 0;
|
|
1088
|
+
const height = dimensions.height || 0;
|
|
1089
|
+
const image = create(ChannelMessage_Post_Content_ImageSchema, {
|
|
1090
|
+
title: attachment.alt || '',
|
|
1091
|
+
info: create(ChannelMessage_Post_Content_Image_InfoSchema, {
|
|
1092
|
+
url: attachment.url,
|
|
1093
|
+
mimetype: contentType,
|
|
1094
|
+
width,
|
|
1095
|
+
height,
|
|
1096
|
+
}),
|
|
1097
|
+
});
|
|
1098
|
+
return {
|
|
1099
|
+
content: {
|
|
1100
|
+
case: 'image',
|
|
1101
|
+
value: image,
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
const createLinkAttachment = (attachment) => {
|
|
1110
|
+
return create(ChannelMessage_Post_AttachmentSchema, {
|
|
1111
|
+
content: {
|
|
1112
|
+
case: 'unfurledUrl',
|
|
1113
|
+
value: {
|
|
1114
|
+
url: attachment.url,
|
|
1115
|
+
image: attachment.image,
|
|
1116
|
+
title: attachment.title ?? '',
|
|
1117
|
+
description: attachment.description ?? '',
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
};
|
|
1122
|
+
const createTickerAttachment = (attachment) => {
|
|
1123
|
+
return create(ChannelMessage_Post_AttachmentSchema, {
|
|
1124
|
+
content: {
|
|
1125
|
+
case: 'ticker',
|
|
1126
|
+
value: {
|
|
1127
|
+
address: attachment.address,
|
|
1128
|
+
chainId: attachment.chainId,
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
};
|
|
1133
|
+
const createMiniAppAttachment = (attachment) => {
|
|
1134
|
+
return create(ChannelMessage_Post_AttachmentSchema, {
|
|
1135
|
+
content: {
|
|
1136
|
+
case: 'miniapp',
|
|
1137
|
+
value: { url: attachment.url },
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
};
|
|
1141
|
+
const ensureOutboundSession = async (streamId, encryptionAlgorithm, toUserIds, miniblockInfo) => {
|
|
1142
|
+
if (!(await client.crypto.hasOutboundSession(streamId, encryptionAlgorithm))) {
|
|
1143
|
+
// ATTEMPT 1: Get session from app service
|
|
1144
|
+
const appService = await client.appServiceClient();
|
|
1145
|
+
try {
|
|
1146
|
+
const sessionResp = await appService.getSession({
|
|
1147
|
+
appId: userIdToAddress(client.userId),
|
|
1148
|
+
identifier: {
|
|
1149
|
+
case: 'streamId',
|
|
1150
|
+
value: streamIdAsBytes(streamId),
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
if (sessionResp.groupEncryptionSessions) {
|
|
1154
|
+
const parsedEvent = await unpackEnvelope(sessionResp.groupEncryptionSessions, client.unpackEnvelopeOpts);
|
|
1155
|
+
check(parsedEvent.event.payload.case === 'userInboxPayload' &&
|
|
1156
|
+
parsedEvent.event.payload.value.content.case ===
|
|
1157
|
+
'groupEncryptionSessions', 'invalid event payload');
|
|
1158
|
+
await client.importGroupEncryptionSessions({
|
|
1159
|
+
streamId,
|
|
1160
|
+
sessions: parsedEvent.event.payload.value.content.value,
|
|
1161
|
+
});
|
|
1162
|
+
// EARLY RETURN
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
// ignore error (should log)
|
|
1168
|
+
}
|
|
1169
|
+
// ATTEMPT 2: Create new session
|
|
1170
|
+
await client.crypto.ensureOutboundSession(streamId, encryptionAlgorithm, {
|
|
1171
|
+
shareShareSessionTimeoutMs: 5000,
|
|
1172
|
+
priorityUserIds: [client.userId, ...toUserIds],
|
|
1173
|
+
miniblockInfo,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
const sendMessageEvent = async ({ streamId, payload, tags, ephemeral, }) => {
|
|
1178
|
+
const miniblockInfo = await client.getMiniblockInfo(streamId);
|
|
1179
|
+
const eventTags = {
|
|
1180
|
+
...unsafe_makeTags(payload),
|
|
1181
|
+
participatingUserAddresses: tags?.participatingUserAddresses || [],
|
|
1182
|
+
threadId: tags?.threadId || undefined,
|
|
1183
|
+
};
|
|
1184
|
+
const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
|
|
1185
|
+
? miniblockInfo.encryptionAlgorithm.algorithm
|
|
1186
|
+
: client.defaultGroupEncryptionAlgorithm;
|
|
1187
|
+
await ensureOutboundSession(streamId, encryptionAlgorithm, Array.from(new Set([
|
|
1188
|
+
...eventTags.participatingUserAddresses.map((x) => userIdFromAddress(x)),
|
|
1189
|
+
...eventTags.mentionedUserAddresses.map((x) => userIdFromAddress(x)),
|
|
1190
|
+
])), miniblockInfo);
|
|
1191
|
+
const message = await client.crypto.encryptGroupEvent(streamId, toBinary(ChannelMessageSchema, payload), encryptionAlgorithm);
|
|
1192
|
+
message.refEventId = getRefEventIdFromChannelMessage(payload);
|
|
1193
|
+
let eventPayload;
|
|
1194
|
+
if (isChannelStreamId(streamId)) {
|
|
1195
|
+
eventPayload = make_ChannelPayload_Message(message);
|
|
1196
|
+
}
|
|
1197
|
+
else if (isDMChannelStreamId(streamId)) {
|
|
1198
|
+
eventPayload = make_DMChannelPayload_Message(message);
|
|
1199
|
+
}
|
|
1200
|
+
else if (isGDMChannelStreamId(streamId)) {
|
|
1201
|
+
eventPayload = make_GDMChannelPayload_Message(message);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
throw new Error(`Invalid stream ID type: ${streamId}`);
|
|
1205
|
+
}
|
|
1206
|
+
return client.sendEvent(streamId, eventPayload, eventTags, ephemeral);
|
|
1207
|
+
};
|
|
1208
|
+
const sendKeySolicitation = async (streamId, sessionIds) => {
|
|
1209
|
+
const encryptionDevice = client.crypto.getUserDevice();
|
|
1210
|
+
const missingSessionIds = sessionIds.filter((sessionId) => sessionId !== '');
|
|
1211
|
+
return client.sendEvent(streamId, make_MemberPayload_KeySolicitation({
|
|
1212
|
+
deviceKey: encryptionDevice.deviceKey,
|
|
1213
|
+
fallbackKey: encryptionDevice.fallbackKey,
|
|
1214
|
+
isNewDevice: missingSessionIds.length === 0,
|
|
1215
|
+
sessionIds: missingSessionIds,
|
|
1216
|
+
}));
|
|
1217
|
+
};
|
|
1218
|
+
const uploadDeviceKeys = async () => {
|
|
1219
|
+
const streamId = makeUserMetadataStreamId(client.userId);
|
|
1220
|
+
const encryptionDevice = client.crypto.getUserDevice();
|
|
1221
|
+
return client.sendEvent(streamId, make_UserMetadataPayload_EncryptionDevice({
|
|
1222
|
+
...encryptionDevice,
|
|
1223
|
+
}));
|
|
1224
|
+
};
|
|
1225
|
+
const sendMessage = async (streamId, message, opts, tags) => {
|
|
1226
|
+
const processedAttachments = [];
|
|
1227
|
+
if (opts?.attachments && opts.attachments.length > 0) {
|
|
1228
|
+
for (const attachment of opts.attachments) {
|
|
1229
|
+
switch (attachment.type) {
|
|
1230
|
+
case 'image': {
|
|
1231
|
+
const result = await createImageAttachmentFromURL(attachment);
|
|
1232
|
+
processedAttachments.push(result);
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
case 'chunked': {
|
|
1236
|
+
const result = await createChunkedMediaAttachment(attachment);
|
|
1237
|
+
processedAttachments.push(result);
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
case 'link': {
|
|
1241
|
+
const result = createLinkAttachment(attachment);
|
|
1242
|
+
processedAttachments.push(result);
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
case 'ticker': {
|
|
1246
|
+
const result = createTickerAttachment(attachment);
|
|
1247
|
+
processedAttachments.push(result);
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
case 'miniapp': {
|
|
1251
|
+
const result = createMiniAppAttachment(attachment);
|
|
1252
|
+
processedAttachments.push(result);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
default:
|
|
1256
|
+
logNever(attachment);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const payload = create(ChannelMessageSchema, {
|
|
1261
|
+
payload: {
|
|
1262
|
+
case: 'post',
|
|
1263
|
+
value: {
|
|
1264
|
+
threadId: opts?.threadId,
|
|
1265
|
+
replyId: opts?.replyId,
|
|
1266
|
+
replyPreview: opts?.replyId ? '🙈' : undefined,
|
|
1267
|
+
threadPreview: opts?.threadId ? '🙉' : undefined,
|
|
1268
|
+
content: {
|
|
1269
|
+
case: 'text',
|
|
1270
|
+
value: {
|
|
1271
|
+
body: message,
|
|
1272
|
+
attachments: processedAttachments.filter((x) => x !== null),
|
|
1273
|
+
mentions: processMentions(opts?.mentions),
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
|
|
1280
|
+
};
|
|
1281
|
+
const editMessage = async (streamId, messageId, message, opts, tags) => {
|
|
1282
|
+
const processedAttachments = [];
|
|
1283
|
+
if (opts?.attachments && opts.attachments.length > 0) {
|
|
1284
|
+
for (const attachment of opts.attachments) {
|
|
1285
|
+
switch (attachment.type) {
|
|
1286
|
+
case 'image': {
|
|
1287
|
+
const result = await createImageAttachmentFromURL(attachment);
|
|
1288
|
+
processedAttachments.push(result);
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
case 'chunked': {
|
|
1292
|
+
const result = await createChunkedMediaAttachment(attachment);
|
|
1293
|
+
processedAttachments.push(result);
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
case 'link': {
|
|
1297
|
+
const result = createLinkAttachment(attachment);
|
|
1298
|
+
processedAttachments.push(result);
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
1301
|
+
case 'ticker': {
|
|
1302
|
+
const result = createTickerAttachment(attachment);
|
|
1303
|
+
processedAttachments.push(result);
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
case 'miniapp': {
|
|
1307
|
+
const result = createMiniAppAttachment(attachment);
|
|
1308
|
+
processedAttachments.push(result);
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
default:
|
|
1312
|
+
logNever(attachment);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
const payload = create(ChannelMessageSchema, {
|
|
1317
|
+
payload: {
|
|
1318
|
+
case: 'edit',
|
|
1319
|
+
value: {
|
|
1320
|
+
refEventId: messageId,
|
|
1321
|
+
post: {
|
|
1322
|
+
threadId: opts?.threadId,
|
|
1323
|
+
replyId: opts?.replyId,
|
|
1324
|
+
replyPreview: opts?.replyId ? '🙈' : undefined,
|
|
1325
|
+
threadPreview: opts?.threadId ? '🙉' : undefined,
|
|
1326
|
+
content: {
|
|
1327
|
+
case: 'text',
|
|
1328
|
+
value: {
|
|
1329
|
+
body: message,
|
|
1330
|
+
mentions: processMentions(opts?.mentions),
|
|
1331
|
+
attachments: processedAttachments.filter((x) => x !== null),
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
|
|
1339
|
+
};
|
|
1340
|
+
const sendReaction = async (streamId, messageId, reaction, tags) => {
|
|
1341
|
+
const payload = create(ChannelMessageSchema, {
|
|
1342
|
+
payload: { case: 'reaction', value: { refEventId: messageId, reaction } },
|
|
1343
|
+
});
|
|
1344
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
1345
|
+
};
|
|
1346
|
+
/**
|
|
1347
|
+
* Used to send a typed message into a channel stream.
|
|
1348
|
+
* The message will be serialized to JSON using superjson and then encoded to bytes.
|
|
1349
|
+
* Clients can agree on the schema to deserialize the message by the typeUrl.
|
|
1350
|
+
* @param streamId - The stream ID to send the message to.
|
|
1351
|
+
* @param typeUrl - A schema type URL for the message
|
|
1352
|
+
* @param message - The message to send as raw bytes.
|
|
1353
|
+
* @param tags - The tags to send with the message.
|
|
1354
|
+
* @returns The event ID of the sent message.
|
|
1355
|
+
*/
|
|
1356
|
+
async function sendGM(streamId, typeUrl, schema, data, opts, tags) {
|
|
1357
|
+
const result = await schema['~standard'].validate(data);
|
|
1358
|
+
if ('issues' in result && result.issues) {
|
|
1359
|
+
throw new Error(`Schema validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`);
|
|
1360
|
+
}
|
|
1361
|
+
const jsonString = superjsonStringify(result.value);
|
|
1362
|
+
const jsonBytesMessage = new TextEncoder().encode(jsonString);
|
|
1363
|
+
const payload = create(ChannelMessageSchema, {
|
|
1364
|
+
payload: {
|
|
1365
|
+
case: 'post',
|
|
1366
|
+
value: {
|
|
1367
|
+
threadId: opts?.threadId,
|
|
1368
|
+
replyId: opts?.replyId,
|
|
1369
|
+
replyPreview: opts?.replyId ? '🙈' : undefined,
|
|
1370
|
+
threadPreview: opts?.threadId ? '🙉' : undefined,
|
|
1371
|
+
content: { case: 'gm', value: { typeUrl: typeUrl, value: jsonBytesMessage } },
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Used to send a custom message into a channel stream.
|
|
1379
|
+
* The messages will be a raw bytes.
|
|
1380
|
+
* Clients can agree on the schema to deserialize the message by the typeUrl.
|
|
1381
|
+
* @param streamId - The stream ID to send the message to.
|
|
1382
|
+
* @param typeUrl - A schema type URL for the message
|
|
1383
|
+
* @param message - The message to send as raw bytes.
|
|
1384
|
+
* @param tags - The tags to send with the message.
|
|
1385
|
+
* @returns The event ID of the sent message.
|
|
1386
|
+
*/
|
|
1387
|
+
const sendRawGM = async (streamId, typeUrl, message, opts, tags) => {
|
|
1388
|
+
const payload = create(ChannelMessageSchema, {
|
|
1389
|
+
payload: {
|
|
1390
|
+
case: 'post',
|
|
1391
|
+
value: {
|
|
1392
|
+
threadId: opts?.threadId,
|
|
1393
|
+
replyId: opts?.replyId,
|
|
1394
|
+
replyPreview: opts?.replyId ? '🙈' : undefined,
|
|
1395
|
+
threadPreview: opts?.threadId ? '🙉' : undefined,
|
|
1396
|
+
content: { case: 'gm', value: { typeUrl: typeUrl, value: message } },
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
|
|
1401
|
+
};
|
|
1402
|
+
const removeEvent = async (streamId, messageId, tags) => {
|
|
1403
|
+
const payload = create(ChannelMessageSchema, {
|
|
1404
|
+
payload: { case: 'redaction', value: { refEventId: messageId } },
|
|
1405
|
+
});
|
|
1406
|
+
return sendMessageEvent({ streamId, payload, tags });
|
|
1407
|
+
};
|
|
1408
|
+
/**
|
|
1409
|
+
* Pin a message to a stream
|
|
1410
|
+
* @param streamId - The stream ID to pin the message to
|
|
1411
|
+
* @param eventId - The event ID of the message to pin
|
|
1412
|
+
* @param streamEvent - The stream event to pin
|
|
1413
|
+
* @returns The event ID of the pinned message
|
|
1414
|
+
*/
|
|
1415
|
+
const pinMessage = async (streamId, eventId, streamEvent) => {
|
|
1416
|
+
return client.sendEvent(streamId, make_MemberPayload_Pin(bin_fromHexString(eventId), streamEvent));
|
|
1417
|
+
};
|
|
1418
|
+
/**
|
|
1419
|
+
* Unpin a message from a stream
|
|
1420
|
+
* @param streamId - The stream ID to unpin the message from
|
|
1421
|
+
* @param eventId - The event ID of the message to unpin
|
|
1422
|
+
* @returns The event ID of the unpinned message
|
|
1423
|
+
*/
|
|
1424
|
+
const unpinMessage = async (streamId, eventId) => {
|
|
1425
|
+
return client.sendEvent(streamId, make_MemberPayload_Unpin(bin_fromHexString(eventId)));
|
|
1426
|
+
};
|
|
1427
|
+
const getChannelSettings = async (channelId) => {
|
|
1428
|
+
const spaceId = spaceIdFromChannelId(channelId);
|
|
1429
|
+
const streamView = await client.getStream(spaceId);
|
|
1430
|
+
const channel = streamView.spaceContent.spaceChannelsMetadata[channelId];
|
|
1431
|
+
return channel;
|
|
1432
|
+
};
|
|
1433
|
+
// Implementation
|
|
1434
|
+
async function sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, optsOrTags, maybeTags) {
|
|
1435
|
+
// Detect which format is being used
|
|
1436
|
+
let content;
|
|
1437
|
+
let recipient;
|
|
1438
|
+
let opts;
|
|
1439
|
+
let tags;
|
|
1440
|
+
if (isFlattenedRequest(contentOrPayload)) {
|
|
1441
|
+
// New flattened format
|
|
1442
|
+
content = flattenedToPayloadContent(contentOrPayload);
|
|
1443
|
+
recipient = contentOrPayload.recipient
|
|
1444
|
+
? bin_fromHexString(contentOrPayload.recipient)
|
|
1445
|
+
: undefined;
|
|
1446
|
+
opts = recipientOrOpts;
|
|
1447
|
+
tags = optsOrTags;
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
// Old format
|
|
1451
|
+
content = contentOrPayload;
|
|
1452
|
+
recipient = recipientOrOpts;
|
|
1453
|
+
opts = optsOrTags;
|
|
1454
|
+
tags = maybeTags;
|
|
1455
|
+
}
|
|
1456
|
+
// Get encryption settings (same as sendMessageEvent)
|
|
1457
|
+
const miniblockInfo = await client.getMiniblockInfo(streamId);
|
|
1458
|
+
const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
|
|
1459
|
+
? miniblockInfo.encryptionAlgorithm.algorithm
|
|
1460
|
+
: client.defaultGroupEncryptionAlgorithm;
|
|
1461
|
+
await ensureOutboundSession(streamId, encryptionAlgorithm, recipient ? [userIdFromAddress(recipient)] : [], miniblockInfo);
|
|
1462
|
+
// Create payload with content and encryption device for response
|
|
1463
|
+
const payload = create(InteractionRequestPayloadSchema, {
|
|
1464
|
+
encryptionDevice: client.crypto.getUserDevice(),
|
|
1465
|
+
content: content,
|
|
1466
|
+
});
|
|
1467
|
+
// Encrypt using group encryption (same as messages)
|
|
1468
|
+
const encryptedData = await client.crypto.encryptGroupEvent(streamId, toBinary(InteractionRequestPayloadSchema, payload), encryptionAlgorithm);
|
|
1469
|
+
// Create the request matching InteractionResponse structure
|
|
1470
|
+
const request = {
|
|
1471
|
+
recipient: recipient,
|
|
1472
|
+
encryptedData: encryptedData,
|
|
1473
|
+
threadId: opts?.threadId ? bin_fromHexString(opts.threadId) : undefined,
|
|
1474
|
+
};
|
|
1475
|
+
// Send as InteractionRequest
|
|
1476
|
+
const eventPayload = make_payload_InteractionRequest(streamId, request);
|
|
1477
|
+
return client.sendEvent(streamId, eventPayload, tags, opts?.ephemeral);
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Send a blockchain transaction to the stream
|
|
1481
|
+
* @param streamId - The stream ID to send the transaction to
|
|
1482
|
+
* @param chainId - The chain ID where the transaction occurred
|
|
1483
|
+
* @param receipt - The transaction receipt from the blockchain
|
|
1484
|
+
* @param content - The transaction content (tip, transfer, etc.)
|
|
1485
|
+
* @returns The transaction hash and event ID
|
|
1486
|
+
*/
|
|
1487
|
+
const sendBlockchainTransaction = async (chainId, receipt, content, tags) => {
|
|
1488
|
+
const transaction = create(BlockchainTransactionSchema, {
|
|
1489
|
+
receipt: {
|
|
1490
|
+
chainId: BigInt(chainId),
|
|
1491
|
+
transactionHash: bin_fromHexString(receipt.transactionHash),
|
|
1492
|
+
blockNumber: receipt.blockNumber,
|
|
1493
|
+
to: bin_fromHexString(receipt.to || zeroAddress),
|
|
1494
|
+
from: bin_fromHexString(receipt.from),
|
|
1495
|
+
logs: receipt.logs.map((log) => ({
|
|
1496
|
+
address: bin_fromHexString(log.address),
|
|
1497
|
+
topics: log.topics.map((topic) => bin_fromHexString(topic)),
|
|
1498
|
+
data: bin_fromHexString(log.data),
|
|
1499
|
+
})),
|
|
1500
|
+
},
|
|
1501
|
+
solanaReceipt: undefined,
|
|
1502
|
+
content: content ?? { case: undefined },
|
|
1503
|
+
});
|
|
1504
|
+
const result = await client.sendEvent(makeUserStreamId(client.userId), make_UserPayload_BlockchainTransaction(transaction), tags);
|
|
1505
|
+
return { txHash: receipt.transactionHash, eventId: result.eventId };
|
|
1506
|
+
};
|
|
1507
|
+
/** Sends a tip to a user.
|
|
1508
|
+
* Tip will always get funds from the app account balance.
|
|
1509
|
+
* @param params - Tip parameters including recipient, amount, messageId, channelId, currency.
|
|
1510
|
+
* @returns The transaction hash and event ID
|
|
1511
|
+
*/
|
|
1512
|
+
const sendTipImpl = async (params, tags) => {
|
|
1513
|
+
const currency = params.currency ?? ETH_ADDRESS;
|
|
1514
|
+
const isEth = currency === ETH_ADDRESS;
|
|
1515
|
+
const { receiver, amount, messageId, channelId } = params;
|
|
1516
|
+
const isDm = isDMChannelStreamId(channelId);
|
|
1517
|
+
const isGdm = isGDMChannelStreamId(channelId);
|
|
1518
|
+
const accountModulesAddress = client.config.base.chainConfig.addresses.accountModules;
|
|
1519
|
+
if ((isDm || isGdm) && !accountModulesAddress) {
|
|
1520
|
+
throw new Error('AccountModules address is not configured for DM/GDM tips');
|
|
1521
|
+
}
|
|
1522
|
+
let recipientType;
|
|
1523
|
+
let encodedData;
|
|
1524
|
+
let targetContract;
|
|
1525
|
+
let tokenId;
|
|
1526
|
+
if (isDm || isGdm) {
|
|
1527
|
+
// DM tips use AnyTipParams sent to AccountModules contract
|
|
1528
|
+
recipientType = TipRecipientType.Any;
|
|
1529
|
+
const sender = appAddress; // msg.sender when contract executes
|
|
1530
|
+
const data = encodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }], [`0x${messageId}`, `0x${channelId}`]);
|
|
1531
|
+
encodedData = encodeAbiParameters([
|
|
1532
|
+
{
|
|
1533
|
+
type: 'tuple',
|
|
1534
|
+
components: [
|
|
1535
|
+
{ name: 'currency', type: 'address' },
|
|
1536
|
+
{ name: 'sender', type: 'address' },
|
|
1537
|
+
{ name: 'receiver', type: 'address' },
|
|
1538
|
+
{ name: 'amount', type: 'uint256' },
|
|
1539
|
+
{ name: 'data', type: 'bytes' },
|
|
1540
|
+
],
|
|
1541
|
+
},
|
|
1542
|
+
], [
|
|
1543
|
+
{
|
|
1544
|
+
currency,
|
|
1545
|
+
sender,
|
|
1546
|
+
receiver,
|
|
1547
|
+
amount,
|
|
1548
|
+
data,
|
|
1549
|
+
},
|
|
1550
|
+
]);
|
|
1551
|
+
targetContract = accountModulesAddress;
|
|
1552
|
+
tokenId = undefined;
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
// Member tips use MembershipTipParams sent to Space contract
|
|
1556
|
+
recipientType = TipRecipientType.Member;
|
|
1557
|
+
const spaceId = spaceIdFromChannelId(channelId);
|
|
1558
|
+
tokenId = await spaceDapp.getTokenIdOfOwner(spaceId, receiver);
|
|
1559
|
+
if (!tokenId) {
|
|
1560
|
+
throw new Error(`No token ID found for user ${receiver} in space ${spaceId}`);
|
|
1561
|
+
}
|
|
1562
|
+
const metadataData = encodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }, { type: 'uint256' }], [`0x${messageId}`, `0x${channelId}`, BigInt(tokenId)]);
|
|
1563
|
+
encodedData = encodeAbiParameters([
|
|
1564
|
+
{
|
|
1565
|
+
type: 'tuple',
|
|
1566
|
+
components: [
|
|
1567
|
+
{ name: 'receiver', type: 'address' },
|
|
1568
|
+
{ name: 'tokenId', type: 'uint256' },
|
|
1569
|
+
{ name: 'currency', type: 'address' },
|
|
1570
|
+
{ name: 'amount', type: 'uint256' },
|
|
1571
|
+
{
|
|
1572
|
+
name: 'metadata',
|
|
1573
|
+
type: 'tuple',
|
|
1574
|
+
components: [
|
|
1575
|
+
{ name: 'messageId', type: 'bytes32' },
|
|
1576
|
+
{ name: 'channelId', type: 'bytes32' },
|
|
1577
|
+
{ name: 'data', type: 'bytes' },
|
|
1578
|
+
],
|
|
1579
|
+
},
|
|
1580
|
+
],
|
|
1581
|
+
},
|
|
1582
|
+
], [
|
|
1583
|
+
{
|
|
1584
|
+
receiver,
|
|
1585
|
+
tokenId: BigInt(tokenId),
|
|
1586
|
+
currency,
|
|
1587
|
+
amount,
|
|
1588
|
+
metadata: {
|
|
1589
|
+
messageId: `0x${messageId}`,
|
|
1590
|
+
channelId: `0x${channelId}`,
|
|
1591
|
+
data: metadataData,
|
|
1592
|
+
},
|
|
1593
|
+
},
|
|
1594
|
+
]);
|
|
1595
|
+
targetContract = SpaceAddressFromSpaceId(spaceId);
|
|
1596
|
+
}
|
|
1597
|
+
let hash;
|
|
1598
|
+
if (isEth) {
|
|
1599
|
+
hash = await writeContract(viem, {
|
|
1600
|
+
address: targetContract,
|
|
1601
|
+
abi: tippingFacetAbi,
|
|
1602
|
+
functionName: 'sendTip',
|
|
1603
|
+
args: [recipientType, encodedData],
|
|
1604
|
+
value: amount,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
const approveHash = await writeContract(viem, {
|
|
1609
|
+
address: currency,
|
|
1610
|
+
abi: erc20Abi,
|
|
1611
|
+
functionName: 'approve',
|
|
1612
|
+
args: [targetContract, amount],
|
|
1613
|
+
});
|
|
1614
|
+
await waitForTransactionReceipt(viem, {
|
|
1615
|
+
hash: approveHash,
|
|
1616
|
+
confirmations: 3,
|
|
1617
|
+
});
|
|
1618
|
+
hash = await writeContract(viem, {
|
|
1619
|
+
address: targetContract,
|
|
1620
|
+
abi: tippingFacetAbi,
|
|
1621
|
+
functionName: 'sendTip',
|
|
1622
|
+
args: [recipientType, encodedData],
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
const receipt = await waitForTransactionReceipt(viem, { hash, confirmations: 3 });
|
|
1626
|
+
if (receipt.status !== 'success') {
|
|
1627
|
+
throw new Error(`Tip transaction failed: ${hash}`);
|
|
1628
|
+
}
|
|
1629
|
+
const tipEvent = parseEventLogs({
|
|
1630
|
+
abi: tippingFacetAbi,
|
|
1631
|
+
logs: receipt.logs,
|
|
1632
|
+
eventName: 'TipSent',
|
|
1633
|
+
})[0];
|
|
1634
|
+
return sendBlockchainTransaction(viem.chain.id, receipt, {
|
|
1635
|
+
case: 'tip',
|
|
1636
|
+
value: {
|
|
1637
|
+
event: {
|
|
1638
|
+
tokenId: tokenId ? BigInt(tokenId) : undefined,
|
|
1639
|
+
currency: bin_fromHexString(tipEvent.args.currency),
|
|
1640
|
+
sender: bin_fromHexString(tipEvent.args.sender),
|
|
1641
|
+
receiver: bin_fromHexString(tipEvent.args.receiver),
|
|
1642
|
+
amount: tipEvent.args.amount,
|
|
1643
|
+
messageId: bin_fromHexString(messageId),
|
|
1644
|
+
channelId: bin_fromHexString(channelId),
|
|
1645
|
+
},
|
|
1646
|
+
toUserAddress: bin_fromHexString(params.receiverUserId),
|
|
1647
|
+
},
|
|
1648
|
+
}, {
|
|
1649
|
+
groupMentionTypes: tags?.groupMentionTypes || [],
|
|
1650
|
+
mentionedUserAddresses: tags?.mentionedUserAddresses || [],
|
|
1651
|
+
threadId: tags?.threadId,
|
|
1652
|
+
appClientAddress: tags?.appClientAddress,
|
|
1653
|
+
messageInteractionType: MessageInteractionType.TIP,
|
|
1654
|
+
participatingUserAddresses: [bin_fromHexString(params.receiverUserId)],
|
|
1655
|
+
});
|
|
1656
|
+
};
|
|
1657
|
+
/** Sends a tip to a user by looking up their smart account by userId.
|
|
1658
|
+
* Tip will always get funds from the app account balance.
|
|
1659
|
+
* @param params - Tip parameters including userId, amount, messageId, channelId, currency.
|
|
1660
|
+
* @returns The transaction hash and event ID
|
|
1661
|
+
*/
|
|
1662
|
+
const sendTip = async (params, tags) => {
|
|
1663
|
+
const smartAccountAddress = await getSmartAccountFromUserIdImpl(client.config.base.chainConfig.addresses.spaceFactory, viem, params.userId);
|
|
1664
|
+
return sendTipImpl({
|
|
1665
|
+
...params,
|
|
1666
|
+
receiver: smartAccountAddress ?? params.userId,
|
|
1667
|
+
receiverUserId: params.userId,
|
|
1668
|
+
}, tags);
|
|
1669
|
+
};
|
|
1670
|
+
return {
|
|
1671
|
+
sendMessage,
|
|
1672
|
+
editMessage,
|
|
1673
|
+
sendReaction,
|
|
1674
|
+
sendInteractionRequest,
|
|
1675
|
+
sendGM,
|
|
1676
|
+
sendRawGM,
|
|
1677
|
+
removeEvent,
|
|
1678
|
+
sendKeySolicitation,
|
|
1679
|
+
uploadDeviceKeys,
|
|
1680
|
+
pinMessage,
|
|
1681
|
+
unpinMessage,
|
|
1682
|
+
getChannelSettings,
|
|
1683
|
+
sendBlockchainTransaction,
|
|
1684
|
+
sendTip,
|
|
1685
|
+
};
|
|
1686
|
+
};
|
|
1687
|
+
/**
|
|
1688
|
+
* Given a slash command message, returns the command and the arguments
|
|
1689
|
+
* @example
|
|
1690
|
+
* ```
|
|
1691
|
+
* /help
|
|
1692
|
+
* args: []
|
|
1693
|
+
* ```
|
|
1694
|
+
* ```
|
|
1695
|
+
* /sum 1 2
|
|
1696
|
+
* args: ['1', '2']
|
|
1697
|
+
* ```
|
|
1698
|
+
*/
|
|
1699
|
+
const parseSlashCommand = (message) => {
|
|
1700
|
+
const parts = message.split(' ');
|
|
1701
|
+
const commandWithSlash = parts[0];
|
|
1702
|
+
const command = commandWithSlash.substring(1);
|
|
1703
|
+
const args = parts.slice(1);
|
|
1704
|
+
return { command, args };
|
|
1705
|
+
};
|
|
1706
|
+
const parseMentions = (mentions) =>
|
|
1707
|
+
// Agents doesn't care about @channel or @role mentions
|
|
1708
|
+
mentions.flatMap((m) => m.mentionBehavior.case === undefined
|
|
1709
|
+
? [{ userId: m.userId, displayName: m.displayName }]
|
|
1710
|
+
: []);
|
|
1711
|
+
const processMentions = (mentions) => {
|
|
1712
|
+
if (!mentions) {
|
|
1713
|
+
return [];
|
|
1714
|
+
}
|
|
1715
|
+
return mentions.map((mention) => {
|
|
1716
|
+
if ('userId' in mention) {
|
|
1717
|
+
return create(ChannelMessage_Post_MentionSchema, {
|
|
1718
|
+
userId: mention.userId,
|
|
1719
|
+
displayName: mention.displayName,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
else if ('roleId' in mention) {
|
|
1723
|
+
return create(ChannelMessage_Post_MentionSchema, {
|
|
1724
|
+
mentionBehavior: {
|
|
1725
|
+
case: 'atRole',
|
|
1726
|
+
value: {
|
|
1727
|
+
roleId: mention.roleId,
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
else if ('atChannel' in mention) {
|
|
1733
|
+
return create(ChannelMessage_Post_MentionSchema, {
|
|
1734
|
+
mentionBehavior: {
|
|
1735
|
+
case: 'atChannel',
|
|
1736
|
+
value: create(EmptySchema, {}),
|
|
1737
|
+
},
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
throw new Error(`Invalid mention type: ${JSON.stringify(mention)}`);
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
};
|
|
1745
|
+
const createBasePayload = (userId, streamId, eventId, createdAt, event) => {
|
|
1746
|
+
const isDm = isDMChannelStreamId(streamId);
|
|
1747
|
+
const isGdm = isGDMChannelStreamId(streamId);
|
|
1748
|
+
return {
|
|
1749
|
+
userId,
|
|
1750
|
+
channelId: streamId,
|
|
1751
|
+
eventId,
|
|
1752
|
+
createdAt,
|
|
1753
|
+
event,
|
|
1754
|
+
isDm,
|
|
1755
|
+
isGdm,
|
|
1756
|
+
};
|
|
1757
|
+
};
|
|
1758
|
+
//# sourceMappingURL=agent.js.map
|