@rizvanua/contact-chat 0.1.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/LICENSE +21 -0
- package/README.md +496 -0
- package/dist/index.cjs +704 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +688 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +326 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +118 -0
- package/dist/react/index.d.ts +118 -0
- package/dist/react/index.js +306 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.cjs +704 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +98 -0
- package/dist/server/index.d.ts +98 -0
- package/dist/server/index.js +688 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/nextjs.cjs +10 -0
- package/dist/server/nextjs.cjs.map +1 -0
- package/dist/server/nextjs.d.cts +17 -0
- package/dist/server/nextjs.d.ts +17 -0
- package/dist/server/nextjs.js +8 -0
- package/dist/server/nextjs.js.map +1 -0
- package/dist/stores/index.cjs +244 -0
- package/dist/stores/index.cjs.map +1 -0
- package/dist/stores/index.d.cts +58 -0
- package/dist/stores/index.d.ts +58 -0
- package/dist/stores/index.js +241 -0
- package/dist/stores/index.js.map +1 -0
- package/dist/transports/index.cjs +156 -0
- package/dist/transports/index.cjs.map +1 -0
- package/dist/transports/index.d.cts +48 -0
- package/dist/transports/index.d.ts +48 -0
- package/dist/transports/index.js +151 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/types-BcoqxSLg.d.cts +42 -0
- package/dist/types-BcoqxSLg.d.ts +42 -0
- package/dist/ui/index.cjs +609 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +127 -0
- package/dist/ui/index.d.ts +127 -0
- package/dist/ui/index.js +604 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +146 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { Redis } from '@upstash/redis';
|
|
3
|
+
|
|
4
|
+
// src/core/types.ts
|
|
5
|
+
var MAX_MESSAGE_LENGTH = 1e3;
|
|
6
|
+
var MAX_MESSAGES_PER_SESSION = 40;
|
|
7
|
+
var SESSION_TTL_SECONDS = 604800;
|
|
8
|
+
var RATE_PER_MINUTE = 5;
|
|
9
|
+
var RATE_PER_DAY = 30;
|
|
10
|
+
var DEFAULT_CHAT_LIMITS = {
|
|
11
|
+
maxMessageLength: MAX_MESSAGE_LENGTH,
|
|
12
|
+
maxMessagesPerSession: MAX_MESSAGES_PER_SESSION,
|
|
13
|
+
sessionTtlSeconds: SESSION_TTL_SECONDS,
|
|
14
|
+
ratePerMinute: RATE_PER_MINUTE,
|
|
15
|
+
ratePerDay: RATE_PER_DAY
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/core/config.ts
|
|
19
|
+
var DEFAULT_CHAT_SERVER_COPY = {
|
|
20
|
+
validation: {
|
|
21
|
+
malformedBody: "Malformed request body.",
|
|
22
|
+
missingSession: "Missing session.",
|
|
23
|
+
rejected: "Rejected.",
|
|
24
|
+
messageRequired: "Message is required.",
|
|
25
|
+
messageEmpty: "Message is empty.",
|
|
26
|
+
messageTooLong: (limit) => `Please keep it under ${limit} characters.`,
|
|
27
|
+
nameRequired: "Please tell me your name first."
|
|
28
|
+
},
|
|
29
|
+
rateLimited: "That is a lot of messages \u2014 please wait a moment.",
|
|
30
|
+
messageLimitReached: "This conversation has reached its message limit.",
|
|
31
|
+
storeUnavailable: "Chat is unavailable right now.",
|
|
32
|
+
telegramUnavailable: "Could not deliver that message."
|
|
33
|
+
};
|
|
34
|
+
function resolveConfig(config) {
|
|
35
|
+
return {
|
|
36
|
+
store: config.store,
|
|
37
|
+
transport: config.transport,
|
|
38
|
+
ipHashSalt: config.ipHashSalt,
|
|
39
|
+
limits: { ...DEFAULT_CHAT_LIMITS, ...config.limits },
|
|
40
|
+
copy: {
|
|
41
|
+
...DEFAULT_CHAT_SERVER_COPY,
|
|
42
|
+
...config.copy,
|
|
43
|
+
validation: {
|
|
44
|
+
...DEFAULT_CHAT_SERVER_COPY.validation,
|
|
45
|
+
...config.copy?.validation ?? {}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
onError: config.onError ?? (() => {
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
var MINUTE_SECONDS = 60;
|
|
53
|
+
var DAY_SECONDS = 86400;
|
|
54
|
+
function getClientIp(headers) {
|
|
55
|
+
const forwarded = headers.get("x-forwarded-for");
|
|
56
|
+
if (forwarded) {
|
|
57
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
58
|
+
if (first) return first;
|
|
59
|
+
}
|
|
60
|
+
return headers.get("x-real-ip")?.trim() || "unknown";
|
|
61
|
+
}
|
|
62
|
+
function hashIp(ip, salt) {
|
|
63
|
+
return createHash("sha256").update(`${ip}:${salt}`).digest("hex").slice(0, 32);
|
|
64
|
+
}
|
|
65
|
+
async function checkRateLimit(ip, deps) {
|
|
66
|
+
const id = hashIp(ip, deps.ipHashSalt);
|
|
67
|
+
const perMinute = await deps.store.incrementCounter(`chat:rate:${id}:min`, MINUTE_SECONDS);
|
|
68
|
+
if (perMinute > deps.limits.ratePerMinute) {
|
|
69
|
+
return { allowed: false, retryAfterSeconds: MINUTE_SECONDS };
|
|
70
|
+
}
|
|
71
|
+
const perDay = await deps.store.incrementCounter(`chat:rate:${id}:day`, DAY_SECONDS);
|
|
72
|
+
if (perDay > deps.limits.ratePerDay) {
|
|
73
|
+
return { allowed: false, retryAfterSeconds: DAY_SECONDS };
|
|
74
|
+
}
|
|
75
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/core/name.ts
|
|
79
|
+
var MAX_NAME_LENGTH = 60;
|
|
80
|
+
function sanitizeName(raw) {
|
|
81
|
+
if (typeof raw !== "string") return "";
|
|
82
|
+
return raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_NAME_LENGTH);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/core/handlers.ts
|
|
86
|
+
var TransportFailure = class extends Error {
|
|
87
|
+
constructor(cause) {
|
|
88
|
+
super("transport failure");
|
|
89
|
+
this.cause = cause;
|
|
90
|
+
}
|
|
91
|
+
cause;
|
|
92
|
+
};
|
|
93
|
+
async function callTransport(op) {
|
|
94
|
+
try {
|
|
95
|
+
return await op();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new TransportFailure(error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function fail(status, body, headers) {
|
|
101
|
+
return Response.json(body, { status, headers });
|
|
102
|
+
}
|
|
103
|
+
function createSendHandler(resolved) {
|
|
104
|
+
const { store, transport, limits, copy, ipHashSalt, onError } = resolved;
|
|
105
|
+
return async function sendHandler(request) {
|
|
106
|
+
let payload;
|
|
107
|
+
try {
|
|
108
|
+
payload = await request.json();
|
|
109
|
+
} catch {
|
|
110
|
+
return fail(400, { code: "validation", error: copy.validation.malformedBody });
|
|
111
|
+
}
|
|
112
|
+
const { sessionId, text, name, honeypot } = payload ?? {};
|
|
113
|
+
if (typeof sessionId !== "string" || sessionId.length < 8) {
|
|
114
|
+
return fail(400, { code: "validation", error: copy.validation.missingSession });
|
|
115
|
+
}
|
|
116
|
+
if (typeof honeypot === "string" && honeypot.length > 0) {
|
|
117
|
+
return fail(400, { code: "validation", error: copy.validation.rejected });
|
|
118
|
+
}
|
|
119
|
+
if (typeof text !== "string") {
|
|
120
|
+
return fail(400, { code: "validation", error: copy.validation.messageRequired });
|
|
121
|
+
}
|
|
122
|
+
const trimmed = text.trim();
|
|
123
|
+
if (trimmed.length === 0) {
|
|
124
|
+
return fail(400, { code: "validation", error: copy.validation.messageEmpty });
|
|
125
|
+
}
|
|
126
|
+
if (trimmed.length > limits.maxMessageLength) {
|
|
127
|
+
return fail(400, {
|
|
128
|
+
code: "validation",
|
|
129
|
+
error: copy.validation.messageTooLong(limits.maxMessageLength)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const limit = await checkRateLimit(getClientIp(request.headers), {
|
|
134
|
+
store,
|
|
135
|
+
ipHashSalt,
|
|
136
|
+
limits
|
|
137
|
+
});
|
|
138
|
+
if (!limit.allowed) {
|
|
139
|
+
return fail(
|
|
140
|
+
429,
|
|
141
|
+
{
|
|
142
|
+
code: "rate_limited",
|
|
143
|
+
error: copy.rateLimited
|
|
144
|
+
},
|
|
145
|
+
{ "Retry-After": String(limit.retryAfterSeconds) }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
let session = await store.getSession(sessionId);
|
|
149
|
+
if (session && session.msgCount >= limits.maxMessagesPerSession) {
|
|
150
|
+
return fail(400, {
|
|
151
|
+
code: "validation",
|
|
152
|
+
error: copy.messageLimitReached
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const userAgent = request.headers.get("user-agent");
|
|
156
|
+
if (!session) {
|
|
157
|
+
const visitorName = sanitizeName(name);
|
|
158
|
+
if (!visitorName) {
|
|
159
|
+
return fail(400, {
|
|
160
|
+
code: "validation",
|
|
161
|
+
error: copy.validation.nameRequired
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const topicId = await callTransport(
|
|
165
|
+
() => transport.createThread({ name: visitorName, userAgent, now: /* @__PURE__ */ new Date() })
|
|
166
|
+
);
|
|
167
|
+
session = await store.createSession(sessionId, topicId, visitorName);
|
|
168
|
+
} else if (!session.name) {
|
|
169
|
+
const visitorName = sanitizeName(name);
|
|
170
|
+
if (visitorName) {
|
|
171
|
+
const updated = await store.setSessionName(sessionId, visitorName);
|
|
172
|
+
if (updated) session = updated;
|
|
173
|
+
try {
|
|
174
|
+
await callTransport(
|
|
175
|
+
() => transport.renameThread(session.topicId, {
|
|
176
|
+
name: visitorName,
|
|
177
|
+
userAgent,
|
|
178
|
+
// First contact, matching how a fresh topic is stamped.
|
|
179
|
+
now: new Date(session.createdAt)
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
onError(
|
|
184
|
+
"chat.send.topicRename",
|
|
185
|
+
error instanceof TransportFailure ? error.cause : error
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await callTransport(() => transport.send(session.topicId, trimmed));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const inner = error instanceof TransportFailure ? error.cause : error;
|
|
194
|
+
if (!transport.isMissingThreadError(inner)) throw error;
|
|
195
|
+
onError(
|
|
196
|
+
"chat.send.topicRecreate",
|
|
197
|
+
`topic ${session.topicId} is gone, recreating for session ${sessionId}`
|
|
198
|
+
);
|
|
199
|
+
const topicId = await callTransport(
|
|
200
|
+
() => transport.createThread({
|
|
201
|
+
name: session.name ?? "Visitor",
|
|
202
|
+
userAgent,
|
|
203
|
+
now: /* @__PURE__ */ new Date()
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
session = await store.retopicSession(sessionId, session, topicId);
|
|
207
|
+
await callTransport(() => transport.send(topicId, trimmed));
|
|
208
|
+
}
|
|
209
|
+
const message = {
|
|
210
|
+
id: crypto.randomUUID(),
|
|
211
|
+
from: "visitor",
|
|
212
|
+
text: trimmed,
|
|
213
|
+
ts: Date.now()
|
|
214
|
+
};
|
|
215
|
+
const cursor = await store.appendMessage(sessionId, message);
|
|
216
|
+
const body = { message, cursor };
|
|
217
|
+
return Response.json(body);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof TransportFailure) {
|
|
220
|
+
onError("chat.send.telegram", error.cause);
|
|
221
|
+
return fail(502, {
|
|
222
|
+
code: "telegram_unavailable",
|
|
223
|
+
error: copy.telegramUnavailable
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
onError(
|
|
227
|
+
"chat.send",
|
|
228
|
+
error instanceof Error ? error.message : error
|
|
229
|
+
);
|
|
230
|
+
return fail(503, {
|
|
231
|
+
code: "store_unavailable",
|
|
232
|
+
error: copy.storeUnavailable
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function createPollHandler(resolved) {
|
|
238
|
+
const { store, copy } = resolved;
|
|
239
|
+
return async function pollHandler(request) {
|
|
240
|
+
const url = new URL(request.url);
|
|
241
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
242
|
+
const since = Number(url.searchParams.get("since") ?? "0");
|
|
243
|
+
if (!sessionId || sessionId.length < 8) {
|
|
244
|
+
const body = {
|
|
245
|
+
code: "validation",
|
|
246
|
+
error: copy.validation.missingSession
|
|
247
|
+
};
|
|
248
|
+
return Response.json(body, { status: 400 });
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const { messages, cursor } = await store.getMessagesSince(
|
|
252
|
+
sessionId,
|
|
253
|
+
Number.isFinite(since) ? since : 0
|
|
254
|
+
);
|
|
255
|
+
const body = { messages, cursor };
|
|
256
|
+
return Response.json(body, {
|
|
257
|
+
headers: { "Cache-Control": "no-store" }
|
|
258
|
+
});
|
|
259
|
+
} catch {
|
|
260
|
+
const body = {
|
|
261
|
+
code: "store_unavailable",
|
|
262
|
+
error: copy.storeUnavailable
|
|
263
|
+
};
|
|
264
|
+
return Response.json(body, { status: 503 });
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function createWebhookHandler(resolved) {
|
|
269
|
+
const { store, transport } = resolved;
|
|
270
|
+
const ok = () => Response.json({ ok: true });
|
|
271
|
+
return async function webhookHandler(request) {
|
|
272
|
+
if (!transport.verifyWebhook(request)) {
|
|
273
|
+
return Response.json({ ok: false }, { status: 401 });
|
|
274
|
+
}
|
|
275
|
+
let update;
|
|
276
|
+
try {
|
|
277
|
+
update = await request.json();
|
|
278
|
+
} catch {
|
|
279
|
+
return ok();
|
|
280
|
+
}
|
|
281
|
+
const reply = transport.parseInboundReply(update);
|
|
282
|
+
if (!reply) return ok();
|
|
283
|
+
try {
|
|
284
|
+
const sessionId = await store.resolveTopic(reply.threadId);
|
|
285
|
+
if (!sessionId) return ok();
|
|
286
|
+
const message = {
|
|
287
|
+
id: `tg-${reply.messageId}`,
|
|
288
|
+
from: "owner",
|
|
289
|
+
text: reply.text,
|
|
290
|
+
ts: Date.now()
|
|
291
|
+
};
|
|
292
|
+
await store.appendMessage(sessionId, message);
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
return ok();
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/core/index.ts
|
|
300
|
+
function createChatServer(config) {
|
|
301
|
+
const resolved = resolveConfig(config);
|
|
302
|
+
return {
|
|
303
|
+
send: createSendHandler(resolved),
|
|
304
|
+
poll: createPollHandler(resolved),
|
|
305
|
+
webhook: createWebhookHandler(resolved)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function upstashRedisStore(opts) {
|
|
309
|
+
const redis = new Redis({ url: opts.url, token: opts.token });
|
|
310
|
+
const ttlSeconds = opts.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;
|
|
311
|
+
const sessionKey = (sessionId) => `chat:sess:${sessionId}`;
|
|
312
|
+
const topicKey = (topicId) => `chat:topic:${topicId}`;
|
|
313
|
+
const messagesKey = (sessionId) => `chat:msgs:${sessionId}`;
|
|
314
|
+
async function writeSession(sessionId, session) {
|
|
315
|
+
await redis.set(sessionKey(sessionId), session, { ex: ttlSeconds });
|
|
316
|
+
await redis.set(topicKey(session.topicId), sessionId, {
|
|
317
|
+
ex: ttlSeconds
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
async getSession(sessionId) {
|
|
322
|
+
const raw = await redis.get(sessionKey(sessionId));
|
|
323
|
+
return raw ?? null;
|
|
324
|
+
},
|
|
325
|
+
async createSession(sessionId, topicId, name) {
|
|
326
|
+
const session = {
|
|
327
|
+
topicId,
|
|
328
|
+
createdAt: Date.now(),
|
|
329
|
+
msgCount: 0,
|
|
330
|
+
name
|
|
331
|
+
};
|
|
332
|
+
await writeSession(sessionId, session);
|
|
333
|
+
return session;
|
|
334
|
+
},
|
|
335
|
+
/**
|
|
336
|
+
* Adopts a name onto an existing session. Returns null when the session has
|
|
337
|
+
* expired between the caller's read and this write.
|
|
338
|
+
*/
|
|
339
|
+
async setSessionName(sessionId, name) {
|
|
340
|
+
const session = await this.getSession(sessionId);
|
|
341
|
+
if (!session) return null;
|
|
342
|
+
const next = { ...session, name };
|
|
343
|
+
await writeSession(sessionId, next);
|
|
344
|
+
return next;
|
|
345
|
+
},
|
|
346
|
+
/**
|
|
347
|
+
* Points a session at a replacement topic, dropping the reverse mapping for
|
|
348
|
+
* the old one so a recycled thread id can never resolve to a stale session.
|
|
349
|
+
*/
|
|
350
|
+
async retopicSession(sessionId, session, topicId) {
|
|
351
|
+
if (session.topicId !== topicId) {
|
|
352
|
+
await redis.del(topicKey(session.topicId));
|
|
353
|
+
}
|
|
354
|
+
const next = { ...session, topicId };
|
|
355
|
+
await writeSession(sessionId, next);
|
|
356
|
+
return next;
|
|
357
|
+
},
|
|
358
|
+
async resolveTopic(topicId) {
|
|
359
|
+
return await redis.get(topicKey(topicId)) ?? null;
|
|
360
|
+
},
|
|
361
|
+
/**
|
|
362
|
+
* Appends a message and returns the new cursor (the list length).
|
|
363
|
+
*
|
|
364
|
+
* msgCount is incremented only for visitor messages, so the owner's replies
|
|
365
|
+
* never consume the visitor's per-session message budget.
|
|
366
|
+
*/
|
|
367
|
+
async appendMessage(sessionId, message) {
|
|
368
|
+
const cursor = await redis.rpush(messagesKey(sessionId), message);
|
|
369
|
+
await redis.expire(messagesKey(sessionId), ttlSeconds);
|
|
370
|
+
const session = await this.getSession(sessionId);
|
|
371
|
+
if (session) {
|
|
372
|
+
await writeSession(sessionId, {
|
|
373
|
+
...session,
|
|
374
|
+
msgCount: message.from === "visitor" ? session.msgCount + 1 : session.msgCount
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return cursor;
|
|
378
|
+
},
|
|
379
|
+
async getMessagesSince(sessionId, since) {
|
|
380
|
+
const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;
|
|
381
|
+
const messages = await redis.lrange(
|
|
382
|
+
messagesKey(sessionId),
|
|
383
|
+
start,
|
|
384
|
+
-1
|
|
385
|
+
);
|
|
386
|
+
return { messages, cursor: start + messages.length };
|
|
387
|
+
},
|
|
388
|
+
/**
|
|
389
|
+
* INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is
|
|
390
|
+
* set only on that first hit. That keeps the window fixed rather than sliding
|
|
391
|
+
* forward on every request, which would make the limit unreachable.
|
|
392
|
+
*/
|
|
393
|
+
async incrementCounter(key, ttlSeconds2) {
|
|
394
|
+
const count = await redis.incr(key);
|
|
395
|
+
if (count === 1) await redis.expire(key, ttlSeconds2);
|
|
396
|
+
return count;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/stores/memory.ts
|
|
402
|
+
var inMemoryStoreKeysSymbol = /* @__PURE__ */ Symbol.for("contact-chat.inMemoryStore.keys");
|
|
403
|
+
function inMemoryStore(opts) {
|
|
404
|
+
const ttlSeconds = opts?.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;
|
|
405
|
+
const now = opts?.now ?? Date.now;
|
|
406
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
407
|
+
const topics = /* @__PURE__ */ new Map();
|
|
408
|
+
const messages = /* @__PURE__ */ new Map();
|
|
409
|
+
const counters = /* @__PURE__ */ new Map();
|
|
410
|
+
const sessionKey = (sessionId) => `chat:sess:${sessionId}`;
|
|
411
|
+
const topicKey = (topicId) => `chat:topic:${topicId}`;
|
|
412
|
+
const messagesKey = (sessionId) => `chat:msgs:${sessionId}`;
|
|
413
|
+
function writeSession(sessionId, session) {
|
|
414
|
+
const expiresAt = now() + ttlSeconds * 1e3;
|
|
415
|
+
sessions.set(sessionKey(sessionId), {
|
|
416
|
+
session: structuredClone(session),
|
|
417
|
+
expiresAt
|
|
418
|
+
});
|
|
419
|
+
topics.set(topicKey(session.topicId), sessionId);
|
|
420
|
+
}
|
|
421
|
+
function getStoredSession(sessionId) {
|
|
422
|
+
const stored = sessions.get(sessionKey(sessionId));
|
|
423
|
+
if (!stored) return null;
|
|
424
|
+
if (stored.expiresAt <= now()) {
|
|
425
|
+
sessions.delete(sessionKey(sessionId));
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return structuredClone(stored.session);
|
|
429
|
+
}
|
|
430
|
+
const store = {
|
|
431
|
+
async getSession(sessionId) {
|
|
432
|
+
return getStoredSession(sessionId);
|
|
433
|
+
},
|
|
434
|
+
async createSession(sessionId, topicId, name) {
|
|
435
|
+
const session = {
|
|
436
|
+
topicId,
|
|
437
|
+
createdAt: now(),
|
|
438
|
+
msgCount: 0,
|
|
439
|
+
name
|
|
440
|
+
};
|
|
441
|
+
writeSession(sessionId, session);
|
|
442
|
+
return structuredClone(session);
|
|
443
|
+
},
|
|
444
|
+
/**
|
|
445
|
+
* Adopts a name onto an existing session. Returns null when the session has
|
|
446
|
+
* expired between the caller's read and this write.
|
|
447
|
+
*/
|
|
448
|
+
async setSessionName(sessionId, name) {
|
|
449
|
+
const session = getStoredSession(sessionId);
|
|
450
|
+
if (!session) return null;
|
|
451
|
+
const next = { ...session, name };
|
|
452
|
+
writeSession(sessionId, next);
|
|
453
|
+
return structuredClone(next);
|
|
454
|
+
},
|
|
455
|
+
/**
|
|
456
|
+
* Points a session at a replacement topic, dropping the reverse mapping for
|
|
457
|
+
* the old one so a recycled thread id can never resolve to a stale session.
|
|
458
|
+
*/
|
|
459
|
+
async retopicSession(sessionId, session, topicId) {
|
|
460
|
+
if (session.topicId !== topicId) {
|
|
461
|
+
topics.delete(topicKey(session.topicId));
|
|
462
|
+
}
|
|
463
|
+
const next = { ...session, topicId };
|
|
464
|
+
writeSession(sessionId, next);
|
|
465
|
+
return structuredClone(next);
|
|
466
|
+
},
|
|
467
|
+
async resolveTopic(topicId) {
|
|
468
|
+
const key = topicKey(topicId);
|
|
469
|
+
const sessionId = topics.get(key);
|
|
470
|
+
if (!sessionId) return null;
|
|
471
|
+
const session = getStoredSession(sessionId);
|
|
472
|
+
if (!session) {
|
|
473
|
+
topics.delete(key);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
return sessionId;
|
|
477
|
+
},
|
|
478
|
+
/**
|
|
479
|
+
* Appends a message and returns the new cursor (the list length).
|
|
480
|
+
*
|
|
481
|
+
* msgCount is incremented only for visitor messages, so the owner's replies
|
|
482
|
+
* never consume the visitor's per-session message budget.
|
|
483
|
+
*/
|
|
484
|
+
async appendMessage(sessionId, message) {
|
|
485
|
+
const key = messagesKey(sessionId);
|
|
486
|
+
const expiresAt = now() + ttlSeconds * 1e3;
|
|
487
|
+
const stored = messages.get(key);
|
|
488
|
+
const existing = stored && stored.expiresAt > now() ? stored : null;
|
|
489
|
+
const list = existing ? [...existing.messages] : [];
|
|
490
|
+
list.push(structuredClone(message));
|
|
491
|
+
messages.set(key, { messages: list, expiresAt });
|
|
492
|
+
const session = getStoredSession(sessionId);
|
|
493
|
+
if (session) {
|
|
494
|
+
writeSession(sessionId, {
|
|
495
|
+
...session,
|
|
496
|
+
msgCount: message.from === "visitor" ? session.msgCount + 1 : session.msgCount
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return list.length;
|
|
500
|
+
},
|
|
501
|
+
async getMessagesSince(sessionId, since) {
|
|
502
|
+
const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;
|
|
503
|
+
const key = messagesKey(sessionId);
|
|
504
|
+
const stored = messages.get(key);
|
|
505
|
+
if (!stored || stored.expiresAt <= now()) {
|
|
506
|
+
return { messages: [], cursor: start };
|
|
507
|
+
}
|
|
508
|
+
const slice = stored.messages.slice(start);
|
|
509
|
+
return { messages: structuredClone(slice), cursor: start + slice.length };
|
|
510
|
+
},
|
|
511
|
+
/**
|
|
512
|
+
* INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is
|
|
513
|
+
* set only on that first hit. That keeps the window fixed rather than sliding
|
|
514
|
+
* forward on every request, which would make the limit unreachable.
|
|
515
|
+
*/
|
|
516
|
+
async incrementCounter(key, ttlSeconds2) {
|
|
517
|
+
const existing = counters.get(key);
|
|
518
|
+
if (!existing || existing.expiresAt <= now()) {
|
|
519
|
+
counters.set(key, { count: 1, expiresAt: now() + ttlSeconds2 * 1e3 });
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
existing.count++;
|
|
523
|
+
return existing.count;
|
|
524
|
+
},
|
|
525
|
+
/** Internal accessor for tests — not part of the ChatStore interface. */
|
|
526
|
+
[inMemoryStoreKeysSymbol]() {
|
|
527
|
+
return [
|
|
528
|
+
...sessions.keys(),
|
|
529
|
+
...topics.keys(),
|
|
530
|
+
...messages.keys(),
|
|
531
|
+
...counters.keys()
|
|
532
|
+
];
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
return store;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/transports/telegram.ts
|
|
539
|
+
var TelegramError = class extends Error {
|
|
540
|
+
constructor(message, description) {
|
|
541
|
+
super(message);
|
|
542
|
+
this.description = description;
|
|
543
|
+
}
|
|
544
|
+
description;
|
|
545
|
+
};
|
|
546
|
+
function isMissingThreadError(error) {
|
|
547
|
+
if (!(error instanceof TelegramError)) return false;
|
|
548
|
+
const detail = (error.description ?? error.message).toLowerCase();
|
|
549
|
+
return detail.includes("thread not found") || detail.includes("topic_deleted") || detail.includes("topic was deleted") || detail.includes("thread_not_found");
|
|
550
|
+
}
|
|
551
|
+
var API_BASE = "https://api.telegram.org";
|
|
552
|
+
function describeBrowser(userAgent) {
|
|
553
|
+
if (!userAgent) return "Unknown browser";
|
|
554
|
+
if (/edg\//i.test(userAgent)) return "Edge";
|
|
555
|
+
if (/opr\//i.test(userAgent)) return "Opera";
|
|
556
|
+
if (/chrome\//i.test(userAgent)) return "Chrome";
|
|
557
|
+
if (/firefox\//i.test(userAgent)) return "Firefox";
|
|
558
|
+
if (/safari\//i.test(userAgent)) return "Safari";
|
|
559
|
+
return "Unknown browser";
|
|
560
|
+
}
|
|
561
|
+
function buildTopicName(input) {
|
|
562
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
563
|
+
timeZone: "Europe/Kyiv",
|
|
564
|
+
day: "2-digit",
|
|
565
|
+
month: "2-digit",
|
|
566
|
+
hour: "2-digit",
|
|
567
|
+
minute: "2-digit",
|
|
568
|
+
hour12: false
|
|
569
|
+
}).formatToParts(input.now);
|
|
570
|
+
const at = (type) => parts.find((part) => part.type === type)?.value ?? "00";
|
|
571
|
+
return [
|
|
572
|
+
input.name,
|
|
573
|
+
at("day"),
|
|
574
|
+
at("month"),
|
|
575
|
+
at("hour"),
|
|
576
|
+
at("minute"),
|
|
577
|
+
describeBrowser(input.userAgent)
|
|
578
|
+
].join("-").slice(0, 128);
|
|
579
|
+
}
|
|
580
|
+
function telegramTransport(opts) {
|
|
581
|
+
const {
|
|
582
|
+
botToken,
|
|
583
|
+
chatId,
|
|
584
|
+
webhookSecret,
|
|
585
|
+
topicIconColor = 13338331,
|
|
586
|
+
buildTopicName: buildTopicNameFn = buildTopicName,
|
|
587
|
+
fetch: fetchFn = globalThis.fetch
|
|
588
|
+
} = opts;
|
|
589
|
+
if (!botToken) {
|
|
590
|
+
throw new Error("telegramTransport: botToken is required.");
|
|
591
|
+
}
|
|
592
|
+
if (!chatId) {
|
|
593
|
+
throw new Error("telegramTransport: chatId is required.");
|
|
594
|
+
}
|
|
595
|
+
if (!webhookSecret) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
"telegramTransport: webhookSecret is required. Pass the same value you configured with Telegram via /setWebhook."
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
async function callApi(method, body) {
|
|
601
|
+
const response = await fetchFn(`${API_BASE}/bot${botToken}/${method}`, {
|
|
602
|
+
method: "POST",
|
|
603
|
+
headers: { "Content-Type": "application/json" },
|
|
604
|
+
body: JSON.stringify(body),
|
|
605
|
+
cache: "no-store"
|
|
606
|
+
});
|
|
607
|
+
const payload = await response.json();
|
|
608
|
+
if (!response.ok || !payload.ok) {
|
|
609
|
+
throw new TelegramError(
|
|
610
|
+
`${method} failed: ${payload.description ?? response.status}`,
|
|
611
|
+
payload.description
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return payload.result;
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
async createThread(input) {
|
|
618
|
+
const name = buildTopicNameFn(input);
|
|
619
|
+
const result = await callApi(
|
|
620
|
+
"createForumTopic",
|
|
621
|
+
{
|
|
622
|
+
chat_id: chatId,
|
|
623
|
+
name: name.slice(0, 128),
|
|
624
|
+
// Telegram snaps icon_color to a fixed six-colour palette and silently
|
|
625
|
+
// substitutes the nearest allowed value. 0xCB86DB is the purple one;
|
|
626
|
+
// an arbitrary brand hex such as 0x9333EA comes back as blue.
|
|
627
|
+
icon_color: topicIconColor
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
return result.message_thread_id;
|
|
631
|
+
},
|
|
632
|
+
async send(threadId, text) {
|
|
633
|
+
await callApi("sendMessage", {
|
|
634
|
+
chat_id: chatId,
|
|
635
|
+
message_thread_id: threadId,
|
|
636
|
+
text
|
|
637
|
+
});
|
|
638
|
+
},
|
|
639
|
+
/**
|
|
640
|
+
* Retitles an existing topic. Needed when a session created before the name
|
|
641
|
+
* gate shipped supplies a name for the first time — the topic already exists,
|
|
642
|
+
* so it has to be renamed rather than created.
|
|
643
|
+
*/
|
|
644
|
+
async renameThread(threadId, input) {
|
|
645
|
+
const name = buildTopicNameFn(input);
|
|
646
|
+
await callApi("editForumTopic", {
|
|
647
|
+
chat_id: chatId,
|
|
648
|
+
message_thread_id: threadId,
|
|
649
|
+
name: name.slice(0, 128)
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
isMissingThreadError,
|
|
653
|
+
/**
|
|
654
|
+
* Narrows an inbound webhook update to an owner reply, or null.
|
|
655
|
+
*
|
|
656
|
+
* Every inbound filter lives here: not a message, sent by a bot, missing a
|
|
657
|
+
* message_thread_id (i.e. posted in the group's General area rather than a
|
|
658
|
+
* topic), or from an unexpected chat all return null.
|
|
659
|
+
*/
|
|
660
|
+
parseInboundReply(update) {
|
|
661
|
+
if (typeof update !== "object" || update === null) return null;
|
|
662
|
+
const message = update.message;
|
|
663
|
+
if (typeof message !== "object" || message === null) return null;
|
|
664
|
+
const {
|
|
665
|
+
message_id: messageId,
|
|
666
|
+
message_thread_id: threadId,
|
|
667
|
+
text,
|
|
668
|
+
chat,
|
|
669
|
+
from
|
|
670
|
+
} = message;
|
|
671
|
+
if (from?.is_bot === true) return null;
|
|
672
|
+
if (typeof threadId !== "number") return null;
|
|
673
|
+
if (typeof messageId !== "number") return null;
|
|
674
|
+
if (typeof text !== "string" || text.trim().length === 0) return null;
|
|
675
|
+
if (!chatId || String(chat?.id) !== chatId) return null;
|
|
676
|
+
return { threadId, text, messageId };
|
|
677
|
+
},
|
|
678
|
+
verifyWebhook(request) {
|
|
679
|
+
const header = request.headers.get("x-telegram-bot-api-secret-token");
|
|
680
|
+
if (!header) return false;
|
|
681
|
+
return header === webhookSecret;
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export { DEFAULT_CHAT_LIMITS, DEFAULT_CHAT_SERVER_COPY, MAX_NAME_LENGTH, TelegramError, buildTopicName, checkRateLimit, createChatServer, describeBrowser, getClientIp, hashIp, inMemoryStore, resolveConfig, sanitizeName, telegramTransport, upstashRedisStore };
|
|
687
|
+
//# sourceMappingURL=index.js.map
|
|
688
|
+
//# sourceMappingURL=index.js.map
|