-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.mjs
More file actions
246 lines (212 loc) · 7.57 KB
/
server.mjs
File metadata and controls
246 lines (212 loc) · 7.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
/**
* XMTP Proxy Server
* Runs on Mac mini, handles all XMTP operations.
* Vercel API routes proxy to this server.
*/
import express from 'express';
import cors from 'cors';
import { Client } from '@xmtp/node-sdk';
import { privateKeyToAccount } from 'viem/accounts';
import { toBytes } from 'viem';
const PORT = process.env.PORT || process.env.XMTP_PROXY_PORT || 3847;
const API_SECRET = process.env.XMTP_PROXY_SECRET || 'changeme';
const ADMIN_KEY = process.env.XMTP_ADMIN_PRIVATE_KEY;
if (!ADMIN_KEY) {
console.error('XMTP_ADMIN_PRIVATE_KEY required');
process.exit(1);
}
const app = express();
app.use(cors());
app.use(express.json());
// Auth middleware — Vercel routes must send this secret
function authMiddleware(req, res, next) {
const secret = req.headers['x-proxy-secret'];
if (secret !== API_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
app.use(authMiddleware);
// --- XMTP Client ---
let adminClient = null;
const account = privateKeyToAccount(ADMIN_KEY);
function getAdminSigner() {
return {
type: 'EOA',
getIdentifier: () => ({
identifier: account.address.toLowerCase(),
identifierKind: 0,
}),
signMessage: async (message) => {
const signature = await account.signMessage({ message });
return toBytes(signature);
},
};
}
async function getClient() {
if (adminClient) return adminClient;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
console.log(`[XMTP] Initializing admin client (attempt ${attempt})...`);
adminClient = await Client.create(getAdminSigner(), {
env: 'production',
appVersion: 'claws/1.0.0',
});
console.log('[XMTP] Admin client ready');
return adminClient;
} catch (err) {
console.error(`[XMTP] Init attempt ${attempt} failed:`, err.message);
if (attempt < 3) await new Promise(r => setTimeout(r, 2000 * attempt));
}
}
throw new Error('Failed to initialize XMTP client after 3 attempts');
}
function ethId(address) {
return { identifier: address.toLowerCase(), identifierKind: 0 };
}
// --- Groups cache (handle -> groupId) ---
const groupCache = new Map();
// --- Routes ---
// Health check
app.get('/health', (_req, res) => {
res.json({ ok: true, uptime: process.uptime() });
});
// Check if address can receive XMTP
app.post('/can-message', async (req, res) => {
try {
const { address } = req.body;
const canMessage = await Client.canMessage([ethId(address)]);
const reachable = canMessage.get(address.toLowerCase()) ?? false;
res.json({ reachable });
} catch (err) {
console.error('[can-message]', err.message);
res.status(500).json({ error: err.message });
}
});
// Create or get group
app.post('/group', async (req, res) => {
try {
const { handle, name, description } = req.body;
const handleLower = handle.toLowerCase();
// Check cache
if (groupCache.has(handleLower)) {
return res.json({ groupId: groupCache.get(handleLower) });
}
const client = await getClient();
// Try to find existing group by listing conversations
// For now just create — caller should track groupId in DB
const group = await client.conversations.createGroup([], {
groupName: name || `🦞 @${handle} holders`,
groupDescription: description || `Token-gated chat for @${handle} claw holders on claws.tech`,
});
groupCache.set(handleLower, group.id);
console.log(`[XMTP] Created group for @${handle}: ${group.id}`);
res.json({ groupId: group.id });
} catch (err) {
console.error('[group]', err.message);
res.status(500).json({ error: err.message });
}
});
// Add member to group
app.post('/add-member', async (req, res) => {
try {
const { groupId, address } = req.body;
const client = await getClient();
// Check reachability
const canMessage = await Client.canMessage([ethId(address)]);
if (!canMessage.get(address.toLowerCase())) {
return res.json({ added: false, reason: 'not-on-xmtp' });
}
const convo = await client.conversations.getConversationById(groupId);
if (!convo) return res.status(404).json({ error: 'Group not found' });
await convo.addMembers([ethId(address)]);
res.json({ added: true });
} catch (err) {
console.error('[add-member]', err.message);
res.status(500).json({ error: err.message });
}
});
// Remove member
app.post('/remove-member', async (req, res) => {
try {
const { groupId, address } = req.body;
const client = await getClient();
const convo = await client.conversations.getConversationById(groupId);
if (!convo) return res.status(404).json({ error: 'Group not found' });
await convo.removeMembers([ethId(address)]);
res.json({ removed: true });
} catch (err) {
console.error('[remove-member]', err.message);
res.status(500).json({ error: err.message });
}
});
// Check membership
app.post('/is-member', async (req, res) => {
try {
const { groupId, address } = req.body;
const client = await getClient();
const convo = await client.conversations.getConversationById(groupId);
if (!convo) return res.json({ isMember: false });
await convo.sync();
const members = await convo.listMembers();
const isMember = members.some(m => {
const ids = m.accountIdentifiers || [];
return ids.some(id => id.identifier?.toLowerCase() === address.toLowerCase());
});
res.json({ isMember });
} catch (err) {
console.error('[is-member]', err.message);
res.status(500).json({ error: err.message });
}
});
// Send message
app.post('/send', async (req, res) => {
try {
const { groupId, senderAddress, content } = req.body;
const client = await getClient();
const convo = await client.conversations.getConversationById(groupId);
if (!convo) return res.status(404).json({ error: 'Group not found' });
// Prefix with sender address so recipients know who sent it
const formatted = `${senderAddress.slice(0, 6)}...${senderAddress.slice(-4)}: ${content}`;
const messageId = await convo.sendText(formatted);
console.log(`[XMTP] Message sent in group ${groupId}: ${messageId}`);
res.json({ messageId, success: true });
} catch (err) {
console.error('[send]', err.message);
res.status(500).json({ error: err.message });
}
});
// Get messages
app.post('/messages', async (req, res) => {
try {
const { groupId, limit = 50 } = req.body;
const client = await getClient();
const convo = await client.conversations.getConversationById(groupId);
if (!convo) return res.status(404).json({ error: 'Group not found' });
await convo.sync();
const messages = await convo.messages({ limit });
const filtered = messages
.filter(m => String(m.kind) === '0' || String(m.kind) === 'application' || String(m.kind) === 'Application')
.map(m => ({
id: m.id,
senderInboxId: m.senderInboxId,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
sentAt: m.sentAt?.toISOString() || new Date().toISOString(),
}));
res.json({ messages: filtered });
} catch (err) {
console.error('[messages]', err.message);
res.status(500).json({ error: err.message });
}
});
// --- Start ---
// Start server immediately, lazy-init XMTP client on first request
app.listen(PORT, () => {
console.log(`[XMTP Proxy] Running on port ${PORT}`);
// Try to pre-init client in background (non-blocking)
getClient().then(() => {
console.log('[XMTP Proxy] Client pre-initialized');
}).catch(err => {
console.warn('[XMTP Proxy] Pre-init failed, will retry on first request:', err.message);
});
});